Compare commits

..
168 changed files with 4474 additions and 15025 deletions
@@ -1,27 +0,0 @@
"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 }}
+42 -38
View File
@@ -1,8 +1,12 @@
# OpenCode Monorepo Agent Guide
- 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.
This file is for coding agents working in `/Users/ryanvogel/dev/opencode`.
## Style Guide
## Scope And Precedence
### General Principles
- Keep things in one function unless composable or reusable
- Avoid `try`/`catch` where possible
@@ -52,48 +56,48 @@ else foo = 2
### Control Flow
- Prefer early returns over nested `else` blocks.
- Keep functions focused; split only when it improves reuse or readability.
Avoid `else` statements. Prefer early returns.
### Error Handling
```ts
// Good
function foo() {
if (condition) return 1
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.
// Bad
function foo() {
if (condition) return 1
else return 2
}
```
### Data / DB
### Schema Definitions (Drizzle)
- 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`.
Use snake_case for field names so column names don't need to be redefined as strings.
### Testing Philosophy
```ts
// Good
const table = sqliteTable("session", {
id: text().primaryKey(),
project_id: text().notNull(),
created_at: integer().notNull(),
})
- Prefer testing real behavior over mocks.
- Add regression tests for bug fixes where practical.
- Keep fixtures small and focused.
// Bad
const table = sqliteTable("session", {
id: text("id").primaryKey(),
projectID: text("project_id").notNull(),
createdAt: integer("created_at").notNull(),
})
```
## Agent Workflow Tips
## Testing
- 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.
- 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`.
## Known Operational Notes
## Type Checking
- `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.
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
+73 -1547
View File
File diff suppressed because it is too large Load Diff
View File
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-i9TxYwWkJAR+kW6pbvhgQbRW9UYPtdrPQAGic4zPoa4=",
"aarch64-linux": "sha256-RYc/OYlETXUwkWBRDas+/P4cBW6zde4FqxxnMARu5vs=",
"aarch64-darwin": "sha256-jIhUOIRIQEa2WT62TVIedmRIhl/edhK8sbiAFvU3yCM=",
"x86_64-darwin": "sha256-xLGzaX7OofFlZzVgpORJR5QXD2u+54hp+t3cCfUtO84="
"x86_64-linux": "sha256-YcVW8AGN3TP34CoBoCw+Fx30RL1aveNvxr5eoeOgYeg=",
"aarch64-linux": "sha256-G/J3YFfrpEwXSHa25kNyUhYpwPhzNIZf/4v+RCfuslk=",
"aarch64-darwin": "sha256-dNPYrGWKoafk4rHqc34U34TtiJGk87yUv5tKnliQcWs=",
"x86_64-darwin": "sha256-1LStvefCajGkbdXobMpk0IQyw9SQcQgGKE+U3Fc0Osw="
}
}
-11
View File
@@ -1,11 +0,0 @@
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
-106
View File
@@ -1,106 +0,0 @@
# 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.
-14
View File
@@ -1,14 +0,0 @@
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"]
-46
View File
@@ -1,46 +0,0 @@
# 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
```
-17
View File
@@ -1,17 +0,0 @@
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,
},
},
})
-27
View File
@@ -1,27 +0,0 @@
{
"$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"
}
}
-185
View File
@@ -1,185 +0,0 @@
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,
}
}
-28
View File
@@ -1,28 +0,0 @@
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)
})
-11
View File
@@ -1,11 +0,0 @@
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 })
-47
View File
@@ -1,47 +0,0 @@
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
-5
View File
@@ -1,5 +0,0 @@
import { createHash } from "node:crypto"
export function hash(input: string) {
return createHash("sha256").update(input).digest("hex")
}
-448
View File
@@ -1,448 +0,0 @@
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 }
-35
View File
@@ -1,35 +0,0 @@
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),
],
)
-34
View File
@@ -1,34 +0,0 @@
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;
`)
}
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
}
}
@@ -8,20 +8,14 @@ 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<{ defaultTab?: string }> = (props) => {
export const DialogSettings: Component = () => {
const language = useLanguage()
const platform = usePlatform()
return (
<Dialog size="x-large" transition>
<Tabs
orientation="vertical"
variant="settings"
defaultValue={props.defaultTab ?? "general"}
class="h-full settings-dialog"
>
<Tabs orientation="vertical" variant="settings" defaultValue="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">
@@ -51,10 +45,6 @@ export const DialogSettings: Component<{ defaultTab?: string }> = (props) => {
<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>
@@ -77,9 +67,6 @@ export const DialogSettings: Component<{ defaultTab?: string }> = (props) => {
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="pair" class="no-scrollbar">
<SettingsPair />
</Tabs.Content>
</Tabs>
</Dialog>
)
@@ -1,166 +0,0 @@
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>
)
}
-15
View File
@@ -28,7 +28,6 @@ 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",
@@ -871,20 +870,6 @@ 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",
+2 -9
View File
@@ -1061,13 +1061,6 @@ 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"),
@@ -1220,11 +1213,11 @@ export default function Layout(props: ParentProps) {
})
}
function openSettings(defaultTab?: string) {
function openSettings() {
const run = ++dialogRun
void import("@/components/dialog-settings").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSettings defaultTab={defaultTab} />)
dialog.show(() => <x.DialogSettings />)
})
}
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.4.11",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"test": "bun test",
"typecheck": "tsgo --noEmit"
},
"bin": {
"opencode": "./bin/opencode"
},
"exports": {
"./*": "./src/*.ts"
},
"imports": {},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/semver": "catalog:",
"@types/bun": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3"
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@npmcli/arborist": "catalog:",
"effect": "catalog:",
"glob": "13.0.5",
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"semver": "catalog:",
"xdg-basedir": "5.1.0",
"zod": "catalog:"
},
"overrides": {
"drizzle-orm": "catalog:"
}
}
+2
View File
@@ -0,0 +1,2 @@
import { Layer } from "effect"
export const memoMap = Layer.makeMemoMapUnsafe()
+107
View File
@@ -0,0 +1,107 @@
import { Effect, Layer, Logger } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
import * as EffectLogger from "./logger"
import { Flag } from "@/flag/flag"
import { InstallationChannel, InstallationVersion } from "@/installation/version"
import { ensureProcessMetadata } from "@/util/opencode-process"
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
const processID = crypto.randomUUID()
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, x) => {
const [key, ...value] = x.split("=")
acc[key] = value.join("=")
return acc
},
{} as Record<string, string>,
)
: undefined
export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
const processMetadata = ensureProcessMetadata("main")
const attributes: Record<string, string> = (() => {
const value = process.env.OTEL_RESOURCE_ATTRIBUTES
if (!value) return {}
try {
return Object.fromEntries(
value.split(",").map((entry) => {
const index = entry.indexOf("=")
if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry")
return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))]
}),
)
} catch {
return {}
}
})()
return {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
...attributes,
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
"opencode.process_role": processMetadata.processRole,
"opencode.run_id": processMetadata.runID,
"service.instance.id": processID,
},
}
}
function logs() {
return Logger.layer(
[
EffectLogger.logger,
OtlpLogger.make({
url: `${base}/v1/logs`,
resource: resource(),
headers,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
}
const traces = async () => {
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
const SdkBase = await import("@opentelemetry/sdk-trace-base")
// @effect/opentelemetry creates a NodeTracerProvider but never calls
// register(), so the global @opentelemetry/api context manager stays
// as the no-op default. Non-Effect code (like the AI SDK) that calls
// tracer.startActiveSpan() relies on context.active() to find the
// parent span — without a real context manager every span starts a
// new trace. Registering AsyncLocalStorageContextManager fixes this.
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
const { context } = await import("@opentelemetry/api")
const mgr = new AsyncLocalStorageContextManager()
mgr.enable()
context.setGlobalContextManager(mgr)
return NodeSdk.layer(() => ({
resource: resource(),
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${base}/v1/traces`,
headers,
}),
),
}))
}
export const layer = !base
? EffectLogger.layer
: Layer.unwrap(
Effect.gen(function* () {
const trace = yield* Effect.promise(traces)
return Layer.mergeAll(trace, logs())
}),
)
export const Observability = { enabled, layer }
+19
View File
@@ -0,0 +1,19 @@
import { Observability } from "./observability"
import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
import { memoMap } from "./memo-map"
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
const getRuntime = () =>
(rt ??= ManagedRuntime.make(Layer.provideMerge(layer, Observability.layer) as Layer.Layer<I, E>, { memoMap }))
return {
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
getRuntime().runPromiseExit(service.use(fn), options),
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
getRuntime().runPromise(service.use(fn), options),
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
}
}
+237
View File
@@ -0,0 +1,237 @@
export * as AppFileSystem from "./filesystem.js"
import { NodeFileSystem } from "@effect/platform-node"
import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs"
import * as NFS from "fs/promises"
import { lookup } from "mime-types"
import { Effect, FileSystem, Layer, Schema, Context } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { Glob } from "./util/glob.js"
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
method: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export type Error = PlatformError | FileSystemError
export interface DirEntry {
readonly name: string
readonly type: "file" | "directory" | "symlink" | "other"
}
export interface Interface extends FileSystem.FileSystem {
readonly isDir: (path: string) => Effect.Effect<boolean>
readonly isFile: (path: string) => Effect.Effect<boolean>
readonly existsSafe: (path: string) => Effect.Effect<boolean>
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
readonly globMatch: (pattern: string, filepath: string) => boolean
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
})
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
return info?.type === "Directory"
})
const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
return info?.type === "File"
})
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
return yield* Effect.tryPromise({
try: async () => {
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
return entries.map(
(e): DirEntry => ({
name: e.name,
type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
}),
)
},
catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
})
})
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
const text = yield* fs.readFileString(path)
return JSON.parse(text)
})
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
const content = JSON.stringify(data, null, 2)
yield* fs.writeFileString(path, content)
if (mode) yield* fs.chmod(path, mode)
})
const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
yield* fs.makeDirectory(path, { recursive: true })
})
const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
path: string,
content: string | Uint8Array,
mode?: number,
) {
const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
yield* write.pipe(
Effect.catchIf(
(e) => e.reason._tag === "NotFound",
() =>
Effect.gen(function* () {
yield* fs.makeDirectory(dirname(path), { recursive: true })
yield* write
}),
),
)
if (mode) yield* fs.chmod(path, mode)
})
const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
return yield* Effect.tryPromise({
try: () => Glob.scan(pattern, options),
catch: (cause) => new FileSystemError({ method: "glob", cause }),
})
})
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
const result: string[] = []
let current = start
while (true) {
const search = join(current, target)
if (yield* fs.exists(search)) result.push(search)
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
const result: string[] = []
let current = options.start
while (true) {
for (const target of options.targets) {
const search = join(current, target)
if (yield* fs.exists(search)) result.push(search)
}
if (options.stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
const result: string[] = []
let current = start
while (true) {
const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
Effect.catch(() => Effect.succeed([] as string[])),
)
result.push(...matches)
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
return result
})
return Service.of({
...fs,
existsSafe,
isDir,
isFile,
readDirectoryEntries,
readJson,
writeJson,
ensureDir,
writeWithDirs,
findUp,
up,
globUp,
glob,
globMatch: Glob.match,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
// Pure helpers that don't need Effect (path manipulation, sync operations)
export function mimeType(p: string): string {
return lookup(p) || "application/octet-stream"
}
export function normalizePath(p: string): string {
if (process.platform !== "win32") return p
const resolved = pathResolve(windowsPath(p))
try {
return realpathSync.native(resolved)
} catch {
return resolved
}
}
export function normalizePathPattern(p: string): string {
if (process.platform !== "win32") return p
if (p === "*") return p
const match = p.match(/^(.*)[\\/]\*$/)
if (!match) return normalizePath(p)
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
return join(normalizePath(dir), "*")
}
export function resolve(p: string): string {
const resolved = pathResolve(windowsPath(p))
try {
return normalizePath(realpathSync(resolved))
} catch (e: any) {
if (e?.code === "ENOENT") return normalizePath(resolved)
throw e
}
}
export function windowsPath(p: string): string {
if (process.platform !== "win32") return p
return p
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
}
export function overlaps(a: string, b: string) {
const relA = relative(a, b)
const relB = relative(b, a)
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
}
export function contains(parent: string, child: string) {
return !relative(parent, child).startsWith("..")
}
+42
View File
@@ -0,0 +1,42 @@
export * as Global from "./global.js"
import path from "path"
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import os from "os"
import { Context, Effect, Layer } from "effect"
export class Service extends Context.Service<Service, Interface>()("@opencode/Global") {}
export interface Interface {
readonly home: string
readonly data: string
readonly cache: string
readonly config: string
readonly state: string
readonly bin: string
readonly log: string
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const app = "opencode"
const home = process.env.OPENCODE_TEST_HOME ?? os.homedir()
const data = path.join(xdgData!, app)
const cache = path.join(xdgCache!, app)
const cfg = path.join(xdgConfig!, app)
const state = path.join(xdgState!, app)
const bin = path.join(cache, "bin")
const log = path.join(data, "log")
return Service.of({
home,
data,
cache,
config: cfg,
state,
bin,
log,
})
}),
)
+294
View File
@@ -0,0 +1,294 @@
export * as Npm from "./npm.js"
import path from "path"
import npa from "npm-package-arg"
import semver from "semver"
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "./filesystem.js"
import { Global } from "./global.js"
import { EffectFlock } from "./util/effect-flock.js"
import { makeRuntime } from "../effect/runtime"
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional),
dir: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface EntryPoint {
readonly directory: string
readonly entrypoint: Option.Option<string>
}
export interface Interface {
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly install: (
dir: string,
input?: {
add: {
name: string
version?: string
}[]
},
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
export function sanitize(pkg: string) {
if (!illegal) return pkg
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
}
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
let entrypoint: Option.Option<string>
try {
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
entrypoint = Option.some(resolved)
} catch {
entrypoint = Option.none()
}
return {
directory: dir,
entrypoint,
}
}
interface ArboristNode {
name: string
path: string
}
interface ArboristTree {
edgesOut: Map<string, { to?: ArboristNode }>
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const afs = yield* AppFileSystem.Service
const global = yield* Global.Service
const fs = yield* FileSystem.FileSystem
const flock = yield* EffectFlock.Service
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
const reify = (input: { dir: string; add?: string[] }) =>
Effect.gen(function* () {
yield* flock.acquire(`npm-install:${input.dir}`)
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const arborist = new Arborist({
path: input.dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
return yield* Effect.tryPromise({
try: () =>
arborist.reify({
add: input?.add || [],
save: true,
saveType: "prod",
}),
catch: (cause) =>
new InstallFailedError({
cause,
add: input?.add,
dir: input.dir,
}),
}) as Effect.Effect<ArboristTree, InstallFailedError>
}).pipe(
Effect.withSpan("Npm.reify", {
attributes: input,
}),
)
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
const response = yield* Effect.tryPromise({
try: () => fetch(`https://registry.npmjs.org/${pkg}`),
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => undefined))
if (!response || !response.ok) {
return false
}
const data = yield* Effect.tryPromise({
try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => undefined))
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
return false
}
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
})
const add = Effect.fn("Npm.add")(function* (pkg: string) {
const dir = directory(pkg)
const name = (() => {
try {
return npa(pkg).name ?? pkg
} catch {
return pkg
}
})()
if (yield* afs.existsSafe(dir)) {
return resolveEntryPoint(name, path.join(dir, "node_modules", name))
}
const tree = yield* reify({ dir, add: [pkg] })
const first = tree.edgesOut.values().next().value?.to
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
return resolveEntryPoint(first.name, first.path)
}, Effect.scoped)
const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) {
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
if (!canWrite) return
const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
if (
yield* Effect.gen(function* () {
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
if (!nodeModulesExists) {
yield* reify({ add, dir })
return true
}
return false
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
)
return
yield* Effect.gen(function* () {
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
const pkgAny = pkg as any
const lockAny = lock as any
const declared = new Set([
...Object.keys(pkgAny?.dependencies || {}),
...Object.keys(pkgAny?.devDependencies || {}),
...Object.keys(pkgAny?.peerDependencies || {}),
...Object.keys(pkgAny?.optionalDependencies || {}),
...(input?.add || []).map((pkg) => pkg.name),
])
const root = lockAny?.packages?.[""] || {}
const locked = new Set([
...Object.keys(root?.dependencies || {}),
...Object.keys(root?.devDependencies || {}),
...Object.keys(root?.peerDependencies || {}),
...Object.keys(root?.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
yield* reify({ dir, add })
return
}
}
}).pipe(Effect.withSpan("Npm.checkDirty"))
return
}, Effect.scoped)
const which = Effect.fn("Npm.which")(function* (pkg: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
const pick = Effect.fnUntraced(function* () {
const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
if (files.length === 0) return Option.none<string>()
if (files.length === 1) return Option.some(files[0])
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
if (Option.isSome(pkgJson)) {
const parsed = pkgJson.value as { bin?: string | Record<string, string> }
if (parsed?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const bin = parsed.bin
if (typeof bin === "string") return Option.some(unscoped)
const keys = Object.keys(bin)
if (keys.length === 1) return Option.some(keys[0])
return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
}
}
return Option.some(files[0])
})
return yield* Effect.gen(function* () {
const bin = yield* pick()
if (Option.isSome(bin)) {
return Option.some(path.join(binDir, bin.value))
}
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
yield* add(pkg)
const resolved = yield* pick()
if (Option.isNone(resolved)) return Option.none<string>()
return Option.some(path.join(binDir, resolved.value))
}).pipe(
Effect.scoped,
Effect.orElseSucceed(() => Option.none<string>()),
)
})
return Service.of({
add,
install,
outdated,
which,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(EffectFlock.layer),
Layer.provide(AppFileSystem.layer),
Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function install(...args: Parameters<Interface["install"]>) {
return runPromise((svc) => svc.install(...args))
}
export async function add(...args: Parameters<Interface["add"]>) {
const entry = await runPromise((svc) => svc.add(...args))
return {
directory: entry.directory,
entrypoint: Option.getOrUndefined(entry.entrypoint),
}
}
export async function outdated(...args: Parameters<Interface["outdated"]>) {
return runPromise((svc) => svc.outdated(...args))
}
export async function which(...args: Parameters<Interface["which"]>) {
const resolved = await runPromise((svc) => svc.which(...args))
return Option.getOrUndefined(resolved)
}
+268
View File
@@ -0,0 +1,268 @@
import path from "path"
import npa from "npm-package-arg"
import semver from "semver"
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "../filesystem"
import { Global } from "../global"
import { EffectFlock } from "../util/effect-flock"
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional),
dir: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface EntryPoint {
readonly directory: string
readonly entrypoint: Option.Option<string>
}
export interface Interface {
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly install: (
dir: string,
input?: {
add: {
name: string
version?: string
}[]
},
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
export function sanitize(pkg: string) {
if (!illegal) return pkg
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
}
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
let entrypoint: Option.Option<string>
try {
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
entrypoint = Option.some(resolved)
} catch {
entrypoint = Option.none()
}
return {
directory: dir,
entrypoint,
}
}
interface ArboristNode {
name: string
path: string
}
interface ArboristTree {
edgesOut: Map<string, { to?: ArboristNode }>
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const afs = yield* AppFileSystem.Service
const global = yield* Global.Service
const fs = yield* FileSystem.FileSystem
const flock = yield* EffectFlock.Service
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
const reify = (input: { dir: string; add?: string[] }) =>
Effect.gen(function* () {
yield* flock.acquire(`npm-install:${input.dir}`)
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const arborist = new Arborist({
path: input.dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
return yield* Effect.tryPromise({
try: () =>
arborist.reify({
add: input?.add || [],
save: true,
saveType: "prod",
}),
catch: (cause) =>
new InstallFailedError({
cause,
add: input?.add,
dir: input.dir,
}),
}) as Effect.Effect<ArboristTree, InstallFailedError>
}).pipe(
Effect.withSpan("Npm.reify", {
attributes: input,
}),
)
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
const response = yield* Effect.tryPromise({
try: () => fetch(`https://registry.npmjs.org/${pkg}`),
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => undefined))
if (!response || !response.ok) {
return false
}
const data = yield* Effect.tryPromise({
try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => undefined))
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
return false
}
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
})
const add = Effect.fn("Npm.add")(function* (pkg: string) {
const dir = directory(pkg)
const name = (() => {
try {
return npa(pkg).name ?? pkg
} catch {
return pkg
}
})()
if (yield* afs.existsSafe(dir)) {
return resolveEntryPoint(name, path.join(dir, "node_modules", name))
}
const tree = yield* reify({ dir, add: [pkg] })
const first = tree.edgesOut.values().next().value?.to
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
return resolveEntryPoint(first.name, first.path)
}, Effect.scoped)
const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) {
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
if (!canWrite) return
const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
if (
yield* Effect.gen(function* () {
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
if (!nodeModulesExists) {
yield* reify({ add, dir })
return true
}
return false
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
)
return
yield* Effect.gen(function* () {
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
const pkgAny = pkg as any
const lockAny = lock as any
const declared = new Set([
...Object.keys(pkgAny?.dependencies || {}),
...Object.keys(pkgAny?.devDependencies || {}),
...Object.keys(pkgAny?.peerDependencies || {}),
...Object.keys(pkgAny?.optionalDependencies || {}),
...(input?.add || []).map((pkg) => pkg.name),
])
const root = lockAny?.packages?.[""] || {}
const locked = new Set([
...Object.keys(root?.dependencies || {}),
...Object.keys(root?.devDependencies || {}),
...Object.keys(root?.peerDependencies || {}),
...Object.keys(root?.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
yield* reify({ dir, add })
return
}
}
}).pipe(Effect.withSpan("Npm.checkDirty"))
return
}, Effect.scoped)
const which = Effect.fn("Npm.which")(function* (pkg: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
const pick = Effect.fnUntraced(function* () {
const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
if (files.length === 0) return Option.none<string>()
if (files.length === 1) return Option.some(files[0])
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
if (Option.isSome(pkgJson)) {
const parsed = pkgJson.value as { bin?: string | Record<string, string> }
if (parsed?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const bin = parsed.bin
if (typeof bin === "string") return Option.some(unscoped)
const keys = Object.keys(bin)
if (keys.length === 1) return Option.some(keys[0])
return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
}
}
return Option.some(files[0])
})
return yield* Effect.gen(function* () {
const bin = yield* pick()
if (Option.isSome(bin)) {
return Option.some(path.join(binDir, bin.value))
}
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
yield* add(pkg)
const resolved = yield* pick()
if (Option.isNone(resolved)) return Option.none<string>()
return Option.some(path.join(binDir, resolved.value))
}).pipe(
Effect.scoped,
Effect.orElseSucceed(() => Option.none<string>()),
)
})
return Service.of({
add,
install,
outdated,
which,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(EffectFlock.layer),
Layer.provide(AppFileSystem.layer),
Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer),
)
export * as Npm from "."
+44
View File
@@ -0,0 +1,44 @@
declare module "@npmcli/arborist" {
export interface ArboristOptions {
path: string
binLinks?: boolean
progress?: boolean
savePrefix?: string
ignoreScripts?: boolean
}
export interface ArboristNode {
name: string
path: string
}
export interface ArboristEdge {
to?: ArboristNode
}
export interface ArboristTree {
edgesOut: Map<string, ArboristEdge>
}
export interface ReifyOptions {
add?: string[]
save?: boolean
saveType?: "prod" | "dev" | "optional" | "peer"
}
export class Arborist {
constructor(options: ArboristOptions)
loadVirtual(): Promise<ArboristTree | undefined>
reify(options?: ReifyOptions): Promise<ArboristTree>
}
}
declare var Bun:
| {
file(path: string): {
text(): Promise<string>
json(): Promise<unknown>
}
write(path: string, content: string | Uint8Array): Promise<void>
}
| undefined
+10
View File
@@ -0,0 +1,10 @@
export function findLast<T>(
items: readonly T[],
predicate: (item: T, index: number, items: readonly T[]) => boolean,
): T | undefined {
for (let i = items.length - 1; i >= 0; i -= 1) {
const item = items[i]
if (predicate(item, i, items)) return item
}
return undefined
}
+41
View File
@@ -0,0 +1,41 @@
export namespace Binary {
export function search<T>(array: T[], id: string, compare: (item: T) => string): { found: boolean; index: number } {
let left = 0
let right = array.length - 1
while (left <= right) {
const mid = Math.floor((left + right) / 2)
const midId = compare(array[mid])
if (midId === id) {
return { found: true, index: mid }
} else if (midId < id) {
left = mid + 1
} else {
right = mid - 1
}
}
return { found: false, index: left }
}
export function insert<T>(array: T[], item: T, compare: (item: T) => string): T[] {
const id = compare(item)
let left = 0
let right = array.length
while (left < right) {
const mid = Math.floor((left + right) / 2)
const midId = compare(array[mid])
if (midId < id) {
left = mid + 1
} else {
right = mid
}
}
array.splice(left, 0, item)
return array
}
}
+283
View File
@@ -0,0 +1,283 @@
import path from "path"
import os from "os"
import { randomUUID } from "crypto"
import { Context, Effect, Function, Layer, Option, Schedule, Schema } from "effect"
import type { FileSystem, Scope } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { AppFileSystem } from "../filesystem"
import { Global } from "../global"
import { Hash } from "./hash"
export namespace EffectFlock {
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
export class LockTimeoutError extends Schema.TaggedErrorClass<LockTimeoutError>()("LockTimeoutError", {
key: Schema.String,
}) {}
export class LockCompromisedError extends Schema.TaggedErrorClass<LockCompromisedError>()("LockCompromisedError", {
detail: Schema.String,
}) {}
class ReleaseError extends Schema.TaggedErrorClass<ReleaseError>()("ReleaseError", {
detail: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {
override get message() {
return this.detail
}
}
/** Internal: signals "lock is held, retry later". Never leaks to callers. */
class NotAcquired extends Schema.TaggedErrorClass<NotAcquired>()("NotAcquired", {}) {}
export type LockError = LockTimeoutError | LockCompromisedError
// ---------------------------------------------------------------------------
// Timing (baked in — no caller ever overrides these)
// ---------------------------------------------------------------------------
const STALE_MS = 60_000
const TIMEOUT_MS = 5 * 60_000
const BASE_DELAY_MS = 100
const MAX_DELAY_MS = 2_000
const HEARTBEAT_MS = Math.max(100, Math.floor(STALE_MS / 3))
const retrySchedule = Schedule.exponential(BASE_DELAY_MS, 1.7).pipe(
Schedule.either(Schedule.spaced(MAX_DELAY_MS)),
Schedule.jittered,
Schedule.while((meta) => meta.elapsed < TIMEOUT_MS),
)
// ---------------------------------------------------------------------------
// Lock metadata schema
// ---------------------------------------------------------------------------
const LockMetaJson = Schema.fromJsonString(
Schema.Struct({
token: Schema.String,
pid: Schema.Number,
hostname: Schema.String,
createdAt: Schema.String,
}),
)
const decodeMeta = Schema.decodeUnknownSync(LockMetaJson)
const encodeMeta = Schema.encodeSync(LockMetaJson)
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
export interface Interface {
readonly acquire: (key: string, dir?: string) => Effect.Effect<void, LockError, Scope.Scope>
readonly withLock: {
(key: string, dir?: string): <A, E, R>(body: Effect.Effect<A, E, R>) => Effect.Effect<A, E | LockError, R>
<A, E, R>(body: Effect.Effect<A, E, R>, key: string, dir?: string): Effect.Effect<A, E | LockError, R>
}
}
export class Service extends Context.Service<Service, Interface>()("EffectFlock") {}
// ---------------------------------------------------------------------------
// Layer
// ---------------------------------------------------------------------------
function wall() {
return performance.timeOrigin + performance.now()
}
const mtimeMs = (info: FileSystem.File.Info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime()
const isPathGone = (e: PlatformError) => e.reason._tag === "NotFound" || e.reason._tag === "Unknown"
export const layer: Layer.Layer<Service, never, Global.Service | AppFileSystem.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const global = yield* Global.Service
const fs = yield* AppFileSystem.Service
const lockRoot = path.join(global.state, "locks")
const hostname = os.hostname()
const ensuredDirs = new Set<string>()
// -- helpers (close over fs) --
const safeStat = (file: string) =>
fs.stat(file).pipe(
Effect.catchIf(isPathGone, () => Effect.void),
Effect.orDie,
)
const forceRemove = (target: string) => fs.remove(target, { recursive: true }).pipe(Effect.ignore)
/** Atomic mkdir — returns true if created, false if already exists, dies on other errors. */
const atomicMkdir = (dir: string) =>
fs.makeDirectory(dir, { mode: 0o700 }).pipe(
Effect.as(true),
Effect.catchIf(
(e) => e.reason._tag === "AlreadyExists",
() => Effect.succeed(false),
),
Effect.orDie,
)
/** Write with exclusive create — compromised error if file already exists. */
const exclusiveWrite = (filePath: string, content: string, lockDir: string, detail: string) =>
fs.writeFileString(filePath, content, { flag: "wx" }).pipe(
Effect.catch(() =>
Effect.gen(function* () {
yield* forceRemove(lockDir)
return yield* new LockCompromisedError({ detail })
}),
),
)
const cleanStaleBreaker = Effect.fnUntraced(function* (breakerPath: string) {
const bs = yield* safeStat(breakerPath)
if (bs && wall() - mtimeMs(bs) > STALE_MS) yield* forceRemove(breakerPath)
return false
})
const ensureDir = Effect.fnUntraced(function* (dir: string) {
if (ensuredDirs.has(dir)) return
yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
ensuredDirs.add(dir)
})
const isStale = Effect.fnUntraced(function* (lockDir: string, heartbeatPath: string, metaPath: string) {
const now = wall()
const hb = yield* safeStat(heartbeatPath)
if (hb) return now - mtimeMs(hb) > STALE_MS
const meta = yield* safeStat(metaPath)
if (meta) return now - mtimeMs(meta) > STALE_MS
const dir = yield* safeStat(lockDir)
if (!dir) return false
return now - mtimeMs(dir) > STALE_MS
})
// -- single lock attempt --
type Handle = { token: string; metaPath: string; heartbeatPath: string; lockDir: string }
const tryAcquireLockDir = (lockDir: string, key: string) =>
Effect.gen(function* () {
const token = randomUUID()
const metaPath = path.join(lockDir, "meta.json")
const heartbeatPath = path.join(lockDir, "heartbeat")
// Atomic mkdir — the POSIX lock primitive
const created = yield* atomicMkdir(lockDir)
if (!created) {
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return yield* new NotAcquired()
// Stale — race for breaker ownership
const breakerPath = lockDir + ".breaker"
const claimed = yield* fs.makeDirectory(breakerPath, { mode: 0o700 }).pipe(
Effect.as(true),
Effect.catchIf(
(e) => e.reason._tag === "AlreadyExists",
() => cleanStaleBreaker(breakerPath),
),
Effect.catchIf(isPathGone, () => Effect.succeed(false)),
Effect.orDie,
)
if (!claimed) return yield* new NotAcquired()
// We own the breaker — double-check staleness, nuke, recreate
const recreated = yield* Effect.gen(function* () {
if (!(yield* isStale(lockDir, heartbeatPath, metaPath))) return false
yield* forceRemove(lockDir)
return yield* atomicMkdir(lockDir)
}).pipe(Effect.ensuring(forceRemove(breakerPath)))
if (!recreated) return yield* new NotAcquired()
}
// We own the lock dir — write heartbeat + meta with exclusive create
yield* exclusiveWrite(heartbeatPath, "", lockDir, "heartbeat already existed")
const metaJson = encodeMeta({ token, pid: process.pid, hostname, createdAt: new Date().toISOString() })
yield* exclusiveWrite(metaPath, metaJson, lockDir, "meta.json already existed")
return { token, metaPath, heartbeatPath, lockDir } satisfies Handle
}).pipe(
Effect.withSpan("EffectFlock.tryAcquire", {
attributes: { key },
}),
)
// -- retry wrapper (preserves Handle type) --
const acquireHandle = (lockfile: string, key: string): Effect.Effect<Handle, LockError> =>
tryAcquireLockDir(lockfile, key).pipe(
Effect.retry({
while: (err) => err._tag === "NotAcquired",
schedule: retrySchedule,
}),
Effect.catchTag("NotAcquired", () => Effect.fail(new LockTimeoutError({ key }))),
)
// -- release --
const release = (handle: Handle) =>
Effect.gen(function* () {
const raw = yield* fs.readFileString(handle.metaPath).pipe(
Effect.catch((err) => {
if (isPathGone(err)) return Effect.die(new ReleaseError({ detail: "metadata missing" }))
return Effect.die(err)
}),
)
const parsed = yield* Effect.try({
try: () => decodeMeta(raw),
catch: (cause) => new ReleaseError({ detail: "metadata invalid", cause }),
}).pipe(Effect.orDie)
if (parsed.token !== handle.token) return yield* Effect.die(new ReleaseError({ detail: "token mismatch" }))
yield* forceRemove(handle.lockDir)
})
// -- build service --
const acquire = Effect.fn("EffectFlock.acquire")(function* (key: string, dir?: string) {
const lockDir = dir ?? lockRoot
yield* ensureDir(lockDir)
const lockfile = path.join(lockDir, Hash.fast(key) + ".lock")
// acquireRelease: acquire is uninterruptible, release is guaranteed
const handle = yield* Effect.acquireRelease(acquireHandle(lockfile, key), (handle) => release(handle))
// Heartbeat fiber — scoped, so it's interrupted before release runs
yield* fs
.utimes(handle.heartbeatPath, new Date(), new Date())
.pipe(Effect.ignore, Effect.repeat(Schedule.spaced(HEARTBEAT_MS)), Effect.forkScoped)
})
const withLock: Interface["withLock"] = Function.dual(
(args) => Effect.isEffect(args[0]),
<A, E, R>(body: Effect.Effect<A, E, R>, key: string, dir?: string): Effect.Effect<A, E | LockError, R> =>
Effect.scoped(
Effect.gen(function* () {
yield* acquire(key, dir)
return yield* body
}),
),
)
return Service.of({ acquire, withLock })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer))
}
+51
View File
@@ -0,0 +1,51 @@
export function base64Encode(value: string) {
const bytes = new TextEncoder().encode(value)
const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join("")
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
export function base64Decode(value: string) {
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"))
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
export async function hash(content: string, algorithm = "SHA-256"): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(content)
const hashBuffer = await crypto.subtle.digest(algorithm, data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
return hashHex
}
export function checksum(content: string): string | undefined {
if (!content) return undefined
let hash = 0x811c9dc5
for (let i = 0; i < content.length; i++) {
hash ^= content.charCodeAt(i)
hash = Math.imul(hash, 0x01000193)
}
return (hash >>> 0).toString(36)
}
export function sampledChecksum(content: string, limit = 500_000): string | undefined {
if (!content) return undefined
if (content.length <= limit) return checksum(content)
const size = 4096
const points = [
0,
Math.floor(content.length * 0.25),
Math.floor(content.length * 0.5),
Math.floor(content.length * 0.75),
content.length - size,
]
const hashes = points
.map((point) => {
const start = Math.max(0, Math.min(content.length - size, point - Math.floor(size / 2)))
return checksum(content.slice(start, start + size)) ?? ""
})
.join(":")
return `${content.length}:${hashes}`
}
+60
View File
@@ -0,0 +1,60 @@
import z from "zod"
export abstract class NamedError extends Error {
abstract schema(): z.core.$ZodType
abstract toObject(): { name: string; data: any }
static hasName(error: unknown, name: string): boolean {
return (
typeof error === "object" && error !== null && "name" in error && (error as Record<string, unknown>).name === name
)
}
static create<Name extends string, Data extends z.core.$ZodType>(name: Name, data: Data) {
const schema = z
.object({
name: z.literal(name),
data,
})
.meta({
ref: name,
})
const result = class extends NamedError {
public static readonly Schema = schema
public override readonly name = name as Name
constructor(
public readonly data: z.input<Data>,
options?: ErrorOptions,
) {
super(name, options)
this.name = name
}
static isInstance(input: any): input is InstanceType<typeof result> {
return typeof input === "object" && "name" in input && input.name === name
}
schema() {
return schema
}
toObject() {
return {
name: name,
data: this.data,
}
}
}
Object.defineProperty(result, "name", { value: name })
return result
}
public static readonly Unknown = NamedError.create(
"UnknownError",
z.object({
message: z.string(),
}),
)
}
+358
View File
@@ -0,0 +1,358 @@
import path from "path"
import os from "os"
import { randomBytes, randomUUID } from "crypto"
import { mkdir, readFile, rm, stat, utimes, writeFile } from "fs/promises"
import { Hash } from "./hash"
import { Effect } from "effect"
export type FlockGlobal = {
state: string
}
export namespace Flock {
let global: FlockGlobal | undefined
export function setGlobal(g: FlockGlobal) {
global = g
}
const root = () => {
if (!global) throw new Error("Flock global not set")
return path.join(global.state, "locks")
}
// Defaults for callers that do not provide timing options.
const defaultOpts = {
staleMs: 60_000,
timeoutMs: 5 * 60_000,
baseDelayMs: 100,
maxDelayMs: 2_000,
}
export interface WaitEvent {
key: string
attempt: number
delay: number
waited: number
}
export type Wait = (input: WaitEvent) => void | Promise<void>
export interface Options {
dir?: string
signal?: AbortSignal
staleMs?: number
timeoutMs?: number
baseDelayMs?: number
maxDelayMs?: number
onWait?: Wait
}
type Opts = {
staleMs: number
timeoutMs: number
baseDelayMs: number
maxDelayMs: number
}
type Owned = {
acquired: true
startHeartbeat: (intervalMs?: number) => void
release: () => Promise<void>
}
export interface Lease {
release: () => Promise<void>
[Symbol.asyncDispose]: () => Promise<void>
}
function code(err: unknown) {
if (typeof err !== "object" || err === null || !("code" in err)) return
const value = err.code
if (typeof value !== "string") return
return value
}
function sleep(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason ?? new Error("Aborted"))
return
}
let timer: NodeJS.Timeout | undefined
const done = () => {
signal?.removeEventListener("abort", abort)
resolve()
}
const abort = () => {
if (timer) {
clearTimeout(timer)
}
signal?.removeEventListener("abort", abort)
reject(signal?.reason ?? new Error("Aborted"))
}
signal?.addEventListener("abort", abort, { once: true })
timer = setTimeout(done, ms)
})
}
function jitter(ms: number) {
const j = Math.floor(ms * 0.3)
const d = Math.floor(Math.random() * (2 * j + 1)) - j
return Math.max(0, ms + d)
}
function mono() {
return performance.now()
}
function wall() {
return performance.timeOrigin + mono()
}
async function stats(file: string) {
try {
return await stat(file)
} catch (err) {
const errCode = code(err)
if (errCode === "ENOENT" || errCode === "ENOTDIR") return
throw err
}
}
async function stale(lockDir: string, heartbeatPath: string, metaPath: string, staleMs: number) {
// Stale detection allows automatic recovery after crashed owners.
const now = wall()
const heartbeat = await stats(heartbeatPath)
if (heartbeat) {
return now - heartbeat.mtimeMs > staleMs
}
const meta = await stats(metaPath)
if (meta) {
return now - meta.mtimeMs > staleMs
}
const dir = await stats(lockDir)
if (!dir) {
return false
}
return now - dir.mtimeMs > staleMs
}
async function tryAcquireLockDir(lockDir: string, opts: Opts): Promise<Owned | { acquired: false }> {
const token = randomUUID?.() ?? randomBytes(16).toString("hex")
const metaPath = path.join(lockDir, "meta.json")
const heartbeatPath = path.join(lockDir, "heartbeat")
try {
await mkdir(lockDir, { mode: 0o700 })
} catch (err) {
if (code(err) !== "EEXIST") {
throw err
}
if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) {
return { acquired: false }
}
const breakerPath = lockDir + ".breaker"
try {
await mkdir(breakerPath, { mode: 0o700 })
} catch (claimErr) {
const errCode = code(claimErr)
if (errCode === "EEXIST") {
const breaker = await stats(breakerPath)
if (breaker && wall() - breaker.mtimeMs > opts.staleMs) {
await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined)
}
return { acquired: false }
}
if (errCode === "ENOENT" || errCode === "ENOTDIR") {
return { acquired: false }
}
throw claimErr
}
try {
// Breaker ownership ensures only one contender performs stale cleanup.
if (!(await stale(lockDir, heartbeatPath, metaPath, opts.staleMs))) {
return { acquired: false }
}
await rm(lockDir, { recursive: true, force: true })
try {
await mkdir(lockDir, { mode: 0o700 })
} catch (retryErr) {
const errCode = code(retryErr)
if (errCode === "EEXIST" || errCode === "ENOTEMPTY") {
return { acquired: false }
}
throw retryErr
}
} finally {
await rm(breakerPath, { recursive: true, force: true }).catch(() => undefined)
}
}
const meta = {
token,
pid: process.pid,
hostname: os.hostname(),
createdAt: new Date().toISOString(),
}
await writeFile(heartbeatPath, "", { flag: "wx" }).catch(async () => {
await rm(lockDir, { recursive: true, force: true })
throw new Error("Lock acquired but heartbeat already existed (possible compromise).")
})
await writeFile(metaPath, JSON.stringify(meta, null, 2), { flag: "wx" }).catch(async () => {
await rm(lockDir, { recursive: true, force: true })
throw new Error("Lock acquired but meta.json already existed (possible compromise).")
})
let timer: NodeJS.Timeout | undefined
const startHeartbeat = (intervalMs = Math.max(100, Math.floor(opts.staleMs / 3))) => {
if (timer) return
// Heartbeat prevents long critical sections from being evicted as stale.
timer = setInterval(() => {
const t = new Date()
void utimes(heartbeatPath, t, t).catch(() => undefined)
}, intervalMs)
timer.unref?.()
}
const release = async () => {
if (timer) {
clearInterval(timer)
timer = undefined
}
const current = await readFile(metaPath, "utf8")
.then((raw) => {
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object") return {}
return {
token: "token" in parsed && typeof parsed.token === "string" ? parsed.token : undefined,
}
})
.catch((err) => {
const errCode = code(err)
if (errCode === "ENOENT" || errCode === "ENOTDIR") {
throw new Error("Refusing to release: lock is compromised (metadata missing).")
}
if (err instanceof SyntaxError) {
throw new Error("Refusing to release: lock is compromised (metadata invalid).")
}
throw err
})
// Token check prevents deleting a lock that was re-acquired by another process.
if (current.token !== token) {
throw new Error("Refusing to release: lock token mismatch (not the owner).")
}
await rm(lockDir, { recursive: true, force: true })
}
return {
acquired: true,
startHeartbeat,
release,
}
}
async function acquireLockDir(
lockDir: string,
input: { key: string; onWait?: Wait; signal?: AbortSignal },
opts: Opts,
) {
const stop = mono() + opts.timeoutMs
let attempt = 0
let waited = 0
let delay = opts.baseDelayMs
while (true) {
input.signal?.throwIfAborted()
const res = await tryAcquireLockDir(lockDir, opts)
if (res.acquired) {
return res
}
if (mono() > stop) {
throw new Error(`Timed out waiting for lock: ${input.key}`)
}
attempt += 1
const ms = jitter(delay)
await input.onWait?.({
key: input.key,
attempt,
delay: ms,
waited,
})
await sleep(ms, input.signal)
waited += ms
delay = Math.min(opts.maxDelayMs, Math.floor(delay * 1.7))
}
}
export async function acquire(key: string, input: Options = {}): Promise<Lease> {
input.signal?.throwIfAborted()
const cfg: Opts = {
staleMs: input.staleMs ?? defaultOpts.staleMs,
timeoutMs: input.timeoutMs ?? defaultOpts.timeoutMs,
baseDelayMs: input.baseDelayMs ?? defaultOpts.baseDelayMs,
maxDelayMs: input.maxDelayMs ?? defaultOpts.maxDelayMs,
}
const dir = input.dir ?? root()
await mkdir(dir, { recursive: true })
const lockfile = path.join(dir, Hash.fast(key) + ".lock")
const lock = await acquireLockDir(
lockfile,
{
key,
onWait: input.onWait,
signal: input.signal,
},
cfg,
)
lock.startHeartbeat()
const release = () => lock.release()
return {
release,
[Symbol.asyncDispose]() {
return release()
},
}
}
export async function withLock<T>(key: string, fn: () => Promise<T>, input: Options = {}) {
await using _ = await acquire(key, input)
input.signal?.throwIfAborted()
return await fn()
}
export const effect = Effect.fn("Flock.effect")(function* (key: string, input: Options = {}) {
return yield* Effect.acquireRelease(
Effect.promise((signal) => Flock.acquire(key, { ...input, signal })).pipe(
Effect.withSpan("Flock.acquire", {
attributes: { key },
}),
),
(lock) => Effect.promise(() => lock.release()).pipe(Effect.withSpan("Flock.release")),
).pipe(Effect.asVoid)
})
}
+11
View File
@@ -0,0 +1,11 @@
import { z } from "zod"
export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
const result = (input: z.infer<T>) => {
const parsed = schema.parse(input)
return cb(parsed)
}
result.force = (input: z.infer<T>) => cb(input)
result.schema = schema
return result
}
+34
View File
@@ -0,0 +1,34 @@
import { glob, globSync, type GlobOptions } from "glob"
import { minimatch } from "minimatch"
export namespace Glob {
export interface Options {
cwd?: string
absolute?: boolean
include?: "file" | "all"
dot?: boolean
symlink?: boolean
}
function toGlobOptions(options: Options): GlobOptions {
return {
cwd: options.cwd,
absolute: options.absolute,
dot: options.dot,
follow: options.symlink ?? false,
nodir: options.include !== "all",
}
}
export async function scan(pattern: string, options: Options = {}): Promise<string[]> {
return glob(pattern, toGlobOptions(options)) as Promise<string[]>
}
export function scanSync(pattern: string, options: Options = {}): string[] {
return globSync(pattern, toGlobOptions(options)) as string[]
}
export function match(pattern: string, filepath: string): boolean {
return minimatch(filepath, pattern, { dot: true })
}
}
+7
View File
@@ -0,0 +1,7 @@
import { createHash } from "crypto"
export namespace Hash {
export function fast(input: string | Buffer): string {
return createHash("sha1").update(input).digest("hex")
}
}
+48
View File
@@ -0,0 +1,48 @@
import { randomBytes } from "crypto"
export namespace Identifier {
const LENGTH = 26
// State for monotonic ID generation
let lastTimestamp = 0
let counter = 0
export function ascending() {
return create(false)
}
export function descending() {
return create(true)
}
function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % 62]
}
return result
}
export function create(descending: boolean, timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter++
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
now = descending ? ~now : now
const timeBytes = Buffer.alloc(6)
for (let i = 0; i < 6; i++) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
}
+3
View File
@@ -0,0 +1,3 @@
export function iife<T>(fn: () => T) {
return fn()
}
+11
View File
@@ -0,0 +1,11 @@
export function lazy<T>(fn: () => T) {
let value: T | undefined
let loaded = false
return (): T => {
if (loaded) return value as T
loaded = true
value = fn()
return value as T
}
}
+10
View File
@@ -0,0 +1,10 @@
import { createRequire } from "node:module"
import path from "node:path"
export namespace Module {
export function resolve(id: string, dir: string) {
try {
return createRequire(path.join(dir, "package.json")).resolve(id)
} catch {}
}
}
+37
View File
@@ -0,0 +1,37 @@
export function getFilename(path: string | undefined) {
if (!path) return ""
const trimmed = path.replace(/[/\\]+$/, "")
const parts = trimmed.split(/[/\\]/)
return parts[parts.length - 1] ?? ""
}
export function getDirectory(path: string | undefined) {
if (!path) return ""
const trimmed = path.replace(/[/\\]+$/, "")
const parts = trimmed.split(/[/\\]/)
return parts.slice(0, parts.length - 1).join("/") + "/"
}
export function getFileExtension(path: string | undefined) {
if (!path) return ""
const parts = path.split(".")
return parts[parts.length - 1]
}
export function getFilenameTruncated(path: string | undefined, maxLength: number = 20) {
const filename = getFilename(path)
if (filename.length <= maxLength) return filename
const lastDot = filename.lastIndexOf(".")
const ext = lastDot <= 0 ? "" : filename.slice(lastDot)
const available = maxLength - ext.length - 1 // -1 for ellipsis
if (available <= 0) return filename.slice(0, maxLength - 1) + "…"
return filename.slice(0, available) + "…" + ext
}
export function truncateMiddle(text: string, maxLength: number = 20) {
if (text.length <= maxLength) return text
const available = maxLength - 1 // -1 for ellipsis
const start = Math.ceil(available / 2)
const end = Math.floor(available / 2)
return text.slice(0, start) + "…" + text.slice(-end)
}
+42
View File
@@ -0,0 +1,42 @@
export interface RetryOptions {
attempts?: number
delay?: number
factor?: number
maxDelay?: number
retryIf?: (error: unknown) => boolean
}
const TRANSIENT_MESSAGES = [
"load failed",
"network connection was lost",
"network request failed",
"failed to fetch",
"econnreset",
"econnrefused",
"etimedout",
"socket hang up",
]
function isTransientError(error: unknown): boolean {
if (!error) return false
// oxlint-disable-next-line no-base-to-string -- error is unknown, intentional coercion for message matching
const message = String(error instanceof Error ? error.message : error).toLowerCase()
return TRANSIENT_MESSAGES.some((m) => message.includes(m))
}
export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
const { attempts = 3, delay = 500, factor = 2, maxDelay = 10000, retryIf = isTransientError } = options
let lastError: unknown
for (let attempt = 0; attempt < attempts; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error
if (attempt === attempts - 1 || !retryIf(error)) throw error
const wait = Math.min(delay * Math.pow(factor, attempt), maxDelay)
await new Promise((resolve) => setTimeout(resolve, wait))
}
}
throw lastError
}
+74
View File
@@ -0,0 +1,74 @@
export namespace Slug {
const ADJECTIVES = [
"brave",
"calm",
"clever",
"cosmic",
"crisp",
"curious",
"eager",
"gentle",
"glowing",
"happy",
"hidden",
"jolly",
"kind",
"lucky",
"mighty",
"misty",
"neon",
"nimble",
"playful",
"proud",
"quick",
"quiet",
"shiny",
"silent",
"stellar",
"sunny",
"swift",
"tidy",
"witty",
] as const
const NOUNS = [
"cabin",
"cactus",
"canyon",
"circuit",
"comet",
"eagle",
"engine",
"falcon",
"forest",
"garden",
"harbor",
"island",
"knight",
"lagoon",
"meadow",
"moon",
"mountain",
"nebula",
"orchid",
"otter",
"panda",
"pixel",
"planet",
"river",
"rocket",
"sailor",
"squid",
"star",
"tiger",
"wizard",
"wolf",
] as const
export function create() {
return [
ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)],
NOUNS[Math.floor(Math.random() * NOUNS.length)],
].join("-")
}
}
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
@@ -0,0 +1,338 @@
import { describe, test, expect } from "bun:test"
import { Effect, Layer, FileSystem } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { testEffect } from "../lib/effect"
import path from "path"
const live = AppFileSystem.layer.pipe(Layer.provideMerge(NodeFileSystem.layer))
const { effect: it } = testEffect(live)
describe("AppFileSystem", () => {
describe("isDir", () => {
it(
"returns true for directories",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
expect(yield* fs.isDir(tmp)).toBe(true)
}),
)
it(
"returns false for files",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const file = path.join(tmp, "test.txt")
yield* filesys.writeFileString(file, "hello")
expect(yield* fs.isDir(file)).toBe(false)
}),
)
it(
"returns false for non-existent paths",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false)
}),
)
})
describe("isFile", () => {
it(
"returns true for files",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const file = path.join(tmp, "test.txt")
yield* filesys.writeFileString(file, "hello")
expect(yield* fs.isFile(file)).toBe(true)
}),
)
it(
"returns false for directories",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
expect(yield* fs.isFile(tmp)).toBe(false)
}),
)
})
describe("readJson / writeJson", () => {
it(
"round-trips JSON data",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const file = path.join(tmp, "data.json")
const data = { name: "test", count: 42, nested: { ok: true } }
yield* fs.writeJson(file, data)
const result = yield* fs.readJson(file)
expect(result).toEqual(data)
}),
)
})
describe("ensureDir", () => {
it(
"creates nested directories",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const nested = path.join(tmp, "a", "b", "c")
yield* fs.ensureDir(nested)
const info = yield* filesys.stat(nested)
expect(info.type).toBe("Directory")
}),
)
it(
"is idempotent",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const dir = path.join(tmp, "existing")
yield* filesys.makeDirectory(dir)
yield* fs.ensureDir(dir)
const info = yield* filesys.stat(dir)
expect(info.type).toBe("Directory")
}),
)
})
describe("writeWithDirs", () => {
it(
"creates parent directories if missing",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const file = path.join(tmp, "deep", "nested", "file.txt")
yield* fs.writeWithDirs(file, "hello")
expect(yield* filesys.readFileString(file)).toBe("hello")
}),
)
it(
"writes directly when parent exists",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const file = path.join(tmp, "direct.txt")
yield* fs.writeWithDirs(file, "world")
expect(yield* filesys.readFileString(file)).toBe("world")
}),
)
it(
"writes Uint8Array content",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const file = path.join(tmp, "binary.bin")
const content = new Uint8Array([0x00, 0x01, 0x02, 0x03])
yield* fs.writeWithDirs(file, content)
const result = yield* filesys.readFile(file)
expect(new Uint8Array(result)).toEqual(content)
}),
)
})
describe("findUp", () => {
it(
"finds target in start directory",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
yield* filesys.writeFileString(path.join(tmp, "target.txt"), "found")
const result = yield* fs.findUp("target.txt", tmp)
expect(result).toEqual([path.join(tmp, "target.txt")])
}),
)
it(
"finds target in parent directories",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
yield* filesys.writeFileString(path.join(tmp, "marker"), "root")
const child = path.join(tmp, "a", "b")
yield* filesys.makeDirectory(child, { recursive: true })
const result = yield* fs.findUp("marker", child, tmp)
expect(result).toEqual([path.join(tmp, "marker")])
}),
)
it(
"returns empty array when not found",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const result = yield* fs.findUp("nonexistent", tmp, tmp)
expect(result).toEqual([])
}),
)
})
describe("up", () => {
it(
"finds multiple targets walking up",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
yield* filesys.writeFileString(path.join(tmp, "a.txt"), "a")
yield* filesys.writeFileString(path.join(tmp, "b.txt"), "b")
const child = path.join(tmp, "sub")
yield* filesys.makeDirectory(child)
yield* filesys.writeFileString(path.join(child, "a.txt"), "a-child")
const result = yield* fs.up({ targets: ["a.txt", "b.txt"], start: child, stop: tmp })
expect(result).toContain(path.join(child, "a.txt"))
expect(result).toContain(path.join(tmp, "a.txt"))
expect(result).toContain(path.join(tmp, "b.txt"))
}),
)
})
describe("glob", () => {
it(
"finds files matching pattern",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
yield* filesys.writeFileString(path.join(tmp, "a.ts"), "a")
yield* filesys.writeFileString(path.join(tmp, "b.ts"), "b")
yield* filesys.writeFileString(path.join(tmp, "c.json"), "c")
const result = yield* fs.glob("*.ts", { cwd: tmp })
expect(result.sort()).toEqual(["a.ts", "b.ts"])
}),
)
it(
"supports absolute paths",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
yield* filesys.writeFileString(path.join(tmp, "file.txt"), "hello")
const result = yield* fs.glob("*.txt", { cwd: tmp, absolute: true })
expect(result).toEqual([path.join(tmp, "file.txt")])
}),
)
})
describe("globMatch", () => {
it(
"matches patterns",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
expect(fs.globMatch("*.ts", "foo.ts")).toBe(true)
expect(fs.globMatch("*.ts", "foo.json")).toBe(false)
expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true)
}),
)
})
describe("globUp", () => {
it(
"finds files walking up directories",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
yield* filesys.writeFileString(path.join(tmp, "root.md"), "root")
const child = path.join(tmp, "a", "b")
yield* filesys.makeDirectory(child, { recursive: true })
yield* filesys.writeFileString(path.join(child, "leaf.md"), "leaf")
const result = yield* fs.globUp("*.md", child, tmp)
expect(result).toContain(path.join(child, "leaf.md"))
expect(result).toContain(path.join(tmp, "root.md"))
}),
)
})
describe("built-in passthrough", () => {
it(
"exists works",
Effect.gen(function* () {
yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const file = path.join(tmp, "exists.txt")
yield* filesys.writeFileString(file, "yes")
expect(yield* filesys.exists(file)).toBe(true)
expect(yield* filesys.exists(file + ".nope")).toBe(false)
}),
)
it(
"remove works",
Effect.gen(function* () {
yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const file = path.join(tmp, "delete-me.txt")
yield* filesys.writeFileString(file, "bye")
yield* filesys.remove(file)
expect(yield* filesys.exists(file)).toBe(false)
}),
)
})
describe("pure helpers", () => {
test("mimeType returns correct types", () => {
expect(AppFileSystem.mimeType("file.json")).toBe("application/json")
expect(AppFileSystem.mimeType("image.png")).toBe("image/png")
expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream")
})
test("contains checks path containment", () => {
expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true)
expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false)
})
test("overlaps detects overlapping paths", () => {
expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true)
expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true)
expect(AppFileSystem.overlaps("/a", "/b")).toBe(false)
})
})
})
@@ -0,0 +1,63 @@
import fs from "fs/promises"
import os from "os"
import { Effect, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Global } from "@opencode-ai/core/global"
type Msg = {
key: string
dir: string
holdMs?: number
ready?: string
active?: string
done?: string
}
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
const msg: Msg = JSON.parse(process.argv[2]!)
const testGlobal = Layer.succeed(
Global.Service,
Global.Service.of({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
}),
)
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
async function job() {
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
if (msg.active) await fs.writeFile(msg.active, String(process.pid), { flag: "wx" })
try {
if (msg.holdMs && msg.holdMs > 0) await sleep(msg.holdMs)
if (msg.done) await fs.appendFile(msg.done, "1\n")
} finally {
if (msg.active) await fs.rm(msg.active, { force: true })
}
}
await Effect.runPromise(
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
yield* flock.withLock(
Effect.promise(() => job()),
msg.key,
msg.dir,
)
}).pipe(Effect.provide(testLayer)),
).catch((err) => {
const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
process.stderr.write(text)
process.exit(1)
})
@@ -0,0 +1,72 @@
import fs from "fs/promises"
import { Flock } from "@opencode-ai/core/util/flock"
type Msg = {
key: string
dir: string
staleMs?: number
timeoutMs?: number
baseDelayMs?: number
maxDelayMs?: number
holdMs?: number
ready?: string
active?: string
done?: string
}
function sleep(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
}
function input() {
const raw = process.argv[2]
if (!raw) {
throw new Error("Missing flock worker input")
}
return JSON.parse(raw) as Msg
}
async function job(input: Msg) {
if (input.ready) {
await fs.writeFile(input.ready, String(process.pid))
}
if (input.active) {
await fs.writeFile(input.active, String(process.pid), { flag: "wx" })
}
try {
if (input.holdMs && input.holdMs > 0) {
await sleep(input.holdMs)
}
if (input.done) {
await fs.appendFile(input.done, "1\n")
}
} finally {
if (input.active) {
await fs.rm(input.active, { force: true })
}
}
}
async function main() {
const msg = input()
await Flock.withLock(msg.key, () => job(msg), {
dir: msg.dir,
staleMs: msg.staleMs,
timeoutMs: msg.timeoutMs,
baseDelayMs: msg.baseDelayMs,
maxDelayMs: msg.maxDelayMs,
})
}
await main().catch((err) => {
const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
process.stderr.write(text)
process.exit(1)
})
+53
View File
@@ -0,0 +1,53 @@
import { test, type TestOptions } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import type * as Scope from "effect/Scope"
import * as TestClock from "effect/testing/TestClock"
import * as TestConsole from "effect/testing/TestConsole"
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
Effect.gen(function* () {
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
if (Exit.isFailure(exit)) {
for (const err of Cause.prettyErrors(exit.cause)) {
yield* Effect.logError(err)
}
}
return yield* exit
}).pipe(Effect.runPromise)
const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>) => {
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test(name, () => run(value, testLayer), opts)
effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.only(name, () => run(value, testLayer), opts)
effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.skip(name, () => run(value, testLayer), opts)
const live = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test(name, () => run(value, liveLayer), opts)
live.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.only(name, () => run(value, liveLayer), opts)
live.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.skip(name, () => run(value, liveLayer), opts)
return { effect, live }
}
// Test environment with TestClock and TestConsole
const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer())
// Live environment - uses real clock, but keeps TestConsole for output capture
const liveEnv = TestConsole.layer
export const it = make(testEnv, liveEnv)
export const testEffect = <R, E>(layer: Layer.Layer<R, E>) =>
make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))
@@ -0,0 +1,389 @@
import { describe, expect } from "bun:test"
import { spawn } from "child_process"
import fs from "fs/promises"
import path from "path"
import os from "os"
import { Cause, Effect, Exit, Layer } from "effect"
import { testEffect } from "../lib/effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Global } from "@opencode-ai/core/global"
import { Hash } from "@opencode-ai/core/util/hash"
function lock(dir: string, key: string) {
return path.join(dir, Hash.fast(key) + ".lock")
}
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
async function exists(file: string) {
return fs
.stat(file)
.then(() => true)
.catch(() => false)
}
async function readJson<T>(p: string): Promise<T> {
return JSON.parse(await fs.readFile(p, "utf8"))
}
// ---------------------------------------------------------------------------
// Worker subprocess helpers
// ---------------------------------------------------------------------------
type Msg = {
key: string
dir: string
holdMs?: number
ready?: string
active?: string
done?: string
}
const root = path.join(import.meta.dir, "../..")
const worker = path.join(import.meta.dir, "../fixture/effect-flock-worker.ts")
function run(msg: Msg) {
return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => {
const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { cwd: root })
const stdout: Buffer[] = []
const stderr: Buffer[] = []
proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
proc.on("close", (code) => {
resolve({ code: code ?? 1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) })
})
})
}
function spawnWorker(msg: Msg) {
return spawn(process.execPath, [worker, JSON.stringify(msg)], {
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
})
}
function stopWorker(proc: ReturnType<typeof spawnWorker>) {
if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve()
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
return Promise.resolve()
}
return new Promise<void>((resolve) => {
const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
killProc.on("close", () => {
proc.kill()
resolve()
})
})
}
async function waitForFile(file: string, timeout = 3_000) {
const stop = Date.now() + timeout
while (Date.now() < stop) {
if (await exists(file)) return
await sleep(20)
}
throw new Error(`Timed out waiting for file: ${file}`)
}
// ---------------------------------------------------------------------------
// Test layer
// ---------------------------------------------------------------------------
const testGlobal = Layer.succeed(
Global.Service,
Global.Service.of({
home: os.homedir(),
data: os.tmpdir(),
cache: os.tmpdir(),
config: os.tmpdir(),
state: os.tmpdir(),
bin: os.tmpdir(),
log: os.tmpdir(),
}),
)
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("util.effect-flock", () => {
const it = testEffect(testLayer)
it.live(
"acquire and release via scoped Effect",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const dir = path.join(tmp, "locks")
const lockDir = lock(dir, "eflock:acquire")
yield* Effect.scoped(flock.acquire("eflock:acquire", dir))
expect(yield* Effect.promise(() => exists(lockDir))).toBe(false)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
it.live(
"withLock data-first",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const dir = path.join(tmp, "locks")
let hit = false
yield* flock.withLock(
Effect.sync(() => {
hit = true
}),
"eflock:df",
dir,
)
expect(hit).toBe(true)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
it.live(
"withLock pipeable",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const dir = path.join(tmp, "locks")
let hit = false
yield* Effect.sync(() => {
hit = true
}).pipe(flock.withLock("eflock:pipe", dir))
expect(hit).toBe(true)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
it.live(
"writes owner metadata",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const dir = path.join(tmp, "locks")
const key = "eflock:meta"
const file = path.join(lock(dir, key), "meta.json")
yield* Effect.scoped(
Effect.gen(function* () {
yield* flock.acquire(key, dir)
const json = yield* Effect.promise(() =>
readJson<{ token?: unknown; pid?: unknown; hostname?: unknown; createdAt?: unknown }>(file),
)
expect(typeof json.token).toBe("string")
expect(typeof json.pid).toBe("number")
expect(typeof json.hostname).toBe("string")
expect(typeof json.createdAt).toBe("string")
}),
)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
it.live(
"breaks stale lock dirs",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const dir = path.join(tmp, "locks")
const key = "eflock:stale"
const lockDir = lock(dir, key)
yield* Effect.promise(async () => {
await fs.mkdir(lockDir, { recursive: true })
const old = new Date(Date.now() - 120_000)
await fs.utimes(lockDir, old, old)
})
let hit = false
yield* flock.withLock(
Effect.sync(() => {
hit = true
}),
key,
dir,
)
expect(hit).toBe(true)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
it.live(
"recovers from stale breaker",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const dir = path.join(tmp, "locks")
const key = "eflock:stale-breaker"
const lockDir = lock(dir, key)
const breaker = lockDir + ".breaker"
yield* Effect.promise(async () => {
await fs.mkdir(lockDir, { recursive: true })
await fs.mkdir(breaker)
const old = new Date(Date.now() - 120_000)
await fs.utimes(lockDir, old, old)
await fs.utimes(breaker, old, old)
})
let hit = false
yield* flock.withLock(
Effect.sync(() => {
hit = true
}),
key,
dir,
)
expect(hit).toBe(true)
expect(yield* Effect.promise(() => exists(breaker))).toBe(false)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
it.live(
"detects compromise when lock dir removed",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const dir = path.join(tmp, "locks")
const key = "eflock:compromised"
const lockDir = lock(dir, key)
const result = yield* flock
.withLock(
Effect.promise(() => fs.rm(lockDir, { recursive: true, force: true })),
key,
dir,
)
.pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("missing")
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
it.live(
"detects token mismatch",
Effect.gen(function* () {
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const dir = path.join(tmp, "locks")
const key = "eflock:token"
const lockDir = lock(dir, key)
const meta = path.join(lockDir, "meta.json")
const result = yield* flock
.withLock(
Effect.promise(async () => {
const json = await readJson<{ token?: string }>(meta)
json.token = "tampered"
await fs.writeFile(meta, JSON.stringify(json, null, 2))
}),
key,
dir,
)
.pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("token mismatch")
expect(yield* Effect.promise(() => exists(lockDir))).toBe(true)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
}),
)
it.live(
"fails on unwritable lock roots",
Effect.gen(function* () {
if (process.platform === "win32") return
const flock = yield* EffectFlock.Service
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
const dir = path.join(tmp, "locks")
yield* Effect.promise(async () => {
await fs.mkdir(dir, { recursive: true })
await fs.chmod(dir, 0o500)
})
const result = yield* flock.withLock(Effect.void, "eflock:perm", dir).pipe(Effect.exit)
// oxlint-disable-next-line no-base-to-string -- Exit has a useful toString for test assertions
expect(String(result)).toContain("PermissionDenied")
yield* Effect.promise(() => fs.chmod(dir, 0o700).then(() => fs.rm(tmp, { recursive: true, force: true })))
}),
)
it.live(
"enforces mutual exclusion under process contention",
() =>
Effect.promise(async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-stress-"))
const dir = path.join(tmp, "locks")
const done = path.join(tmp, "done.log")
const active = path.join(tmp, "active")
const n = 16
try {
const out = await Promise.all(
Array.from({ length: n }, () => run({ key: "eflock:stress", dir, done, active, holdMs: 30 })),
)
expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
const lines = (await fs.readFile(done, "utf8"))
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
expect(lines.length).toBe(n)
} finally {
await fs.rm(tmp, { recursive: true, force: true })
}
}),
60_000,
)
it.live(
"recovers after a crashed lock owner",
() =>
Effect.promise(async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "eflock-crash-"))
const dir = path.join(tmp, "locks")
const ready = path.join(tmp, "ready")
const proc = spawnWorker({ key: "eflock:crash", dir, ready, holdMs: 120_000 })
try {
await waitForFile(ready, 5_000)
await stopWorker(proc)
await new Promise((resolve) => proc.on("close", resolve))
// Backdate lock files so they're past STALE_MS (60s)
const lockDir = lock(dir, "eflock:crash")
const old = new Date(Date.now() - 120_000)
await fs.utimes(lockDir, old, old).catch(() => {})
await fs.utimes(path.join(lockDir, "heartbeat"), old, old).catch(() => {})
await fs.utimes(path.join(lockDir, "meta.json"), old, old).catch(() => {})
const done = path.join(tmp, "done.log")
const result = await run({ key: "eflock:crash", dir, done, holdMs: 10 })
expect(result.code).toBe(0)
expect(result.stderr.toString()).toBe("")
} finally {
await stopWorker(proc).catch(() => {})
await fs.rm(tmp, { recursive: true, force: true })
}
}),
30_000,
)
})
+426
View File
@@ -0,0 +1,426 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import { spawn } from "child_process"
import path from "path"
import os from "os"
import { Flock } from "@opencode-ai/core/util/flock"
import { Hash } from "@opencode-ai/core/util/hash"
type Msg = {
key: string
dir: string
staleMs?: number
timeoutMs?: number
baseDelayMs?: number
maxDelayMs?: number
holdMs?: number
ready?: string
active?: string
done?: string
}
const root = path.join(import.meta.dir, "../..")
const worker = path.join(import.meta.dir, "../fixture/flock-worker.ts")
async function tmpdir() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "flock-test-"))
return {
path: dir,
async [Symbol.asyncDispose]() {
await fs.rm(dir, { recursive: true, force: true })
},
}
}
function lock(dir: string, key: string) {
return path.join(dir, Hash.fast(key) + ".lock")
}
function sleep(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
}
async function exists(file: string) {
return fs
.stat(file)
.then(() => true)
.catch(() => false)
}
async function wait(file: string, timeout = 3_000) {
const stop = Date.now() + timeout
while (Date.now() < stop) {
if (await exists(file)) return
await sleep(20)
}
throw new Error(`Timed out waiting for file: ${file}`)
}
function run(msg: Msg) {
return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => {
const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], {
cwd: root,
})
const stdout: Buffer[] = []
const stderr: Buffer[] = []
proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
proc.on("close", (code) => {
resolve({
code: code ?? 1,
stdout: Buffer.concat(stdout),
stderr: Buffer.concat(stderr),
})
})
})
}
function spawnWorker(msg: Msg) {
return spawn(process.execPath, [worker, JSON.stringify(msg)], {
cwd: root,
stdio: ["ignore", "pipe", "pipe"],
})
}
function stopWorker(proc: ReturnType<typeof spawnWorker>) {
if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve()
if (process.platform !== "win32" || !proc.pid) {
proc.kill()
return Promise.resolve()
}
return new Promise<void>((resolve) => {
const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
killProc.on("close", () => {
proc.kill()
resolve()
})
})
}
async function readJson<T>(p: string): Promise<T> {
return JSON.parse(await fs.readFile(p, "utf8"))
}
describe("util.flock", () => {
test("enforces mutual exclusion under process contention", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "locks")
const done = path.join(tmp.path, "done.log")
const active = path.join(tmp.path, "active")
const key = "flock:stress"
const n = 16
const out = await Promise.all(
Array.from({ length: n }, () =>
run({
key,
dir,
done,
active,
holdMs: 30,
staleMs: 1_000,
timeoutMs: 15_000,
}),
),
)
expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
expect(out.map((x) => x.stderr.toString()).filter(Boolean)).toEqual([])
const lines = (await fs.readFile(done, "utf8"))
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
expect(lines.length).toBe(n)
}, 20_000)
test("times out while waiting when lock is still healthy", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "locks")
const key = "flock:timeout"
const ready = path.join(tmp.path, "ready")
const proc = spawnWorker({
key,
dir,
ready,
holdMs: 20_000,
staleMs: 10_000,
timeoutMs: 30_000,
})
try {
await wait(ready, 5_000)
const seen: string[] = []
const err = await Flock.withLock(key, async () => {}, {
dir,
staleMs: 10_000,
timeoutMs: 1_000,
onWait: (tick) => {
seen.push(tick.key)
},
}).catch((err) => err)
expect(err).toBeInstanceOf(Error)
if (!(err instanceof Error)) throw err
expect(err.message).toContain("Timed out waiting for lock")
expect(seen.length).toBeGreaterThan(0)
expect(seen.every((x) => x === key)).toBe(true)
} finally {
await stopWorker(proc).catch(() => undefined)
await new Promise((resolve) => proc.on("close", resolve))
}
}, 15_000)
test("recovers after a crashed lock owner", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "locks")
const key = "flock:crash"
const ready = path.join(tmp.path, "ready")
const proc = spawnWorker({
key,
dir,
ready,
holdMs: 20_000,
staleMs: 500,
timeoutMs: 30_000,
})
await wait(ready, 5_000)
await stopWorker(proc)
await new Promise((resolve) => proc.on("close", resolve))
let hit = false
await Flock.withLock(
key,
async () => {
hit = true
},
{
dir,
staleMs: 500,
timeoutMs: 8_000,
},
)
expect(hit).toBe(true)
}, 20_000)
test("breaks stale lock dirs when heartbeat is missing", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "locks")
const key = "flock:missing-heartbeat"
const lockDir = lock(dir, key)
await fs.mkdir(lockDir, { recursive: true })
const old = new Date(Date.now() - 2_000)
await fs.utimes(lockDir, old, old)
let hit = false
await Flock.withLock(
key,
async () => {
hit = true
},
{
dir,
staleMs: 200,
timeoutMs: 3_000,
},
)
expect(hit).toBe(true)
})
test("recovers when a stale breaker claim was left behind", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "locks")
const key = "flock:stale-breaker"
const lockDir = lock(dir, key)
const breaker = lockDir + ".breaker"
await fs.mkdir(lockDir, { recursive: true })
await fs.mkdir(breaker)
const old = new Date(Date.now() - 2_000)
await fs.utimes(lockDir, old, old)
await fs.utimes(breaker, old, old)
let hit = false
await Flock.withLock(
key,
async () => {
hit = true
},
{
dir,
staleMs: 200,
timeoutMs: 3_000,
},
)
expect(hit).toBe(true)
expect(await exists(breaker)).toBe(false)
})
test("fails clearly if lock dir is removed while held", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "locks")
const key = "flock:compromised"
const lockDir = lock(dir, key)
const err = await Flock.withLock(
key,
async () => {
await fs.rm(lockDir, {
recursive: true,
force: true,
})
},
{
dir,
staleMs: 1_000,
timeoutMs: 3_000,
},
).catch((err) => err)
expect(err).toBeInstanceOf(Error)
if (!(err instanceof Error)) throw err
expect(err.message).toContain("compromised")
let hit = false
await Flock.withLock(
key,
async () => {
hit = true
},
{
dir,
staleMs: 200,
timeoutMs: 3_000,
},
)
expect(hit).toBe(true)
})
test("writes owner metadata while lock is held", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "locks")
const key = "flock:meta"
const file = path.join(lock(dir, key), "meta.json")
await Flock.withLock(
key,
async () => {
const json = await readJson<{
token?: unknown
pid?: unknown
hostname?: unknown
createdAt?: unknown
}>(file)
expect(typeof json.token).toBe("string")
expect(typeof json.pid).toBe("number")
expect(typeof json.hostname).toBe("string")
expect(typeof json.createdAt).toBe("string")
},
{
dir,
staleMs: 1_000,
timeoutMs: 3_000,
},
)
})
test("supports acquire with await using", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "locks")
const key = "flock:acquire"
const lockDir = lock(dir, key)
{
await using _ = await Flock.acquire(key, {
dir,
staleMs: 1_000,
timeoutMs: 3_000,
})
expect(await exists(lockDir)).toBe(true)
}
expect(await exists(lockDir)).toBe(false)
})
test("refuses token mismatch release and recovers from stale", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "locks")
const key = "flock:token"
const lockDir = lock(dir, key)
const meta = path.join(lockDir, "meta.json")
const err = await Flock.withLock(
key,
async () => {
const json = await readJson<{ token?: string }>(meta)
json.token = "tampered"
await fs.writeFile(meta, JSON.stringify(json, null, 2))
},
{
dir,
staleMs: 500,
timeoutMs: 3_000,
},
).catch((err) => err)
expect(err).toBeInstanceOf(Error)
if (!(err instanceof Error)) throw err
expect(err.message).toContain("token mismatch")
expect(await exists(lockDir)).toBe(true)
let hit = false
await Flock.withLock(
key,
async () => {
hit = true
},
{
dir,
staleMs: 500,
timeoutMs: 6_000,
},
)
expect(hit).toBe(true)
})
test("fails clearly on unwritable lock roots", async () => {
if (process.platform === "win32") return
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "locks")
const key = "flock:perm"
await fs.mkdir(dir, { recursive: true })
await fs.chmod(dir, 0o500)
try {
const err = await Flock.withLock(key, async () => {}, {
dir,
staleMs: 100,
timeoutMs: 500,
}).catch((err) => err)
expect(err).toBeInstanceOf(Error)
if (!(err instanceof Error)) throw err
const text = err.message
expect(text.includes("EACCES") || text.includes("EPERM")).toBe(true)
} finally {
await fs.chmod(dir, 0o700)
}
})
})
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://www.schemastore.org/tsconfig",
"_version": "24.0.0",
"compilerOptions": {
"lib": ["es2024", "ESNext.Array", "ESNext.Collection", "ESNext.Error", "ESNext.Iterator", "ESNext.Promise"],
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2024",
"noUncheckedIndexedAccess": false,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
-4
View File
@@ -10,10 +10,6 @@ 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
+1 -25
View File
@@ -1,20 +1,8 @@
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 { 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> }
@@ -44,7 +32,7 @@ export function setWslConfig(config: WslConfig) {
export async function spawnLocalServer(hostname: string, port: number, password: string) {
prepareServerEnv(password)
const { Log, Server, PushRelay } = await import("virtual:opencode-server")
const { Log, Server } = await import("virtual:opencode-server")
await Log.init({ level: "WARN" })
const listener = await Server.listen({
port,
@@ -53,18 +41,6 @@ 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}`
@@ -1,7 +0,0 @@
{
"plugins": {
"figma": {
"enabled": true
}
}
}
-43
View File
@@ -1,43 +0,0 @@
# 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
-183
View File
@@ -1,183 +0,0 @@
# 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
-39
View File
@@ -1,39 +0,0 @@
# Mobile Voice
Expo app for voice dictation and OpenCode session monitoring.
## Current monitoring behavior
- Foreground: app reads OpenCode SSE (`GET /event`) and updates monitor status live.
- Background/terminated: app relies on APNs notifications sent by `apn-relay`.
- The app registers its native APNs device token with relay route `POST /v1/device/register`.
## App requirements
- Use a development build or production build (not Expo Go).
- `expo-notifications` plugin is enabled with `enableBackgroundRemoteNotifications: true`.
- Notification permission must be granted.
## Server entry fields in app
When adding a server, provide:
- OpenCode URL
- APN relay URL
- Relay shared secret
Default APN relay URL: `https://apn.dev.opencode.ai`
The app uses these values to:
- send prompts to OpenCode
- register/unregister APNs token with relay
- receive background push updates for monitored sessions
## Local dev
```bash
npx expo start
```
Use your machine LAN IP / reachable host values for OpenCode and relay when testing on a physical device.
-101
View File
@@ -1,101 +0,0 @@
{
"expo": {
"name": "Control",
"slug": "control",
"version": "1.0.2",
"orientation": "portrait",
"icon": "./assets/new-control.png",
"scheme": "mobilevoice",
"userInterfaceStyle": "automatic",
"ios": {
"icon": "./assets/new-control.png",
"bundleIdentifier": "com.anomalyco.mobilevoice",
"config": {
"usesNonExemptEncryption": false
},
"entitlements": {
"com.apple.developer.kernel.extended-virtual-addressing": true
},
"infoPlist": {
"NSMicrophoneUsageDescription": "Control uses the microphone while you hold Record to turn your speech into text for an OpenCode session.",
"NSCameraUsageDescription": "Control uses the camera to scan the OpenCode pairing QR code shown on your computer.",
"NSLocalNetworkUsageDescription": "Control uses your local network to discover and connect to OpenCode servers running on your computer.",
"NSBonjourServices": ["_http._tcp."],
"NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true,
"NSExceptionDomains": {
"100.64.0.0/10": {
"NSExceptionAllowsInsecureHTTPLoads": true
},
"ts.net": {
"NSIncludesSubdomains": true,
"NSExceptionAllowsInsecureHTTPLoads": true
}
}
}
}
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#1a1a1a",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"permissions": [
"RECORD_AUDIO",
"POST_NOTIFICATIONS",
"android.permission.FOREGROUND_SERVICE",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.ACCESS_WIFI_STATE",
"android.permission.CHANGE_WIFI_MULTICAST_STATE"
],
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"backgroundColor": "#121212",
"android": {
"image": "./assets/images/splash-icon.png",
"imageWidth": 76
}
}
],
"react-native-audio-api",
"expo-asset",
"expo-audio",
[
"expo-notifications",
{
"enableBackgroundRemoteNotifications": true,
"sounds": ["./assets/sounds/alert.wav"]
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "50b3dac3-8b5e-4142-b749-65ecf7b2904d"
}
},
"owner": "anomaly-co",
"runtimeVersion": "1.0.2",
"updates": {
"url": "https://u.expo.dev/50b3dac3-8b5e-4142-b749-65ecf7b2904d"
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 755 KiB

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

Before

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 755 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

@@ -1,328 +0,0 @@
# Live Activity Implementation Plan
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ iPhone Lock Screen / Dynamic Island │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Live Activity (expo-widgets) │ │
│ │ "Installing dependencies..." ● Working │ │
│ └─────────────────────────────────────────────────────┘ │
│ ▲ local update (foreground) ▲ APNs push (bg) │
│ │ │ │
│ ┌──────┴─────┐ │ │
│ │ SSE stream │ │ │
│ │ /event │ │ │
│ └──────┬─────┘ │ │
└─────────┼──────────────────────────────┼────────────────────┘
│ │
▼ │
┌──────────────────┐ ┌────────┴─────────┐
│ OpenCode Server │──event──> │ APN Relay │
│ (push-relay.ts) │ │ /v1/activity/* │
│ GlobalBus │ │ apns.ts │
└──────────────────┘ └──────────────────┘
```
**Foreground**: App receives SSE events, updates the Live Activity locally via `instance.update()`.
**Background**: OpenCode server fires events to the relay, relay sends `liveactivity` APNs pushes with `content-state`, iOS updates the widget.
**Push-to-start**: Relay sends a `start` push to begin a Live Activity even when the app hasn't initiated one.
## Content-State Shape
```typescript
type SessionActivityProps = {
status: "working" | "retry" | "permission" | "complete" | "error"
sessionTitle: string // e.g. "Fix auth bug"
lastMessage: string // truncated ~120 chars, e.g. "Installing dependencies..."
retryInfo: string | null // e.g. "Retry 2/5 in 8s" when status is "retry"
}
```
This is intentionally lean -- it keeps APNs payload size well under the 4KB limit.
## Dynamic Island / Lock Screen Layout
| Slot | Content |
| ------------------------ | ---------------------------------------------- |
| **Banner** (Lock Screen) | Session title, status badge, last message text |
| **Compact leading** | App icon or "OC" text |
| **Compact trailing** | Status word ("Working", "Done", "Needs input") |
| **Minimal** | Small status dot/icon |
| **Expanded leading** | Session title + status |
| **Expanded trailing** | Time elapsed or ETA |
| **Expanded bottom** | Last message text, retry info if applicable |
---
## Phase 1: Core Live Activity (App-Initiated, Local + Push Updates)
This phase delivers the end-to-end working feature.
### 1a. Install and configure expo-widgets
**Package**: `mobile-voice`
- Add `expo-widgets` and `@expo/ui` to dependencies
- Add the plugin to `app.json`:
```json
[
"expo-widgets",
{
"enablePushNotifications": true,
"widgets": [
{
"name": "SessionActivity",
"displayName": "OpenCode Session",
"description": "Live session monitoring on Lock Screen and Dynamic Island"
}
]
}
]
```
- Add `NSSupportsLiveActivities: true` and `NSSupportsLiveActivitiesFrequentUpdates: true` to `expo.ios.infoPlist` in `app.json`
- Requires a new EAS dev build after this step
### 1b. Create the Live Activity component
**New file**: `src/widgets/session-activity.tsx`
- Define `SessionActivityProps` type
- Build the `LiveActivityLayout` using `@expo/ui/swift-ui` primitives (`Text`, `VStack`, `HStack`)
- Export via `createLiveActivity('SessionActivity', SessionActivity)`
- Adapt layout per slot (banner, compact, minimal, expanded)
- Use `LiveActivityEnvironment.colorScheme` to handle dark/light
### 1c. Create a Live Activity management hook
**New file**: `src/hooks/use-live-activity.ts`
Responsibilities:
- `startActivity(sessionTitle, sessionId)` -- calls `SessionActivity.start(props, deepLinkURL)`, stores the instance, gets the push token
- `updateActivity(props)` -- calls `instance.update(props)` for foreground SSE-driven updates
- `endActivity(finalStatus)` -- calls `instance.end('default', finalProps)`
- Manages the per-activity push token lifecycle
- Exposes `activityPushToken: string | null` for relay registration
- Handles edge cases: activity already running (end previous before starting new), activities disabled by user, iOS version checks
### 1d. Integrate into useMonitoring
**File**: `src/hooks/use-monitoring.ts`
- Import and use `useLiveActivity`
- **On `beginMonitoring(job)`**: call `startActivity(sessionTitle, job.sessionID)`
- **In the SSE event handler** (foreground): map classified events to `updateActivity()` calls:
- `session.status` busy -> `{ status: "working", lastMessage: <latest text> }`
- `session.status` retry -> `{ status: "retry", retryInfo: "Retry N in Xs" }`
- `permission.asked` -> `{ status: "permission", lastMessage: "Needs your decision" }`
- `session.status` idle (complete) -> `endActivity("complete")`
- `session.error` -> `endActivity("error")`
- **On stop monitoring**: end the activity if still running
- **On app background**: stop SSE (already happens), rely on APNs pushes for updates
- **On app foreground**: reconnect SSE, sync local activity state with `syncSessionState()`
### 1e. Register activity push tokens with the relay
**File**: `src/lib/relay-client.ts`
- New function: `registerActivityToken(input)` -- calls new relay endpoint `POST /v1/activity/register`
```typescript
registerActivityToken(input: {
relayBaseURL: string
secret: string
activityToken: string
sessionID: string
bundleId?: string
}): Promise<void>
```
- New function: `unregisterActivityToken(input)` -- cleanup
```typescript
unregisterActivityToken(input: {
relayBaseURL: string
secret: string
sessionID: string
}): Promise<void>
```
**File**: `src/hooks/use-live-activity.ts` or `use-monitoring.ts`
- When `activityPushToken` becomes available after `startActivity()`, send it to the relay alongside the `sessionID`
- On activity end, unregister the token
### 1f. Extend the APN relay for Live Activity pushes
**Package**: `apn-relay`
New endpoint: `POST /v1/activity/register`
```typescript
{
secret: string,
sessionID: string,
activityToken: string, // the per-activity push token
bundleId?: string
}
```
New endpoint: `POST /v1/activity/unregister`
```typescript
{
secret: string,
sessionID: string
}
```
New DB table: `activity_registration`
```sql
id TEXT PRIMARY KEY,
secret_hash TEXT NOT NULL,
session_id TEXT NOT NULL,
activity_token TEXT NOT NULL,
bundle_id TEXT NOT NULL,
apns_env TEXT NOT NULL DEFAULT 'production',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE(secret_hash, session_id)
```
Modified: `POST /v1/event` handler
- After sending the regular alert push (existing behavior), also check `activity_registration` for matching `(secret_hash, session_id)`
- If a registration exists, send a second push with:
- `apns-push-type: liveactivity`
- `apns-topic: {bundleId}.push-type.liveactivity`
- Payload with `content-state` containing the `SessionActivityProps` fields
- `event: "update"` for progress, `event: "end"` for complete/error
New function in `apns.ts`: `sendLiveActivityUpdate(input)`
- Separate from the existing `send()` function
- Uses `liveactivity` push type headers
- Constructs `content-state` payload format
### 1g. Extend the OpenCode server push-relay for richer events
**File**: `packages/opencode/src/server/push-relay.ts`
- Extend `Type` union: `"complete" | "permission" | "error" | "progress"`
- Add cases to `map()` function:
- `session.status` with `type: "busy"` -> `{ type: "progress", sessionID }`
- `session.status` with `type: "retry"` -> `{ type: "progress", sessionID }` (with retry metadata)
- `message.updated` where the message has tool-use or assistant text -> `{ type: "progress", sessionID }` (throttled)
- Add to `notify()` / `post()`: include a `contentState` object in the relay payload for progress events
- Add throttling: don't send more than ~1 progress push per 10-15 seconds to stay within APNs budget
- Extend `evt` payload sent to relay:
```typescript
{
secret, serverID, eventType, sessionID, title, body,
// New field for Live Activity updates:
contentState?: {
status: "working" | "retry" | "permission" | "complete" | "error",
sessionTitle: string,
lastMessage: string,
retryInfo: string | null
}
}
```
---
## Phase 2: Push-to-Start
This lets the server start a Live Activity on the phone when a session begins, even if the user didn't initiate it from the app.
### 2a. Register push-to-start token from the app
**File**: `src/hooks/use-live-activity.ts`
- On app launch, call `addPushToStartTokenListener()` from `expo-widgets`
- Send the push-to-start token to the relay at registration time (extend existing `/v1/device/register` or new field)
- This token is app-wide (not per-activity), so it lives alongside the device push token
### 2b. Extend relay for push-to-start
**Package**: `apn-relay`
- Add `push_to_start_token` column to `device_registration` table (nullable)
- Extend `/v1/device/register` to accept `pushToStartToken` field
- New logic in `/v1/event`: if `eventType` is the first event for a session and no `activity_registration` exists yet, send a push-to-start payload:
```json
{
"aps": {
"timestamp": 1712345678,
"event": "start",
"content-state": {
"status": "working",
"sessionTitle": "Fix auth bug",
"lastMessage": "Starting...",
"retryInfo": null
},
"attributes-type": "SessionActivityAttributes",
"attributes": {
"sessionId": "abc123"
},
"alert": {
"title": "Session Started",
"body": "OpenCode is working on: Fix auth bug"
}
}
}
```
### 2c. Server-side: emit session start events
**File**: `packages/opencode/src/server/push-relay.ts`
- Add a `"start"` event type
- Map `session.status` with `type: "busy"` (first time for a session) to `{ type: "start", sessionID }`
- Include session metadata (title, directory) in the payload so the relay can populate the `attributes` field for push-to-start
---
## Phase 3: Polish and Edge Cases
- **Deep linking**: When user taps the Live Activity, open the app and navigate to that session (`mobilevoice://session/{id}`)
- **Multiple activities**: Handle the case where the user starts multiple sessions from different servers. iOS supports multiple concurrent Live Activities.
- **Activity expiry**: iOS ends Live Activities after 8 hours. Handle the timeout gracefully (end with a "timed out" status).
- **Token rotation**: Activity push tokens can rotate. The `addPushTokenListener` handles this -- forward new tokens to the relay.
- **Cleanup**: When the relay receives an APNs error like `InvalidToken` for an activity token, delete the `activity_registration` row.
- **Stale activities**: On app foreground, check `SessionActivity.getInstances()` to clean up any orphaned activities.
---
## Changes Per Package Summary
| Package | Files Changed | Files Added |
| ---------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------- |
| **mobile-voice** | `app.json`, `package.json`, `use-monitoring.ts`, `relay-client.ts` | `src/widgets/session-activity.tsx`, `src/hooks/use-live-activity.ts` |
| **apn-relay** | `src/index.ts`, `src/apns.ts`, `src/schema.sql.ts` | (none) |
| **opencode** | `src/server/push-relay.ts` | (none) |
## Build Requirements
- New EAS dev build required after Phase 1a (native widget extension target)
- Relay deployment after Phase 1f
- OpenCode server rebuild after Phase 1g
## Key Technical References
- `expo-widgets` docs: https://docs.expo.dev/versions/latest/sdk/widgets/
- `expo-widgets` alpha blog post: https://expo.dev/blog/home-screen-widgets-and-live-activities-in-expo
- Apple ActivityKit push notifications: https://developer.apple.com/documentation/activitykit/starting-and-updating-live-activities-with-activitykit-push-notifications
- Existing APN relay: `packages/apn-relay/src/`
- Existing push-relay (server-side): `packages/opencode/src/server/push-relay.ts`
- Existing monitoring hook: `packages/mobile-voice/src/hooks/use-monitoring.ts`
- Existing relay client: `packages/mobile-voice/src/lib/relay-client.ts`
## Limitations / Risks
- **expo-widgets is alpha** (March 2026) -- APIs may break
- **Images not yet supported** in `@expo/ui` widget components (on Expo's roadmap)
- **Live Activities have an 8-hour max duration** enforced by iOS
- **APNs budget**: iOS throttles frequent updates; keep progress pushes to ~1 per 10-15 seconds
- **NSSupportsLiveActivitiesFrequentUpdates** needed in Info.plist for higher update frequency
- **Dev builds required** -- adding the widget extension is a native change, OTA won't cover it
-27
View File
@@ -1,27 +0,0 @@
{
"cli": {
"version": ">= 18.4.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"bun": "1.3.11",
"developmentClient": true,
"distribution": "internal",
"channel": "development"
},
"preview": {
"bun": "1.3.11",
"distribution": "internal",
"channel": "preview"
},
"production": {
"bun": "1.3.11",
"autoIncrement": true,
"channel": "production"
}
},
"submit": {
"production": {}
}
}

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