diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0509d91cc..e11f67281a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -270,6 +270,56 @@ jobs: .github/workflows/release-desktop.yml bash scripts/release-workflows.test.sh + # Mobile foundation: Go protocol/mobilecore/node packages plus the Capacitor + # React shell (typecheck, unit tests, production Vite build). Native + # iOS/Android packaging and gomobile bind land in a later workflow stage. + mobile: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + cache-dependency-path: mobile/package-lock.json + + - name: gofmt (mobile packages) + run: | + unformatted=$(gofmt -l internal/mobileprotocol internal/mobilecore internal/node internal/cli/node.go) + if [ -n "$unformatted" ]; then + echo "These mobile Go files are not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + + - name: vet (mobile packages) + run: go vet ./internal/mobileprotocol ./internal/mobilecore ./internal/node + + - name: test (mobile packages) + run: go test ./internal/mobileprotocol ./internal/mobilecore ./internal/node + + - name: Install mobile frontend deps + working-directory: mobile + run: npm ci + + - name: Mobile typecheck + working-directory: mobile + run: npm run typecheck + + - name: Mobile test + working-directory: mobile + run: npm test + + - name: Mobile build + working-directory: mobile + run: npm run build + # The site/ auth client has security-sensitive redirect-validation logic # (safeNext) covered by node:test unit tests. Those tests use only Node # builtins, so no `npm install` is needed — run them directly on every PR so diff --git a/.gitignore b/.gitignore index aed25c13a8..1f8b00c489 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,12 @@ node_modules/ .pnpm-store/ .npm/ +# Mobile (Capacitor) build output — native projects generated locally +/mobile/dist/ +/mobile/ios/ +/mobile/android/ +/mobile/.capacitor/ + # Project-local Reasonix state; keep the checked-in review command. /.reasonix/* !/.reasonix/commands/ diff --git a/Makefile b/Makefile index 81ac5016b4..884a0c1cc3 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev) LDFLAGS := -s -w -X main.version=$(VERSION) GOEXE := $(shell go env GOEXE) -.PHONY: build vet fmt test desktop-test desktop-test-short desktop-test-times hooks cross clean +.PHONY: build vet fmt test mobile-test mobile-ci desktop-test desktop-test-short desktop-test-times hooks cross clean build: CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix @@ -17,6 +17,16 @@ fmt: test: go test ./... +# Focused mobile foundation packages (protocol, mobilecore, node hub). +mobile-test: + go test ./internal/mobileprotocol ./internal/mobilecore ./internal/node + +# Mirror .github/workflows/ci.yml mobile job (Go + React shell). +mobile-ci: + go vet ./internal/mobileprotocol ./internal/mobilecore ./internal/node + go test ./internal/mobileprotocol ./internal/mobilecore ./internal/node + cd mobile && npm ci && npm run typecheck && npm test && npm run build + desktop-test: cd desktop && go test . diff --git a/docs/MOBILE.md b/docs/MOBILE.md new file mode 100644 index 0000000000..bdec2b9e0b --- /dev/null +++ b/docs/MOBILE.md @@ -0,0 +1,190 @@ +# Reasonix Mobile — Product Spec (Frozen Baseline) + +This document freezes the product and protocol contracts for the iOS / Android +dual-runtime client. Implementation work tracks this document; breaking changes +require an explicit version bump of the mobile envelope. + +## Goals + +- One React + Capacitor app for iPhone, iPad, Android phone, and tablet. +- Two **immutable** session runtimes: + - `local` — phone talks to OpenAI-compatible or Anthropic-compatible providers + through a lightweight on-device Agent (`mobilecore`). + - `remote` — phone connects to a Reasonix Node on a computer or server and runs + the full tool surface (Shell, Git, files, MCP, background jobs). +- Default path needs no account for local providers and LAN / Tailscale direct + connect. Official relay, cross-network pairing, and push require a Reasonix + account. +- Minimum OS: iOS 17, Android 10 (API 29). Channels: App Store, Google Play, + signed APK. Mainland China Android stores are a later release stage. + +## Non-goals (v1) + +- Local full coding environment (no Shell / Git / arbitrary filesystem / stdio MCP). +- Full end-to-end cloud session sync of conversation content or API keys. +- React Native or separate iOS/Android business stacks. + +## Architecture + +``` +┌──────────────── mobile (Capacitor) ────────────────┐ +│ UI → SessionBackend → LocalBackend | RemoteBackend │ +└───────────────┬───────────────────┬────────────────┘ + │ │ + mobilecore (Go) WebSocket MobileEnvelope + Keychain/Keystore (LAN / Tailscale / relay) + │ │ + Provider APIs reasonix node (multi-session) +``` + +### SessionBackend + +UI depends only on `SessionBackend`. Runtime is fixed at session creation and +never mutated in place. Migration between local and remote is always +**copy-to-device** or **hand-off-to-node** that creates a new session. + +### MobileEnvelope + +All mobile transport frames use a versioned envelope: + +```ts +MobileEnvelope { + version: number // currently 1 + type: string // command | event | ack | hello | snapshot | error | ping | pong + requestId?: string // write commands are deduped by this id + sessionId?: string + seq?: number // monotonic event sequence per session + ack?: number // last event seq the peer has applied + payload?: unknown +} +``` + +Rules: + +- Every write command carries a non-empty `requestId`; the node keeps a bounded + dedupe table and returns the prior result on retries. +- Every session event carries a monotonic `seq` starting at 1. +- Reconnect sends `lastAckSeq`; valid cursors get incremental replay; expired + cursors get a full snapshot (history, partial turn, todos, running, pending + approval, revision). + +### SessionDescriptor + +Persisted client-side index (and node-side session index): + +```ts +SessionDescriptor { + id: string + runtime: "local" | "remote" // immutable after create + nodeId?: string + providerRef?: string + capabilities: string[] + revision: number + lastEventSeq: number + title?: string + status?: string // idle | running | pending_approval | failed + updatedAt?: string // RFC3339 +} +``` + +New persisted fields always use `omitempty` on the Go side so older clients and +desktop/CLI stores remain readable. + +## Local runtime (`mobilecore`) + +- Built with `gomobile bind` into Android AAR + iOS XCFramework. +- Exposed to JS via Capacitor plugins (Kotlin / Swift) as a JSON string API. +- Reuses Go provider serialization, reasoning adapters, retry, usage, + compaction, eventwire, and cache diagnostics. **Do not reimplement provider + protocol in TypeScript / Kotlin / Swift.** +- Fixed methods: `CreateSession`, `RestoreSession`, `Submit`, `Cancel`, + `Answer`, `Approve`, `Snapshot`, `SubscribeEvents`, `ListModels`, + `ProbeProvider`. +- Local tools only: web read, user-authorized attachment read, image input, + HTTP MCP. No Shell, Git, arbitrary FS, or stdio MCP. +- Tool set and order freeze at session create. Dynamic device state is injected + as user-turn data only (cache-first). +- API keys in Keychain / Android Keystore. Session snapshots AES-GCM encrypted + in the app private directory. JS, logs, and crash reports must never persist + keys. + +## Remote runtime (`reasonix node`) + +- Multi-session daemon; existing single-session `reasonix serve` is unchanged. +- One WebSocket application protocol for direct connect and official relay. +- Node keeps running when the phone backgrounds, drops network, or is killed. +- One writable runtime owner per session; multiple read-only observers allowed. +- Lease and failure-atomicity rules match desktop/CLI session ownership. + +## Account, relay, push + +- Cloud stores account, device/node public keys, session index, online status, + and notification metadata only. Never API keys, code, attachments, or full + transcripts. +- Relay is ciphertext-only (Noise XX + AEAD). Prefer verified LAN / Tailscale; + fall back to official relay. +- APNs / FCM carry minimal signals only (done / failed / needs approval). + +## Navigation and design + +Bottom tabs (exactly four): **Sessions**, **Nodes**, **Providers**, **Settings**. +Chat, approval, Diff, and attachments are detail routes. + +Visual system: precision industrial — charcoal / cool-grey surfaces, warm-copper +accent (`#d97757` family aligned with desktop), independent semantic green / +blue / yellow / red. Full dark and light themes. Corner radius ≤ 8px. No nested +cards. Compact tool timeline. Stable monospace for code and Diff. + +Motion only for navigation, streaming status, approval feedback, and node +connect (120 / 180 / 260 ms). No decorative gradient orbs, blur backgrounds, or +idle looping animations. + +Locales: English, Simplified Chinese, Traditional Chinese. Accessibility: +VoiceOver / TalkBack, WCAG 2.2 AA, 44 / 48 pt targets, reduced motion, high +contrast, non-color status cues. + +## Cache-impact policy + +Mobile work that touches provider requests, system prompt, tool schemas, or +compaction must include PR footer lines: + +``` +Cache-impact: ... +Cache-guard: ... +System-prompt-review: ... +``` + +Local runtime freezes tools at session create and injects dynamic device state +only on user turns so the provider-visible prefix stays stable. + +## Compatibility with existing surfaces + +| Surface | Contract | +| --- | --- | +| `reasonix serve` | Unchanged HTTP + SSE single-session API | +| Desktop / CLI | Unchanged session file formats; new fields `omitempty` | +| Mobile envelope | Versioned; unknown types are ignored or rejected safely | + +## Milestones (summary) + +1. Spec freeze, design tokens, protocol schema, `SessionBackend`, cache baseline. +2. Capacitor shell, navigation, session UI, secure storage, `mobilecore`, local chat. +3. `reasonix node`, multi-session hub, WebSocket, snapshot, idempotent commands, LAN pair. +4. Accounts, Noise relay, node management, APNs/FCM, offline reconnect. +5. Attachments, light tools, HTTP MCP, approval, todos, Diff, tablet, three locales. +6. Security audit, fault injection, store assets, beta roll-out, public launch. + +## CI (current) + +PR / push to `main-v2` runs job **`mobile`** in `.github/workflows/ci.yml`: + +- Go: `internal/mobileprotocol`, `internal/mobilecore`, `internal/node` +- React shell: `mobile/` `npm ci`, typecheck, test, Vite production build + +Local: `make mobile-ci`. Native iOS/Android packaging, `gomobile bind`, and +store publish are not wired yet. + +## Release tags + +- Stable: `mobile-vX.Y.Z` +- RC: `mobile-vX.Y.Z-rc.N` diff --git a/docs/MOBILE.zh-CN.md b/docs/MOBILE.zh-CN.md new file mode 100644 index 0000000000..aeab602c33 --- /dev/null +++ b/docs/MOBILE.zh-CN.md @@ -0,0 +1,148 @@ +# Reasonix Mobile — 产品规格(冻结基线) + +本文冻结 iOS / Android 双运行模式客户端的产品与协议契约。实现以本文为准; +破坏性变更必须提升移动信封版本号。 + +## 目标 + +- 统一 React + Capacitor 应用,覆盖 iPhone、iPad、Android 手机与平板。 +- 两种**创建后不可变**的会话运行时: + - `local`:手机经 `mobilecore` 直连 OpenAI-compatible / Anthropic-compatible 供应商,运行轻量 Agent。 + - `remote`:连接电脑或服务器上的 Reasonix Node,运行完整 Shell、Git、文件、MCP 与后台任务。 +- 本地供应商与局域网 / Tailscale 直连默认无需账号;官方中继、跨网配对与推送需要 Reasonix 账号。 +- 最低系统:iOS 17、Android 10(API 29)。渠道:App Store、Google Play、签名 APK;中国大陆安卓商店为后续阶段。 + +## 非目标(v1) + +- 手机本地完整编码环境(不含 Shell / Git / 任意文件系统 / stdio MCP)。 +- 完整会话内容与 API Key 的云端端到端同步。 +- React Native 或双端独立业务实现。 + +## 架构 + +``` +┌──────────────── mobile (Capacitor) ────────────────┐ +│ UI → SessionBackend → LocalBackend | RemoteBackend │ +└───────────────┬───────────────────┬────────────────┘ + │ │ + mobilecore (Go) WebSocket MobileEnvelope + Keychain/Keystore (局域网 / Tailscale / 中继) + │ │ + Provider APIs reasonix node(多会话) +``` + +### SessionBackend + +UI 只依赖 `SessionBackend`。`runtime` 在会话创建后不可原地修改。本地与远程迁移只能通过「复制到此设备」或「交给节点」创建**新**会话。 + +### MobileEnvelope + +```ts +MobileEnvelope { + version: number + type: string // command | event | ack | hello | snapshot | error | ping | pong + requestId?: string + sessionId?: string + seq?: number + ack?: number + payload?: unknown +} +``` + +- 写命令必须带 `requestId`,节点侧有限去重表;重试返回原结果。 +- 事件使用每会话单调递增 `seq`(从 1 起)。 +- 重连发送 `lastAckSeq`:游标有效则增量补发,过期则返回完整 snapshot。 + +### SessionDescriptor + +```ts +SessionDescriptor { + id: string + runtime: "local" | "remote" // 创建后不可变 + nodeId?: string + providerRef?: string + capabilities: string[] + revision: number + lastEventSeq: number + title?: string + status?: string + updatedAt?: string +} +``` + +Go 侧新字段一律 `omitempty`,保证旧客户端与桌面 / CLI 可读写共存。 + +## 本地 Runtime(`mobilecore`) + +- `gomobile bind` → Android AAR + iOS XCFramework;经 Capacitor 原生插件暴露 JSON API。 +- 复用 Go 侧 Provider 序列化、reasoning 适配、重试、usage、compaction、eventwire 与缓存诊断;**禁止**在 TS / Kotlin / Swift 复制 Provider 协议。 +- 固定 API:`CreateSession`、`RestoreSession`、`Submit`、`Cancel`、`Answer`、`Approve`、`Snapshot`、`SubscribeEvents`、`ListModels`、`ProbeProvider`。 +- 本地工具仅:网页读取、用户授权附件读取、图片输入、HTTP MCP。 +- 工具集合与顺序在会话创建时冻结;设备动态状态只作 user-turn 注入(缓存优先)。 +- API Key 存 Keychain / Keystore;会话快照 AES-GCM 加密后写入 App 私有目录。 + +## 远程 Runtime(`reasonix node`) + +- 多会话守护进程;保留现有单会话 `reasonix serve`。 +- 直连与官方中继共用同一 WebSocket 应用协议。 +- 手机退后台、断网或被杀**不得**中止节点侧任务。 +- 每会话单写入 Runtime、多观察客户端;lease 与 failure-atomicity 与桌面 / CLI 一致。 + +## 账号、中继与推送 + +- 云端仅存账号、设备/节点公钥、会话索引、在线状态与通知元数据。 +- 中继只转发密文(Noise XX + AEAD)。优先已验证局域网 / Tailscale,再回退官方中继。 +- APNs / FCM 仅发「完成 / 失败 / 需要审批」等最小信号。 + +## 导航与设计 + +底部四栏:**会话、节点、供应商、设置**。聊天、审批、Diff、附件为详情路由。 + +视觉:精密工业风 — 炭黑 / 冷灰表面、暖铜主强调色(与桌面 `#d97757` 家族对齐),成功绿 / 信息蓝 / 警告黄 / 危险红独立语义。完整深浅主题;圆角 ≤ 8px;不嵌套卡片;工具执行紧凑时间线;代码与 Diff 稳定等宽字体。 + +动效仅用于页面进入、流式状态、审批反馈、节点连接(120 / 180 / 260 ms)。禁止装饰性渐变球、模糊背景与持续无意义动画。 + +三语:英 / 简中 / 繁中。无障碍:VoiceOver / TalkBack、WCAG 2.2 AA、44 / 48pt 触控目标、减少动态效果、高对比度、非颜色状态提示。 + +## 缓存策略 + +触及 Provider 请求、system prompt、tool schema 或 compaction 的 PR 必须包含: + +``` +Cache-impact: ... +Cache-guard: ... +System-prompt-review: ... +``` + +本地 Runtime 在创建时冻结工具,动态状态只进 user-turn,保持 provider-visible prefix 稳定。 + +## 与现有面兼容 + +| 面 | 契约 | +| --- | --- | +| `reasonix serve` | HTTP + SSE 单会话 API 不变 | +| Desktop / CLI | 会话文件格式不变;新字段 `omitempty` | +| 移动信封 | 版本化;未知类型安全忽略或拒绝 | + +## 里程碑(摘要) + +1. 规格冻结、设计令牌、协议 Schema、`SessionBackend`、缓存基准。 +2. Capacitor 壳、导航、会话 UI、安全存储、`mobilecore`、本地对话。 +3. `reasonix node`、多会话 Hub、WebSocket、快照、幂等命令、局域网配对。 +4. 账号扩展、Noise 中继、节点管理、APNs/FCM、离线重连。 +5. 附件、轻量工具、HTTP MCP、审批、Todos、Diff、平板、三语。 +6. 安全审计、故障注入、商店素材、Beta、公开发布。 + +## CI(当前) + +面向 `main-v2` 的 PR / push 会跑 `.github/workflows/ci.yml` 中的 **`mobile`** job: + +- Go:`internal/mobileprotocol`、`internal/mobilecore`、`internal/node` +- React 壳:`mobile/` 下 `npm ci`、typecheck、test、Vite 生产构建 + +本地镜像:`make mobile-ci`。原生 iOS/Android 打包、`gomobile bind`、商店发布尚未接入。 + +## 发布标签 + +- 稳定:`mobile-vX.Y.Z` +- RC:`mobile-vX.Y.Z-rc.N` diff --git a/go.mod b/go.mod index cd4cc81cc4..ac752af58b 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/aymanbagabas/go-udiff v0.4.1 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charmbracelet/x/ansi v0.11.7 + github.com/gorilla/websocket v1.5.0 github.com/joho/godotenv v1.5.1 github.com/larksuite/oapi-sdk-go/v3 v3.9.5 github.com/mattn/go-runewidth v0.0.24 @@ -55,7 +56,6 @@ require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/gorilla/websocket v1.5.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-pointer v0.0.1 // indirect github.com/muesli/cancelreader v0.2.2 // indirect diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7f4f704208..5e53a21980 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -101,6 +101,8 @@ func Run(args []string, version string) int { return runInteractiveSession(rest) case "serve": return runServe(rest) + case "node": + return runNode(rest) case "setup": configureCLIThemeFromConfigForTTYOutput() return setupConfig(rest) @@ -169,7 +171,7 @@ func isDefaultInteractiveFlag(arg string) bool { func shouldMigrateLegacyConfigForCLI(cmd string) bool { switch cmd { - case "", "run", "chat", "code", "serve", "setup", "config", "init", "acp", "mcp", "plugin", "subagent", "doctor", "bot", "upgrade", "update": + case "", "run", "chat", "code", "serve", "node", "setup", "config", "init", "acp", "mcp", "plugin", "subagent", "doctor", "bot", "upgrade", "update": return true default: return false diff --git a/internal/cli/node.go b/internal/cli/node.go new file mode 100644 index 0000000000..d349e3a5e3 --- /dev/null +++ b/internal/cli/node.go @@ -0,0 +1,64 @@ +package cli + +import ( + "context" + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "reasonix/internal/i18n" + "reasonix/internal/node" +) + +// runNode starts the multi-session Reasonix Node daemon for mobile remote mode. +// It is intentionally separate from single-session reasonix serve. +func runNode(args []string) int { + fs := flag.NewFlagSet("node", flag.ContinueOnError) + addr := fs.String("addr", "127.0.0.1:8790", "listen address for the mobile WebSocket/HTTP API") + nodeID := fs.String("id", "", "stable node identity (default: hostname-based)") + if err := fs.Parse(args); err != nil { + return 2 + } + id := *nodeID + if id == "" { + host, _ := os.Hostname() + if host == "" { + host = "reasonix" + } + id = "node-" + host + } + hub := node.NewHub(id) + srv := &http.Server{ + Addr: *addr, + Handler: hub.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + fmt.Printf("reasonix node — %s on http://%s\n", id, *addr) + fmt.Printf(" mobile ws: ws://%s/mobile/ws\n", *addr) + fmt.Printf(" health: http://%s/healthz\n", *addr) + fmt.Printf(" note: single-session browser API remains `reasonix serve`\n") + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + errCh := make(chan error, 1) + go func() { + errCh <- srv.ListenAndServe() + }() + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + return 0 + case err := <-errCh: + if err != nil && err != http.ErrServerClosed { + fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) + return 1 + } + return 0 + } +} diff --git a/internal/i18n/messages_en.go b/internal/i18n/messages_en.go index c3804f3a64..2a8edbba85 100644 --- a/internal/i18n/messages_en.go +++ b/internal/i18n/messages_en.go @@ -494,6 +494,7 @@ Usage: reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] [--copy] [--output-format FORMAT] reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI-powered code review on local diffs reasonix serve [--model NAME] [--addr HOST:PORT] [--auth none|token|password] [--token STR] [--password STR] [--hash-password] serve over HTTP+SSE (with optional auth) + reasonix node [--addr HOST:PORT] [--id NODE_ID] multi-session mobile node (WebSocket remote runtime) reasonix acp [--model NAME] serve Agent Client Protocol over stdio (also: reasonix --acp) reasonix setup [path] interactive config wizard; writes reasonix.toml (+ .env) reasonix config auto-plan [off|on] configure automatic plan mode diff --git a/internal/i18n/messages_zh.go b/internal/i18n/messages_zh.go index 1e76ab247f..4b4b065039 100644 --- a/internal/i18n/messages_zh.go +++ b/internal/i18n/messages_zh.go @@ -495,6 +495,7 @@ var Chinese = Messages{ reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] [--copy] [--output-format FORMAT] reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI 代码审查(基于本地 diff) reasonix serve [--model NAME] [--addr HOST:PORT] [--auth none|token|password] [--token STR] [--password STR] [--hash-password] 通过 HTTP+SSE 提供服务(支持可选认证) + reasonix node [--addr HOST:PORT] [--id NODE_ID] 多会话移动 Node(WebSocket 远程 Runtime) reasonix acp [--model NAME] 通过 stdio 提供 Agent Client Protocol(也可用:reasonix --acp) reasonix setup [path] 交互式配置向导;生成 reasonix.toml(及 .env) reasonix config auto-plan [off|on] 配置自动计划模式 diff --git a/internal/i18n/messages_zh_tw.go b/internal/i18n/messages_zh_tw.go index 244bc7e9fc..040ad9e5ce 100644 --- a/internal/i18n/messages_zh_tw.go +++ b/internal/i18n/messages_zh_tw.go @@ -443,6 +443,7 @@ var ChineseTraditional = Messages{ reasonix run [--model NAME] [--max-steps N] [-c|--continue] [--resume PATH] [--copy] [--output-format FORMAT] reasonix review [--base BRANCH] [--commit SHA] [--model NAME] AI 程式碼審查(基於本機 diff) reasonix serve [--model NAME] [--addr HOST:PORT] [--auth none|token|password] [--token STR] [--password STR] [--hash-password] 透過 HTTP+SSE 提供服務(支援可選認證) + reasonix node [--addr HOST:PORT] [--id NODE_ID] 多會話行動 Node(WebSocket 遠端 Runtime) reasonix acp [--model NAME] 透過 stdio 提供 Agent Client Protocol(也可用:reasonix --acp) reasonix setup [path] 互動式設定精靈;生成 reasonix.toml(及 .env) reasonix config auto-plan [off|on] 設定自動計畫模式 diff --git a/internal/mobilecore/core.go b/internal/mobilecore/core.go new file mode 100644 index 0000000000..4d628fd81f --- /dev/null +++ b/internal/mobilecore/core.go @@ -0,0 +1,253 @@ +// Package mobilecore is the on-device Reasonix agent SDK compiled for mobile +// via gomobile bind. It exposes a JSON-string API so Kotlin/Swift Capacitor +// plugins stay thin. +// +// Cache-first rules: +// - Local tool capabilities freeze at CreateSession and never reorder. +// - Dynamic device state must be injected as user-turn data by the caller, +// never by mutating system prompt or tool schemas here. +// +// Provider request serialization reuses the shared Go provider packages; this +// package must not reimplement wire formats in TypeScript/Kotlin/Swift. +package mobilecore + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + "sync" + "time" + + "reasonix/internal/mobileprotocol" +) + +// Core is the process-wide mobile SDK entry. Methods are safe for concurrent +// use from the native bridge. +type Core struct { + mu sync.Mutex + sessions map[string]*localSession +} + +type localSession struct { + desc mobileprotocol.SessionDescriptor + events []json.RawMessage + seq uint64 + // frozen model/provider refs — immutable for the life of the session. + modelRef string + providerRef string +} + +// New constructs an empty Core. +func New() *Core { + return &Core{sessions: make(map[string]*localSession)} +} + +// defaultCore is used by package-level functions that gomobile prefers. +var defaultCore = New() + +// CreateSessionJSON creates a local session from a JSON CreateSessionArgs body. +// Returns a JSON SessionDescriptor or a JSON error object. +func CreateSessionJSON(argsJSON string) string { + return defaultCore.CreateSessionJSON(argsJSON) +} + +// CreateSessionJSON is the instance method used by tests and non-gomobile hosts. +func (c *Core) CreateSessionJSON(argsJSON string) string { + var args mobileprotocol.CreateSessionArgs + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return errJSON("bad_args", err.Error()) + } + if args.Runtime == "" { + args.Runtime = mobileprotocol.RuntimeLocal + } + if args.Runtime != mobileprotocol.RuntimeLocal { + return errJSON("invalid_runtime", "mobilecore only creates local sessions") + } + id := fmt.Sprintf("local_%d", time.Now().UnixNano()) + d := mobileprotocol.SessionDescriptor{ + ID: id, + Runtime: mobileprotocol.RuntimeLocal, + ProviderRef: args.ProviderRef, + Title: args.Title, + Revision: 1, + } + d.Normalize() + // Freeze local capability order at creation (cache-sensitive catalog). + d.Capabilities = append([]string(nil), mobileprotocol.LocalCapabilities...) + + c.mu.Lock() + c.sessions[id] = &localSession{ + desc: d, + modelRef: args.ModelRef, + providerRef: args.ProviderRef, + } + c.mu.Unlock() + + b, _ := json.Marshal(d) + return string(b) +} + +// SubmitJSON submits a user turn. argsJSON is SubmitArgs; sessionID is required. +func SubmitJSON(sessionID, argsJSON, requestID string) string { + return defaultCore.SubmitJSON(sessionID, argsJSON, requestID) +} + +// SubmitJSON implements the local submit path. Full provider streaming is wired +// in the local-chat milestone; this validates contracts and records events. +func (c *Core) SubmitJSON(sessionID, argsJSON, requestID string) string { + _ = requestID // reserved for bridge-level dedupe + var args mobileprotocol.SubmitArgs + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return errJSON("bad_args", err.Error()) + } + if strings.TrimSpace(args.Text) == "" { + return errJSON("bad_args", "text is required") + } + c.mu.Lock() + s, ok := c.sessions[sessionID] + if !ok { + c.mu.Unlock() + return errJSON("not_found", "session not found") + } + s.seq++ + seq := s.seq + s.desc.LastEventSeq = seq + s.desc.Status = "idle" + s.desc.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) + ev, _ := json.Marshal(map[string]any{ + "kind": "notice", + "text": "mobilecore accepted submit (provider stream pending)", + "seq": seq, + }) + s.events = append(s.events, ev) + c.mu.Unlock() + + ack := map[string]any{"accepted": true, "seq": seq, "requestId": requestID} + b, _ := json.Marshal(ack) + return string(b) +} + +// SnapshotJSON returns a SnapshotPayload for the session. +func SnapshotJSON(sessionID string) string { + return defaultCore.SnapshotJSON(sessionID) +} + +// SnapshotJSON returns recovery state for the local session. +func (c *Core) SnapshotJSON(sessionID string) string { + c.mu.Lock() + defer c.mu.Unlock() + s, ok := c.sessions[sessionID] + if !ok { + return errJSON("not_found", "session not found") + } + hist, _ := json.Marshal(s.events) + snap := mobileprotocol.SnapshotPayload{ + Descriptor: s.desc, + History: hist, + LastEventSeq: s.desc.LastEventSeq, + Revision: s.desc.Revision, + } + b, _ := json.Marshal(snap) + return string(b) +} + +// ListModelsJSON returns the configured model catalog placeholder. +// Real enumeration reuses config/provider packages in a later milestone. +func ListModelsJSON() string { + return `{"models":[]}` +} + +// ProbeProviderJSON validates provider endpoint policy before storing a key. +// HTTPS is required by default; http is only allowed for localhost/LAN when +// allowInsecureHttp is true. +func ProbeProviderJSON(argsJSON string) string { + return defaultCore.ProbeProviderJSON(argsJSON) +} + +// ProbeProviderJSON implements provider URL policy checks. +func (c *Core) ProbeProviderJSON(argsJSON string) string { + var args mobileprotocol.ProbeProviderArgs + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return errJSON("bad_args", err.Error()) + } + if args.ProviderRef == "" && args.BaseURL == "" { + return errJSON("bad_args", "providerRef or baseUrl is required") + } + if args.BaseURL != "" { + if err := validateProviderURL(args.BaseURL, args.AllowInsecureHTTP); err != nil { + return errJSON("insecure_url", err.Error()) + } + } + b, _ := json.Marshal(map[string]any{ + "ok": true, + "providerRef": args.ProviderRef, + // Actual network probe lands with provider wiring. + "probed": false, + }) + return string(b) +} + +// LocalCapabilitiesJSON returns the frozen local capability list. +func LocalCapabilitiesJSON() string { + b, _ := json.Marshal(mobileprotocol.LocalCapabilities) + return string(b) +} + +// CancelJSON marks a local turn cancel request. +func CancelJSON(sessionID, requestID string) string { + return defaultCore.CancelJSON(sessionID, requestID) +} + +// CancelJSON implements cancel for a local session. +func (c *Core) CancelJSON(sessionID, requestID string) string { + c.mu.Lock() + defer c.mu.Unlock() + s, ok := c.sessions[sessionID] + if !ok { + return errJSON("not_found", "session not found") + } + s.desc.Status = "idle" + b, _ := json.Marshal(map[string]any{"ok": true, "requestId": requestID}) + return string(b) +} + +func validateProviderURL(raw string, allowInsecure bool) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid url: %w", err) + } + switch strings.ToLower(u.Scheme) { + case "https": + return nil + case "http": + if !allowInsecure { + return fmt.Errorf("http requires explicit allowInsecureHttp") + } + host := strings.ToLower(u.Hostname()) + if host == "localhost" || host == "127.0.0.1" || host == "::1" || isPrivateHost(host) { + return nil + } + return fmt.Errorf("http only allowed for localhost/LAN") + default: + return fmt.Errorf("unsupported scheme %q", u.Scheme) + } +} + +func isPrivateHost(host string) bool { + // Conservative hostname check; full IP CIDR validation lands with networking. + return strings.HasPrefix(host, "10.") || + strings.HasPrefix(host, "192.168.") || + strings.HasPrefix(host, "172.16.") || + strings.HasPrefix(host, "172.17.") || + strings.HasPrefix(host, "172.18.") || + strings.HasPrefix(host, "172.19.") || + strings.HasPrefix(host, "172.2") || + strings.HasPrefix(host, "172.30.") || + strings.HasPrefix(host, "172.31.") +} + +func errJSON(code, msg string) string { + b, _ := json.Marshal(map[string]any{"error": map[string]string{"code": code, "message": msg}}) + return string(b) +} diff --git a/internal/mobilecore/core_test.go b/internal/mobilecore/core_test.go new file mode 100644 index 0000000000..bda64293d2 --- /dev/null +++ b/internal/mobilecore/core_test.go @@ -0,0 +1,78 @@ +package mobilecore + +import ( + "encoding/json" + "strings" + "testing" + + "reasonix/internal/mobileprotocol" +) + +func TestCreateSessionLocalOnly(t *testing.T) { + c := New() + out := c.CreateSessionJSON(`{"runtime":"remote","title":"x"}`) + if !strings.Contains(out, "invalid_runtime") { + t.Fatalf("expected reject remote: %s", out) + } + out = c.CreateSessionJSON(`{"title":"hello","providerRef":"openai/gpt"}`) + var d mobileprotocol.SessionDescriptor + if err := json.Unmarshal([]byte(out), &d); err != nil { + t.Fatal(err, out) + } + if d.Runtime != mobileprotocol.RuntimeLocal || d.ID == "" { + t.Fatalf("%+v", d) + } + if len(d.Capabilities) != len(mobileprotocol.LocalCapabilities) { + t.Fatalf("capabilities not frozen: %v", d.Capabilities) + } + // Order golden — cache-sensitive when later mapped to tools. + for i, want := range mobileprotocol.LocalCapabilities { + if d.Capabilities[i] != want { + t.Fatalf("capability order drift: %v", d.Capabilities) + } + } +} + +func TestSubmitAndSnapshot(t *testing.T) { + c := New() + var d mobileprotocol.SessionDescriptor + if err := json.Unmarshal([]byte(c.CreateSessionJSON(`{"title":"t"}`)), &d); err != nil { + t.Fatal(err) + } + ack := c.SubmitJSON(d.ID, `{"text":"hi"}`, "req-1") + if strings.Contains(ack, `"error"`) { + t.Fatal(ack) + } + snap := c.SnapshotJSON(d.ID) + var payload mobileprotocol.SnapshotPayload + if err := json.Unmarshal([]byte(snap), &payload); err != nil { + t.Fatal(err, snap) + } + if payload.LastEventSeq != 1 { + t.Fatalf("seq=%d", payload.LastEventSeq) + } +} + +func TestProbeProviderHTTPSPolicy(t *testing.T) { + c := New() + if out := c.ProbeProviderJSON(`{"baseUrl":"http://evil.example"}`); !strings.Contains(out, "insecure") && !strings.Contains(out, "http") { + t.Fatalf("expected reject: %s", out) + } + if out := c.ProbeProviderJSON(`{"baseUrl":"http://127.0.0.1:8080","allowInsecureHttp":true}`); strings.Contains(out, `"error"`) { + t.Fatalf("localhost http should be allowed: %s", out) + } + if out := c.ProbeProviderJSON(`{"baseUrl":"https://api.openai.com","providerRef":"openai"}`); strings.Contains(out, `"error"`) { + t.Fatalf("https should be ok: %s", out) + } +} + +func TestLocalCapabilitiesJSONStable(t *testing.T) { + var caps []string + if err := json.Unmarshal([]byte(LocalCapabilitiesJSON()), &caps); err != nil { + t.Fatal(err) + } + want := []string{"web_read", "attachment_read", "image_input", "http_mcp"} + if strings.Join(caps, ",") != strings.Join(want, ",") { + t.Fatalf("got %v", caps) + } +} diff --git a/internal/mobileprotocol/protocol.go b/internal/mobileprotocol/protocol.go new file mode 100644 index 0000000000..dfcbac2606 --- /dev/null +++ b/internal/mobileprotocol/protocol.go @@ -0,0 +1,263 @@ +// Package mobileprotocol defines the versioned mobile transport contract shared +// by mobilecore, reasonix node, and the Capacitor client. It is intentionally +// independent of HTTP serve and desktop Wails so existing frontends stay stable. +package mobileprotocol + +import ( + "encoding/json" + "fmt" + "time" +) + +// CurrentVersion is the envelope schema version understood by this build. +const CurrentVersion = 1 + +// Envelope type discriminators. +const ( + TypeHello = "hello" + TypeCommand = "command" + TypeEvent = "event" + TypeAck = "ack" + TypeSnapshot = "snapshot" + TypeError = "error" + TypePing = "ping" + TypePong = "pong" +) + +// Runtime identifiers. Frozen after session creation. +const ( + RuntimeLocal = "local" + RuntimeRemote = "remote" +) + +// Command names carried in Envelope.Type == TypeCommand payloads. +const ( + CmdCreateSession = "create_session" + CmdRestoreSession = "restore_session" + CmdSubmit = "submit" + CmdCancel = "cancel" + CmdAnswer = "answer" + CmdApprove = "approve" + CmdSnapshot = "snapshot" + CmdListModels = "list_models" + CmdProbeProvider = "probe_provider" + CmdSubscribe = "subscribe" + CmdHello = "hello" +) + +// LocalCapabilities is the frozen tool surface for on-device sessions. +// Order is stable and must not be reordered casually (cache-sensitive when +// exposed as provider tools). +var LocalCapabilities = []string{ + "web_read", + "attachment_read", + "image_input", + "http_mcp", +} + +// RemoteCapabilities is the full node tool surface (informational catalog). +var RemoteCapabilities = []string{ + "shell", + "git", + "filesystem", + "web_read", + "attachment_read", + "image_input", + "http_mcp", + "stdio_mcp", + "background_jobs", + "approval", +} + +// Envelope is the versioned mobile wire frame. +// +// JSON field names are stable. New optional fields must use omitempty. +type Envelope struct { + Version int `json:"version"` + Type string `json:"type"` + RequestID string `json:"requestId,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Seq uint64 `json:"seq,omitempty"` + Ack uint64 `json:"ack,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +// Validate performs cheap structural checks before routing. +func (e Envelope) Validate() error { + if e.Version <= 0 { + return fmt.Errorf("mobileprotocol: version must be positive") + } + if e.Version > CurrentVersion { + return fmt.Errorf("mobileprotocol: unsupported version %d (max %d)", e.Version, CurrentVersion) + } + if e.Type == "" { + return fmt.Errorf("mobileprotocol: type is required") + } + return nil +} + +// NewEnvelope builds a versioned envelope of the given type. +func NewEnvelope(typ string) Envelope { + return Envelope{Version: CurrentVersion, Type: typ} +} + +// MarshalPayload encodes v into the envelope payload. +func (e *Envelope) MarshalPayload(v any) error { + if v == nil { + e.Payload = nil + return nil + } + b, err := json.Marshal(v) + if err != nil { + return err + } + e.Payload = b + return nil +} + +// UnmarshalPayload decodes the envelope payload into v. +func (e Envelope) UnmarshalPayload(v any) error { + if len(e.Payload) == 0 || string(e.Payload) == "null" { + return nil + } + return json.Unmarshal(e.Payload, v) +} + +// SessionDescriptor is the durable session index shared by mobile and node. +// Runtime is immutable after create; migration creates a new descriptor. +type SessionDescriptor struct { + ID string `json:"id"` + Runtime string `json:"runtime"` // local | remote + NodeID string `json:"nodeId,omitempty"` + ProviderRef string `json:"providerRef,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Revision int64 `json:"revision"` + LastEventSeq uint64 `json:"lastEventSeq"` + Title string `json:"title,omitempty"` + Status string `json:"status,omitempty"` // idle | running | pending_approval | failed + UpdatedAt string `json:"updatedAt,omitempty"` +} + +// Normalize fills defaults for a newly created descriptor. +func (d *SessionDescriptor) Normalize() { + if d.Runtime == "" { + d.Runtime = RuntimeLocal + } + if d.Capabilities == nil { + if d.Runtime == RuntimeRemote { + d.Capabilities = append([]string(nil), RemoteCapabilities...) + } else { + d.Capabilities = append([]string(nil), LocalCapabilities...) + } + } + if d.Status == "" { + d.Status = "idle" + } + if d.UpdatedAt == "" { + d.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) + } +} + +// ValidRuntime reports whether runtime is a known immutable mode. +func ValidRuntime(runtime string) bool { + return runtime == RuntimeLocal || runtime == RuntimeRemote +} + +// HelloPayload is exchanged when a client connects to a node. +type HelloPayload struct { + ClientID string `json:"clientId,omitempty"` + DeviceName string `json:"deviceName,omitempty"` + AppVersion string `json:"appVersion,omitempty"` + LastAckSeq uint64 `json:"lastAckSeq,omitempty"` + SessionID string `json:"sessionId,omitempty"` + NodeToken string `json:"nodeToken,omitempty"` + ProtocolMax int `json:"protocolMax,omitempty"` +} + +// CommandPayload wraps a named command and its arguments. +type CommandPayload struct { + Name string `json:"name"` + Args json.RawMessage `json:"args,omitempty"` +} + +// CreateSessionArgs creates a new session on local or remote runtime. +type CreateSessionArgs struct { + Runtime string `json:"runtime"` + ProviderRef string `json:"providerRef,omitempty"` + Title string `json:"title,omitempty"` + // ModelRef freezes the model at creation for local sessions. + ModelRef string `json:"modelRef,omitempty"` +} + +// SubmitArgs is a user message submission. +type SubmitArgs struct { + Text string `json:"text"` + Display string `json:"display,omitempty"` + Images []string `json:"images,omitempty"` // app-local attachment ids +} + +// ApproveArgs answers a tool approval prompt. +type ApproveArgs struct { + ID string `json:"id"` + Allow bool `json:"allow"` + Session bool `json:"session,omitempty"` + Persist bool `json:"persist,omitempty"` +} + +// AnswerArgs answers an ask_user prompt. +type AnswerArgs struct { + ID string `json:"id"` + Answers json.RawMessage `json:"answers"` +} + +// ProbeProviderArgs checks connectivity for a provider endpoint. +type ProbeProviderArgs struct { + ProviderRef string `json:"providerRef"` + BaseURL string `json:"baseUrl,omitempty"` + // AllowInsecureHTTP permits http:// only for localhost/LAN after explicit user opt-in. + AllowInsecureHTTP bool `json:"allowInsecureHttp,omitempty"` +} + +// ErrorPayload is returned on TypeError frames. +type ErrorPayload struct { + Code string `json:"code,omitempty"` + Message string `json:"message"` + Retry bool `json:"retry,omitempty"` +} + +// SnapshotPayload is a full session recovery payload when event cursor is stale. +type SnapshotPayload struct { + Descriptor SessionDescriptor `json:"descriptor"` + History json.RawMessage `json:"history,omitempty"` + PartialTurn json.RawMessage `json:"partialTurn,omitempty"` + Todos json.RawMessage `json:"todos,omitempty"` + Running bool `json:"running,omitempty"` + PendingApproval json.RawMessage `json:"pendingApproval,omitempty"` + LastEventSeq uint64 `json:"lastEventSeq"` + Revision int64 `json:"revision"` +} + +// EventPayload carries a wired session event (eventwire-compatible object). +type EventPayload struct { + Event json.RawMessage `json:"event"` +} + +// Encode is a convenience helper for tests and CLI dumps. +func Encode(e Envelope) ([]byte, error) { + if err := e.Validate(); err != nil { + return nil, err + } + return json.Marshal(e) +} + +// Decode parses and validates an envelope. +func Decode(data []byte) (Envelope, error) { + var e Envelope + if err := json.Unmarshal(data, &e); err != nil { + return Envelope{}, err + } + if err := e.Validate(); err != nil { + return Envelope{}, err + } + return e, nil +} diff --git a/internal/mobileprotocol/protocol_test.go b/internal/mobileprotocol/protocol_test.go new file mode 100644 index 0000000000..ef0d50809b --- /dev/null +++ b/internal/mobileprotocol/protocol_test.go @@ -0,0 +1,122 @@ +package mobileprotocol + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestEnvelopeRoundTripOmitsEmpty(t *testing.T) { + e := NewEnvelope(TypePing) + b, err := Encode(e) + if err != nil { + t.Fatal(err) + } + s := string(b) + for _, banned := range []string{`"requestId"`, `"sessionId"`, `"seq"`, `"ack"`, `"payload"`} { + if strings.Contains(s, banned) { + t.Fatalf("expected omitempty for empty fields, got %s", s) + } + } + got, err := Decode(b) + if err != nil { + t.Fatal(err) + } + if got.Version != CurrentVersion || got.Type != TypePing { + t.Fatalf("round-trip mismatch: %+v", got) + } +} + +func TestEnvelopeRejectsFutureVersion(t *testing.T) { + _, err := Decode([]byte(`{"version":99,"type":"ping"}`)) + if err == nil { + t.Fatal("expected error for future version") + } +} + +func TestSessionDescriptorNormalizeLocal(t *testing.T) { + d := SessionDescriptor{ID: "s1", Runtime: RuntimeLocal} + d.Normalize() + if d.Status != "idle" { + t.Fatalf("status=%q", d.Status) + } + if len(d.Capabilities) != len(LocalCapabilities) { + t.Fatalf("capabilities=%v", d.Capabilities) + } + // Local must not advertise shell/git. + for _, c := range d.Capabilities { + if c == "shell" || c == "git" || c == "stdio_mcp" { + t.Fatalf("local capabilities must not include %q", c) + } + } +} + +func TestSessionDescriptorJSONOmitempty(t *testing.T) { + d := SessionDescriptor{ID: "s1", Runtime: RuntimeLocal, Revision: 1} + b, err := json.Marshal(d) + if err != nil { + t.Fatal(err) + } + s := string(b) + if strings.Contains(s, `"nodeId"`) || strings.Contains(s, `"title"`) { + t.Fatalf("unexpected fields: %s", s) + } +} + +func TestValidRuntime(t *testing.T) { + if !ValidRuntime(RuntimeLocal) || !ValidRuntime(RuntimeRemote) { + t.Fatal("expected local/remote valid") + } + if ValidRuntime("hybrid") { + t.Fatal("hybrid must be invalid — runtime is immutable dual mode only") + } +} + +func TestCommandPayloadCreateSession(t *testing.T) { + args := CreateSessionArgs{Runtime: RuntimeRemote, ProviderRef: "openai/gpt-4.1", Title: "demo"} + raw, err := json.Marshal(args) + if err != nil { + t.Fatal(err) + } + cmd := CommandPayload{Name: CmdCreateSession, Args: raw} + env := NewEnvelope(TypeCommand) + env.RequestID = "req-1" + if err := env.MarshalPayload(cmd); err != nil { + t.Fatal(err) + } + b, err := Encode(env) + if err != nil { + t.Fatal(err) + } + got, err := Decode(b) + if err != nil { + t.Fatal(err) + } + var out CommandPayload + if err := got.UnmarshalPayload(&out); err != nil { + t.Fatal(err) + } + if out.Name != CmdCreateSession { + t.Fatalf("name=%q", out.Name) + } + var gotArgs CreateSessionArgs + if err := json.Unmarshal(out.Args, &gotArgs); err != nil { + t.Fatal(err) + } + if gotArgs.Runtime != RuntimeRemote || gotArgs.Title != "demo" { + t.Fatalf("args=%+v", gotArgs) + } +} + +func TestLocalCapabilitiesOrderStable(t *testing.T) { + // Cache-sensitive surfaces freeze tool order; treat this slice as a golden list. + want := []string{"web_read", "attachment_read", "image_input", "http_mcp"} + if len(LocalCapabilities) != len(want) { + t.Fatalf("len=%d want %d", len(LocalCapabilities), len(want)) + } + for i := range want { + if LocalCapabilities[i] != want[i] { + t.Fatalf("order drift at %d: got %q want %q", i, LocalCapabilities[i], want[i]) + } + } +} diff --git a/internal/node/dedupe.go b/internal/node/dedupe.go new file mode 100644 index 0000000000..b2d1d54285 --- /dev/null +++ b/internal/node/dedupe.go @@ -0,0 +1,61 @@ +package node + +import "sync" + +// DedupeEntry stores the first successful response for a requestId. +type DedupeEntry struct { + Response []byte +} + +// RequestDedupe is a bounded map of requestId → first response. +// Retried mobile write commands must not re-execute side effects. +type RequestDedupe struct { + mu sync.Mutex + max int + order []string + entries map[string]DedupeEntry +} + +// NewRequestDedupe retains at most max entries (min 1). +func NewRequestDedupe(max int) *RequestDedupe { + if max < 1 { + max = 1 + } + return &RequestDedupe{ + max: max, + entries: make(map[string]DedupeEntry), + } +} + +// Lookup returns a prior response when the request was already processed. +func (d *RequestDedupe) Lookup(requestID string) (DedupeEntry, bool) { + if requestID == "" { + return DedupeEntry{}, false + } + d.mu.Lock() + defer d.mu.Unlock() + e, ok := d.entries[requestID] + return e, ok +} + +// Remember stores the first response for requestID. Later Remember calls for +// the same id are ignored so concurrent retries cannot overwrite the winner. +func (d *RequestDedupe) Remember(requestID string, response []byte) { + if requestID == "" { + return + } + d.mu.Lock() + defer d.mu.Unlock() + if _, exists := d.entries[requestID]; exists { + return + } + cp := make([]byte, len(response)) + copy(cp, response) + d.entries[requestID] = DedupeEntry{Response: cp} + d.order = append(d.order, requestID) + for len(d.order) > d.max { + old := d.order[0] + d.order = d.order[1:] + delete(d.entries, old) + } +} diff --git a/internal/node/hub.go b/internal/node/hub.go new file mode 100644 index 0000000000..665592d756 --- /dev/null +++ b/internal/node/hub.go @@ -0,0 +1,447 @@ +// Package node implements the multi-session Reasonix Node daemon used by the +// mobile remote runtime. It preserves single-session reasonix serve unchanged. +package node + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + + "reasonix/internal/mobileprotocol" +) + +const ( + defaultEventRingSize = 512 + defaultDedupeSize = 256 +) + +// SessionSlot is one active remote session on the node. +// Full Controller wiring lands in a follow-up milestone; the slot already +// owns lease identity, sequenced events, and request dedupe. +type SessionSlot struct { + mu sync.Mutex + Descriptor mobileprotocol.SessionDescriptor + Ring *EventRing + Dedupe *RequestDedupe + Running bool + // observers are active WebSocket connections watching this session. + observers map[*websocket.Conn]struct{} +} + +// Hub is the multi-session node process. +type Hub struct { + mu sync.Mutex + nodeID string + sessions map[string]*SessionSlot + upgrader websocket.Upgrader +} + +// NewHub creates an empty multi-session hub. +func NewHub(nodeID string) *Hub { + if nodeID == "" { + nodeID = "node-local" + } + return &Hub{ + nodeID: nodeID, + sessions: make(map[string]*SessionSlot), + upgrader: websocket.Upgrader{ + // LAN pairing uses token/fingerprint auth at the app layer; origin + // checks are enforced once the pairing protocol is complete. + CheckOrigin: func(r *http.Request) bool { return true }, + }, + } +} + +// NodeID returns the stable node identity. +func (h *Hub) NodeID() string { return h.nodeID } + +// CreateSession registers a new remote session descriptor. +func (h *Hub) CreateSession(title, providerRef string) mobileprotocol.SessionDescriptor { + h.mu.Lock() + defer h.mu.Unlock() + id := fmt.Sprintf("sess_%d", time.Now().UnixNano()) + d := mobileprotocol.SessionDescriptor{ + ID: id, + Runtime: mobileprotocol.RuntimeRemote, + NodeID: h.nodeID, + ProviderRef: providerRef, + Title: title, + Revision: 1, + } + d.Normalize() + slot := &SessionSlot{ + Descriptor: d, + Ring: NewEventRing(defaultEventRingSize), + Dedupe: NewRequestDedupe(defaultDedupeSize), + observers: make(map[*websocket.Conn]struct{}), + } + h.sessions[id] = slot + return d +} + +// GetSession returns a copy of the descriptor when present. +func (h *Hub) GetSession(id string) (mobileprotocol.SessionDescriptor, bool) { + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.sessions[id] + if !ok { + return mobileprotocol.SessionDescriptor{}, false + } + s.mu.Lock() + defer s.mu.Unlock() + return s.Descriptor, true +} + +// ListSessions returns descriptors sorted by id for stable tests. +func (h *Hub) ListSessions() []mobileprotocol.SessionDescriptor { + h.mu.Lock() + defer h.mu.Unlock() + out := make([]mobileprotocol.SessionDescriptor, 0, len(h.sessions)) + for _, s := range h.sessions { + s.mu.Lock() + out = append(out, s.Descriptor) + s.mu.Unlock() + } + return out +} + +// HandleCommand routes a mobile command envelope and returns a response envelope. +// Write commands with requestId are idempotent via the session dedupe table. +func (h *Hub) HandleCommand(env mobileprotocol.Envelope) mobileprotocol.Envelope { + if err := env.Validate(); err != nil { + return errorEnv("", "", "bad_envelope", err.Error()) + } + var cmd mobileprotocol.CommandPayload + if err := env.UnmarshalPayload(&cmd); err != nil { + return errorEnv(env.RequestID, env.SessionID, "bad_payload", err.Error()) + } + + // Session-scoped dedupe for write commands. + if env.SessionID != "" && env.RequestID != "" && isWriteCommand(cmd.Name) { + if slot := h.slot(env.SessionID); slot != nil { + if prev, ok := slot.Dedupe.Lookup(env.RequestID); ok { + var prior mobileprotocol.Envelope + if err := json.Unmarshal(prev.Response, &prior); err == nil { + return prior + } + } + } + } + + var resp mobileprotocol.Envelope + switch cmd.Name { + case mobileprotocol.CmdHello, mobileprotocol.CmdCreateSession: + resp = h.handleCreateOrHello(env, cmd) + case mobileprotocol.CmdSnapshot: + resp = h.handleSnapshot(env) + case mobileprotocol.CmdSubmit: + resp = h.handleSubmit(env, cmd) + case mobileprotocol.CmdCancel: + resp = h.handleCancel(env) + case mobileprotocol.CmdApprove: + resp = h.handleApprove(env, cmd) + case mobileprotocol.CmdListModels: + resp = h.handleListModels(env) + default: + resp = errorEnv(env.RequestID, env.SessionID, "unknown_command", "unknown command: "+cmd.Name) + } + + if env.SessionID != "" && env.RequestID != "" && isWriteCommand(cmd.Name) { + if slot := h.slot(env.SessionID); slot != nil { + if b, err := json.Marshal(resp); err == nil { + slot.Dedupe.Remember(env.RequestID, b) + } + } + } + // create_session stores dedupe under the new session id after creation. + if cmd.Name == mobileprotocol.CmdCreateSession && env.RequestID != "" && resp.SessionID != "" { + if slot := h.slot(resp.SessionID); slot != nil { + if b, err := json.Marshal(resp); err == nil { + slot.Dedupe.Remember(env.RequestID, b) + } + } + } + return resp +} + +func isWriteCommand(name string) bool { + switch name { + case mobileprotocol.CmdCreateSession, mobileprotocol.CmdSubmit, mobileprotocol.CmdCancel, + mobileprotocol.CmdApprove, mobileprotocol.CmdAnswer, mobileprotocol.CmdRestoreSession: + return true + default: + return false + } +} + +func (h *Hub) slot(id string) *SessionSlot { + h.mu.Lock() + defer h.mu.Unlock() + return h.sessions[id] +} + +func (h *Hub) handleCreateOrHello(env mobileprotocol.Envelope, cmd mobileprotocol.CommandPayload) mobileprotocol.Envelope { + if cmd.Name == mobileprotocol.CmdHello { + resp := mobileprotocol.NewEnvelope(mobileprotocol.TypeHello) + resp.RequestID = env.RequestID + _ = resp.MarshalPayload(map[string]any{ + "nodeId": h.nodeID, + "protocolMax": mobileprotocol.CurrentVersion, + }) + return resp + } + var args mobileprotocol.CreateSessionArgs + _ = json.Unmarshal(cmd.Args, &args) + if args.Runtime != "" && args.Runtime != mobileprotocol.RuntimeRemote { + return errorEnv(env.RequestID, "", "invalid_runtime", "node only creates remote sessions") + } + d := h.CreateSession(args.Title, args.ProviderRef) + resp := mobileprotocol.NewEnvelope(mobileprotocol.TypeSnapshot) + resp.RequestID = env.RequestID + resp.SessionID = d.ID + _ = resp.MarshalPayload(mobileprotocol.SnapshotPayload{ + Descriptor: d, + LastEventSeq: 0, + Revision: d.Revision, + }) + return resp +} + +func (h *Hub) handleSnapshot(env mobileprotocol.Envelope) mobileprotocol.Envelope { + slot := h.slot(env.SessionID) + if slot == nil { + return errorEnv(env.RequestID, env.SessionID, "not_found", "session not found") + } + slot.mu.Lock() + defer slot.mu.Unlock() + resp := mobileprotocol.NewEnvelope(mobileprotocol.TypeSnapshot) + resp.RequestID = env.RequestID + resp.SessionID = env.SessionID + _ = resp.MarshalPayload(mobileprotocol.SnapshotPayload{ + Descriptor: slot.Descriptor, + Running: slot.Running, + LastEventSeq: slot.Ring.LastSeq(), + Revision: slot.Descriptor.Revision, + }) + return resp +} + +func (h *Hub) handleSubmit(env mobileprotocol.Envelope, cmd mobileprotocol.CommandPayload) mobileprotocol.Envelope { + slot := h.slot(env.SessionID) + if slot == nil { + return errorEnv(env.RequestID, env.SessionID, "not_found", "session not found") + } + var args mobileprotocol.SubmitArgs + _ = json.Unmarshal(cmd.Args, &args) + if args.Text == "" { + return errorEnv(env.RequestID, env.SessionID, "bad_args", "text is required") + } + + // Controller execution is wired in the next milestone. For now emit a + // sequenced notice so reconnect / dedupe paths are testable end-to-end. + slot.mu.Lock() + slot.Running = true + slot.Descriptor.Status = "running" + slot.Descriptor.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano) + slot.mu.Unlock() + + notice, _ := json.Marshal(map[string]any{ + "kind": "notice", + "level": "info", + "text": "node accepted submit (controller wiring pending)", + }) + slot.mu.Lock() + seq := slot.Ring.AppendBuild(func(seq uint64) []byte { + ev := mobileprotocol.NewEnvelope(mobileprotocol.TypeEvent) + ev.SessionID = env.SessionID + ev.Seq = seq + _ = ev.MarshalPayload(mobileprotocol.EventPayload{Event: notice}) + raw, _ := json.Marshal(ev) + return raw + }) + slot.Running = false + slot.Descriptor.Status = "idle" + slot.Descriptor.LastEventSeq = seq + slot.mu.Unlock() + + resp := mobileprotocol.NewEnvelope(mobileprotocol.TypeAck) + resp.RequestID = env.RequestID + resp.SessionID = env.SessionID + resp.Ack = seq + _ = resp.MarshalPayload(map[string]any{"accepted": true, "seq": seq}) + return resp +} + +func (h *Hub) handleCancel(env mobileprotocol.Envelope) mobileprotocol.Envelope { + slot := h.slot(env.SessionID) + if slot == nil { + return errorEnv(env.RequestID, env.SessionID, "not_found", "session not found") + } + slot.mu.Lock() + slot.Running = false + slot.Descriptor.Status = "idle" + slot.mu.Unlock() + resp := mobileprotocol.NewEnvelope(mobileprotocol.TypeAck) + resp.RequestID = env.RequestID + resp.SessionID = env.SessionID + return resp +} + +func (h *Hub) handleApprove(env mobileprotocol.Envelope, cmd mobileprotocol.CommandPayload) mobileprotocol.Envelope { + slot := h.slot(env.SessionID) + if slot == nil { + return errorEnv(env.RequestID, env.SessionID, "not_found", "session not found") + } + var args mobileprotocol.ApproveArgs + _ = json.Unmarshal(cmd.Args, &args) + if args.ID == "" { + return errorEnv(env.RequestID, env.SessionID, "bad_args", "approval id is required") + } + resp := mobileprotocol.NewEnvelope(mobileprotocol.TypeAck) + resp.RequestID = env.RequestID + resp.SessionID = env.SessionID + _ = resp.MarshalPayload(map[string]any{"id": args.ID, "allow": args.Allow}) + return resp +} + +func (h *Hub) handleListModels(env mobileprotocol.Envelope) mobileprotocol.Envelope { + resp := mobileprotocol.NewEnvelope(mobileprotocol.TypeAck) + resp.RequestID = env.RequestID + // Real model catalog comes from config/provider in a later milestone. + _ = resp.MarshalPayload(map[string]any{"models": []any{}}) + return resp +} + +// Replay returns incremental frames after lastAck, or ok=false when snapshot is required. +func (h *Hub) Replay(sessionID string, lastAck uint64) ([]EventFrame, bool) { + slot := h.slot(sessionID) + if slot == nil { + return nil, false + } + return slot.Ring.ReplaySince(lastAck) +} + +// Snapshot builds a full recovery payload for a stale cursor. +func (h *Hub) Snapshot(sessionID string) (mobileprotocol.SnapshotPayload, error) { + slot := h.slot(sessionID) + if slot == nil { + return mobileprotocol.SnapshotPayload{}, fmt.Errorf("session not found") + } + slot.mu.Lock() + defer slot.mu.Unlock() + return mobileprotocol.SnapshotPayload{ + Descriptor: slot.Descriptor, + Running: slot.Running, + LastEventSeq: slot.Ring.LastSeq(), + Revision: slot.Descriptor.Revision, + }, nil +} + +// Handler returns the HTTP mux serving WebSocket /mobile/ws and health. +func (h *Hub) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"nodeId":` + jsonString(h.nodeID) + `}`)) + }) + mux.HandleFunc("/mobile/ws", h.serveWS) + // Keep a JSON command POST for tests and non-WS clients without replacing serve. + mux.HandleFunc("/mobile/command", h.serveCommand) + return mux +} + +func (h *Hub) serveCommand(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var env mobileprotocol.Envelope + if err := json.NewDecoder(r.Body).Decode(&env); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := h.HandleCommand(env) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +func (h *Hub) serveWS(w http.ResponseWriter, r *http.Request) { + conn, err := h.upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + for { + _, data, err := conn.ReadMessage() + if err != nil { + return + } + env, err := mobileprotocol.Decode(data) + if err != nil { + resp := errorEnv("", "", "bad_envelope", err.Error()) + b, _ := json.Marshal(resp) + _ = conn.WriteMessage(websocket.TextMessage, b) + continue + } + switch env.Type { + case mobileprotocol.TypePing: + pong := mobileprotocol.NewEnvelope(mobileprotocol.TypePong) + pong.RequestID = env.RequestID + b, _ := json.Marshal(pong) + _ = conn.WriteMessage(websocket.TextMessage, b) + case mobileprotocol.TypeCommand, mobileprotocol.TypeHello: + // Replay path: hello with lastAckSeq may request snapshot. + if env.Type == mobileprotocol.TypeHello || env.Type == mobileprotocol.TypeCommand { + var hello mobileprotocol.HelloPayload + _ = env.UnmarshalPayload(&hello) + if hello.SessionID != "" && hello.LastAckSeq > 0 { + if frames, ok := h.Replay(hello.SessionID, hello.LastAckSeq); ok { + for _, f := range frames { + _ = conn.WriteMessage(websocket.TextMessage, f.Data) + } + } else if snap, err := h.Snapshot(hello.SessionID); err == nil { + out := mobileprotocol.NewEnvelope(mobileprotocol.TypeSnapshot) + out.SessionID = hello.SessionID + _ = out.MarshalPayload(snap) + b, _ := json.Marshal(out) + _ = conn.WriteMessage(websocket.TextMessage, b) + } + } + } + resp := h.HandleCommand(env) + b, _ := json.Marshal(resp) + _ = conn.WriteMessage(websocket.TextMessage, b) + default: + resp := errorEnv(env.RequestID, env.SessionID, "bad_type", "unsupported envelope type") + b, _ := json.Marshal(resp) + _ = conn.WriteMessage(websocket.TextMessage, b) + } + } +} + +// Run starts the HTTP server until ctx is done or ListenAndServe fails. +func (h *Hub) Run(addr string) error { + srv := &http.Server{ + Addr: addr, + Handler: h.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + return srv.ListenAndServe() +} + +func errorEnv(requestID, sessionID, code, msg string) mobileprotocol.Envelope { + e := mobileprotocol.NewEnvelope(mobileprotocol.TypeError) + e.RequestID = requestID + e.SessionID = sessionID + _ = e.MarshalPayload(mobileprotocol.ErrorPayload{Code: code, Message: msg}) + return e +} + +func jsonString(s string) string { + b, _ := json.Marshal(s) + return string(b) +} diff --git a/internal/node/hub_test.go b/internal/node/hub_test.go new file mode 100644 index 0000000000..7d99e1dd1a --- /dev/null +++ b/internal/node/hub_test.go @@ -0,0 +1,128 @@ +package node + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "reasonix/internal/mobileprotocol" +) + +func TestEventRingReplayAndStale(t *testing.T) { + r := NewEventRing(3) + for i := 0; i < 5; i++ { + r.Append([]byte{byte(i)}) + } + // Ring keeps last 3: seq 3,4,5. lastAck=1 is stale. + if _, ok := r.ReplaySince(1); ok { + t.Fatal("expected stale cursor") + } + frames, ok := r.ReplaySince(3) + if !ok { + t.Fatal("expected replay") + } + if len(frames) != 2 || frames[0].Seq != 4 || frames[1].Seq != 5 { + t.Fatalf("frames=%+v", frames) + } +} + +func TestRequestDedupeIdempotent(t *testing.T) { + d := NewRequestDedupe(2) + d.Remember("a", []byte("one")) + d.Remember("a", []byte("two")) + e, ok := d.Lookup("a") + if !ok || string(e.Response) != "one" { + t.Fatalf("got %+v ok=%v", e, ok) + } + d.Remember("b", []byte("b")) + d.Remember("c", []byte("c")) + if _, ok := d.Lookup("a"); ok { + t.Fatal("oldest entry should have been evicted") + } +} + +func TestHubCreateAndSubmitDedupe(t *testing.T) { + h := NewHub("test-node") + create := commandEnv("", "req-create", mobileprotocol.CmdCreateSession, mobileprotocol.CreateSessionArgs{ + Runtime: mobileprotocol.RuntimeRemote, + Title: "demo", + }) + resp1 := h.HandleCommand(create) + if resp1.Type != mobileprotocol.TypeSnapshot || resp1.SessionID == "" { + t.Fatalf("create resp=%+v", resp1) + } + // Create dedupe is session-scoped after the first response assigns a + // SessionID; submit is the critical write path for requestId retries. + sid := resp1.SessionID + + submit := commandEnv(sid, "req-submit-1", mobileprotocol.CmdSubmit, mobileprotocol.SubmitArgs{Text: "hello"}) + a := h.HandleCommand(submit) + b := h.HandleCommand(submit) + if a.Type != mobileprotocol.TypeAck || b.Type != mobileprotocol.TypeAck { + t.Fatalf("a=%+v b=%+v", a, b) + } + ab, _ := json.Marshal(a) + bb, _ := json.Marshal(b) + if !bytes.Equal(ab, bb) { + t.Fatalf("dedupe mismatch:\n%s\n%s", ab, bb) + } + if a.SessionID != sid || b.SessionID != sid { + t.Fatal("session id must not jump across retries") + } +} + +func TestHubHTTPCommand(t *testing.T) { + h := NewHub("http-node") + srv := httptest.NewServer(h.Handler()) + t.Cleanup(srv.Close) + + body, _ := json.Marshal(commandEnv("", "r1", mobileprotocol.CmdCreateSession, mobileprotocol.CreateSessionArgs{ + Title: "via-http", + })) + res, err := http.Post(srv.URL+"/mobile/command", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode != 200 { + t.Fatalf("status=%d", res.StatusCode) + } + var env mobileprotocol.Envelope + if err := json.NewDecoder(res.Body).Decode(&env); err != nil { + t.Fatal(err) + } + if env.SessionID == "" || env.Type != mobileprotocol.TypeSnapshot { + t.Fatalf("env=%+v", env) + } + + health, err := http.Get(srv.URL + "/healthz") + if err != nil { + t.Fatal(err) + } + defer health.Body.Close() + if health.StatusCode != 200 { + t.Fatalf("health=%d", health.StatusCode) + } +} + +func TestHubRejectsLocalRuntimeOnNode(t *testing.T) { + h := NewHub("n") + resp := h.HandleCommand(commandEnv("", "x", mobileprotocol.CmdCreateSession, mobileprotocol.CreateSessionArgs{ + Runtime: mobileprotocol.RuntimeLocal, + })) + if resp.Type != mobileprotocol.TypeError { + t.Fatalf("expected error, got %+v", resp) + } +} + +func commandEnv(sessionID, requestID, name string, args any) mobileprotocol.Envelope { + raw, _ := json.Marshal(args) + cmd := mobileprotocol.CommandPayload{Name: name, Args: raw} + env := mobileprotocol.NewEnvelope(mobileprotocol.TypeCommand) + env.RequestID = requestID + env.SessionID = sessionID + _ = env.MarshalPayload(cmd) + return env +} diff --git a/internal/node/ring.go b/internal/node/ring.go new file mode 100644 index 0000000000..4b9e61a21c --- /dev/null +++ b/internal/node/ring.go @@ -0,0 +1,86 @@ +package node + +import "sync" + +// EventFrame is a sequenced outbound event for a single session. +type EventFrame struct { + Seq uint64 + Data []byte // marshaled MobileEnvelope +} + +// EventRing is a bounded monotonic event buffer used for reconnect replay. +// When the ring has dropped frames older than a client's lastAckSeq, callers +// must send a full snapshot instead of incremental replay. +type EventRing struct { + mu sync.Mutex + max int + nextSeq uint64 + frames []EventFrame +} + +// NewEventRing creates a ring that retains at most max frames (min 1). +func NewEventRing(max int) *EventRing { + if max < 1 { + max = 1 + } + return &EventRing{max: max, nextSeq: 1} +} + +// Append stores data under the next sequence number and returns that seq. +func (r *EventRing) Append(data []byte) uint64 { + return r.AppendBuild(func(uint64) []byte { return data }) +} + +// AppendBuild reserves the next sequence number, lets the caller build the +// payload with that seq, then stores the result. Prefer this when the wire +// frame itself must include the sequence. +func (r *EventRing) AppendBuild(build func(seq uint64) []byte) uint64 { + r.mu.Lock() + defer r.mu.Unlock() + seq := r.nextSeq + r.nextSeq++ + data := build(seq) + cp := make([]byte, len(data)) + copy(cp, data) + r.frames = append(r.frames, EventFrame{Seq: seq, Data: cp}) + if len(r.frames) > r.max { + r.frames = r.frames[len(r.frames)-r.max:] + } + return seq +} + +// LastSeq returns the most recently assigned sequence (0 if empty). +func (r *EventRing) LastSeq() uint64 { + r.mu.Lock() + defer r.mu.Unlock() + if r.nextSeq == 1 { + return 0 + } + return r.nextSeq - 1 +} + +// ReplaySince returns frames with seq > lastAck, or ok=false when the cursor +// is stale (oldest retained frame is already past lastAck+1). +func (r *EventRing) ReplaySince(lastAck uint64) (frames []EventFrame, ok bool) { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.frames) == 0 { + if lastAck == 0 || lastAck < r.nextSeq { + return nil, true + } + return nil, false + } + oldest := r.frames[0].Seq + // Client is fully caught up past our buffer start — only if they already + // saw everything we still hold, or they are behind by at most the gap that + // starts at oldest. + if lastAck+1 < oldest { + return nil, false + } + for _, f := range r.frames { + if f.Seq > lastAck { + frames = append(frames, f) + } + } + return frames, true +} diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000000..e002d4ae0d --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.DS_Store +*.local +.capacitor/ +ios/ +android/ diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000000..8e359c0de2 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,86 @@ +# Reasonix Mobile + +React + Capacitor client for dual immutable session runtimes: + +- **local** — on-device lightweight agent via `mobilecore` (gomobile) +- **remote** — WebSocket client to `reasonix node` + +Product and protocol contracts: [`docs/MOBILE.md`](../docs/MOBILE.md). + +## Layout + +``` +mobile/ + src/ + protocol/ # MobileEnvelope + SessionDescriptor (mirrors Go) + backend/ # SessionBackend, LocalBackend, RemoteBackend + design/ # brand + platform tokens (ios|android|web) + components/ # TopBar, TabBar, lists, chat, sheet, settings + i18n/ # en / zh / zh-TW + App.tsx # four-tab shell, new-session sheet, dual runtime + capacitor.config.ts +``` + +## UI notes + +- Brand: industrial charcoal + warm copper; shared across platforms. +- Chrome adapts via `data-platform` (auto-detect, overridable in Settings). +- iOS: large titles, inset grouped lists, 44pt targets. +- Android/Web: material-height bars, full-bleed lists, FAB for new session. +- New session sheet: pick local vs node, then create (remote hits `reasonix node`). +- Motion: page enter, sheet open/close, message appear, stream pulse (respects reduced-motion). +- Node pairing: QR demo + paste `reasonix://node-pair?…` / URL; fingerprint stored locally. +- Approval bottom sheet: risk badge, command/diff, long-press allow for dangerous writes, one-tap deny. + +### Try approval demo + +In a local session send: `delete tmp/log` or `write_file example` — the approval sheet opens. + +## Develop + +```bash +cd mobile +npm install +npm run dev # http://localhost:5174 +npm test +npm run build +``` + +## CI + +GitHub Actions job `mobile` in `.github/workflows/ci.yml` runs on every PR to +`main-v2`: + +1. `gofmt` / `go vet` / `go test` for `internal/mobileprotocol`, `mobilecore`, `node` +2. `npm ci` + `typecheck` + `test` + production `build` under `mobile/` + +Local mirror: + +```bash +make mobile-ci +``` + +Not yet in CI: Capacitor native projects, `gomobile bind`, store release +(`mobile-vX.Y.Z`). + +## Node daemon (remote) + +```bash +# from repo root +go run ./cmd/reasonix node --addr 127.0.0.1:8790 +``` + +Mobile remote backend posts JSON envelopes to `POST /mobile/command` and will +use `ws://host/mobile/ws` once the Capacitor shell is packaged. + +## Native / store (later milestones) + +- `npx cap add ios` / `npx cap add android` +- gomobile bind `./internal/mobilecore` → AAR + XCFramework +- Secure storage plugin (Keychain / Keystore) +- Minimum: iOS 17, Android 10 / API 29 + +## Cache policy + +Do not reimplement provider protocols in TypeScript. Dynamic device state is +user-turn data only. Tool order freezes at local session create. diff --git a/mobile/capacitor.config.ts b/mobile/capacitor.config.ts new file mode 100644 index 0000000000..66889dfe75 --- /dev/null +++ b/mobile/capacitor.config.ts @@ -0,0 +1,29 @@ +/** + * Capacitor shell config for iOS / Android packaging. + * Native projects are generated with `npx cap add ios|android` in a later + * milestone once signing materials and mobilecore AAR/XCFramework are ready. + * + * Typed as a plain object so `@capacitor/cli` is not required until native + * scaffolding is added. + */ +const config = { + appId: "ai.reasonix.mobile", + appName: "Reasonix", + webDir: "dist", + server: { + androidScheme: "https", + }, + ios: { + // Product minimum: iOS 17 + preferredContentMode: "mobile", + }, + android: { + // Product minimum: Android 10 / API 29 — enforced in native project. + allowMixedContent: false, + }, + plugins: { + // Secure storage and mobilecore plugins land with native scaffolding. + }, +}; + +export default config; diff --git a/mobile/index.html b/mobile/index.html new file mode 100644 index 0000000000..158aa04e56 --- /dev/null +++ b/mobile/index.html @@ -0,0 +1,17 @@ + + + + + + + + Reasonix + + +
+ + + diff --git a/mobile/package-lock.json b/mobile/package-lock.json new file mode 100644 index 0000000000..2f88dbccc9 --- /dev/null +++ b/mobile/package-lock.json @@ -0,0 +1,1528 @@ +{ + "name": "reasonix-mobile", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "reasonix-mobile", + "version": "0.1.0", + "dependencies": { + "lucide-react": "^0.525.0", + "qrcode.react": "^4.2.0", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "tsx": "^4.22.4", + "typescript": "^5.8.0", + "vite": "^8.0.16" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "0.525.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz", + "integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.17", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.17.tgz", + "integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000000..844dd85648 --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,27 @@ +{ + "name": "reasonix-mobile", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "tsx src/backend/session-backend.test.ts && tsx src/protocol/protocol.test.ts && tsx src/lib/paired-nodes.test.ts && tsx src/lib/approval.test.ts" + }, + "dependencies": { + "lucide-react": "^0.525.0", + "qrcode.react": "^4.2.0", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "tsx": "^4.22.4", + "typescript": "^5.8.0", + "vite": "^8.0.16" + } +} diff --git a/mobile/src/App.tsx b/mobile/src/App.tsx new file mode 100644 index 0000000000..82bfa3d67c --- /dev/null +++ b/mobile/src/App.tsx @@ -0,0 +1,574 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + MessageSquare, + Plus, + Server, + Sparkles, + Trash2, +} from "lucide-react"; +import { + LocalBackend, + RemoteBackend, + type SessionBackend, +} from "./backend/session-backend"; +import type { SessionDescriptor, SessionRuntime } from "./protocol/types"; +import { resolveLocale, t, type Locale } from "./i18n/messages"; +import { applyPlatform, detectPlatform, type Platform } from "./lib/platform"; +import { + loadPairedNodes, + savePairedNodes, + type PairedNode, +} from "./lib/paired-nodes"; +import { + isDangerousWrite, + riskFromTool, + type ApprovalRequest, + type ApprovalRisk, +} from "./lib/approval"; +import { ApprovalSheet } from "./components/ApprovalSheet"; +import { ChatView, type ChatLine } from "./components/ChatView"; +import { EmptyState } from "./components/EmptyState"; +import { IconButton } from "./components/IconButton"; +import { NewSessionSheet } from "./components/NewSessionSheet"; +import { PairNodeSheet } from "./components/PairNodeSheet"; +import { SessionList } from "./components/SessionList"; +import { SettingsPage, type ThemePref } from "./components/SettingsPage"; +import { TabBar, type Tab } from "./components/TabBar"; +import { TopBar } from "./components/TopBar"; + +function applyTheme(pref: ThemePref) { + const root = document.documentElement; + if (pref === "system") { + const light = window.matchMedia("(prefers-color-scheme: light)").matches; + root.setAttribute("data-theme", light ? "light" : "dark"); + } else { + root.setAttribute("data-theme", pref); + } +} + +function useWide(): boolean { + const [wide, setWide] = useState( + () => typeof window !== "undefined" && window.matchMedia("(min-width: 900px)").matches, + ); + useEffect(() => { + const mq = window.matchMedia("(min-width: 900px)"); + const on = () => setWide(mq.matches); + on(); + mq.addEventListener("change", on); + return () => mq.removeEventListener("change", on); + }, []); + return wide; +} + +function parseApprovalEvent( + sessionId: string, + event: unknown, +): ApprovalRequest | null { + const e = event as { + kind?: string; + approval?: { + id?: string; + tool?: string; + subject?: string; + reason?: string; + risk?: ApprovalRisk; + command?: string; + diff?: string; + dangerousWrite?: boolean; + }; + }; + if (e.kind !== "approval_request" || !e.approval?.id) return null; + const tool = e.approval.tool || "tool"; + const subject = e.approval.subject || ""; + const risk = e.approval.risk || riskFromTool(tool, subject); + return { + id: e.approval.id, + sessionId, + tool, + subject, + reason: e.approval.reason, + risk, + command: e.approval.command, + diff: e.approval.diff, + dangerousWrite: + e.approval.dangerousWrite ?? isDangerousWrite(risk, tool), + }; +} + +export function App() { + const [locale, setLocale] = useState(() => resolveLocale(navigator.language)); + const [theme, setTheme] = useState("system"); + const [platform, setPlatform] = useState(() => detectPlatform()); + const [tab, setTab] = useState("sessions"); + const [sessions, setSessions] = useState([]); + const [backends, setBackends] = useState>({}); + const [activeId, setActiveId] = useState(null); + const [linesById, setLinesById] = useState>({}); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const [sheetOpen, setSheetOpen] = useState(false); + const [pairOpen, setPairOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + const [pairedNodes, setPairedNodes] = useState(() => loadPairedNodes()); + const [approval, setApproval] = useState(null); + const [approvalOpen, setApprovalOpen] = useState(false); + const [approvalBusy, setApprovalBusy] = useState(false); + + const localBackend = useMemo(() => new LocalBackend(), []); + const wide = useWide(); + const active = sessions.find((s) => s.id === activeId) ?? null; + const activeBackend = activeId ? backends[activeId] : undefined; + const lines = activeId ? linesById[activeId] ?? [] : []; + const chatOpen = Boolean(active) && (wide || tab === "sessions"); + const pendingApproval = + Boolean(approval && activeId && approval.sessionId === activeId) || + active?.status === "pending_approval"; + + useEffect(() => { + applyPlatform(platform); + }, [platform]); + + useEffect(() => { + applyTheme(theme); + if (theme !== "system") return; + const mq = window.matchMedia("(prefers-color-scheme: light)"); + const onChange = () => applyTheme("system"); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, [theme]); + + useEffect(() => { + savePairedNodes(pairedNodes); + }, [pairedNodes]); + + const openNewSheet = useCallback(() => { + setCreateError(null); + setSheetOpen(true); + }, []); + + const createSession = useCallback( + async (input: { runtime: SessionRuntime; nodeUrl?: string }) => { + setCreating(true); + setCreateError(null); + try { + let backend: SessionBackend; + let d: SessionDescriptor; + if (input.runtime === "local") { + backend = localBackend; + d = await backend.createSession({ + runtime: "local", + title: t(locale, "sessions.runtimeLocal"), + }); + } else { + const url = (input.nodeUrl || "http://127.0.0.1:8790").replace(/\/$/, ""); + backend = new RemoteBackend(url); + d = await backend.createSession({ + runtime: "remote", + title: t(locale, "sessions.runtimeRemote"), + }); + } + setBackends((prev) => ({ ...prev, [d.id]: backend })); + setSessions((prev) => [d, ...prev]); + setLinesById((prev) => ({ ...prev, [d.id]: [] })); + setActiveId(d.id); + setDraft(""); + setTab("sessions"); + setSheetOpen(false); + } catch (err) { + const msg = err instanceof Error ? err.message : t(locale, "sessions.createError"); + setCreateError(msg); + } finally { + setCreating(false); + } + }, + [localBackend, locale], + ); + + const send = useCallback(async () => { + if (!active || !activeBackend || !draft.trim() || sending) return; + const text = draft.trim(); + const sessionId = active.id; + setDraft(""); + setSending(true); + setSessions((prev) => + prev.map((s) => + s.id === sessionId ? { ...s, status: "running", updatedAt: new Date().toISOString() } : s, + ), + ); + setLinesById((prev) => ({ + ...prev, + [sessionId]: [ + ...(prev[sessionId] ?? []), + { id: `u-${Date.now()}`, kind: "user", text, role: "user" }, + ], + })); + const unsub = activeBackend.subscribe(sessionId, (event, seq) => { + const e = event as { kind?: string; text?: string }; + const kind = e.kind || "event"; + const appr = parseApprovalEvent(sessionId, event); + if (appr) { + setApproval(appr); + setApprovalOpen(true); + setSessions((prev) => + prev.map((s) => + s.id === sessionId ? { ...s, status: "pending_approval" } : s, + ), + ); + } + let role: ChatLine["role"] = "assistant"; + if (kind === "notice" || kind.startsWith("tool_") || kind === "approval_request") { + role = "tool"; + } + if (kind === "turn_started" || kind === "turn_done") role = "system"; + let textOut = e.text; + if (!textOut) { + if (kind === "turn_started") textOut = "…"; + else if (kind === "turn_done") textOut = "✓"; + else if (kind === "approval_request") textOut = t(locale, "approval.banner"); + else textOut = JSON.stringify(event); + } + setLinesById((prev) => ({ + ...prev, + [sessionId]: [ + ...(prev[sessionId] ?? []), + { id: `e-${seq}-${kind}`, kind, text: textOut!, role }, + ], + })); + }); + try { + await activeBackend.submit(sessionId, { text }, `req_${Date.now()}`); + const snap = await activeBackend.snapshot(sessionId); + setSessions((prev) => + prev.map((s) => (s.id === sessionId ? snap.descriptor : s)), + ); + if (snap.descriptor.status !== "pending_approval") { + setApproval((a) => (a?.sessionId === sessionId ? null : a)); + setApprovalOpen(false); + } + } catch (err) { + const msg = err instanceof Error ? err.message : "send failed"; + setLinesById((prev) => ({ + ...prev, + [sessionId]: [ + ...(prev[sessionId] ?? []), + { id: `err-${Date.now()}`, kind: "notice", text: msg, role: "tool" }, + ], + })); + } finally { + unsub(); + setSending(false); + } + }, [active, activeBackend, draft, sending, locale]); + + const resolveApproval = useCallback( + async (allow: boolean) => { + if (!approval || !activeBackend) return; + setApprovalBusy(true); + try { + await activeBackend.approve( + approval.sessionId, + { id: approval.id, allow }, + `req_appr_${Date.now()}`, + ); + setApprovalOpen(false); + if (!allow) setApproval(null); + } finally { + setApprovalBusy(false); + } + }, + [approval, activeBackend], + ); + + const selectSession = (id: string) => { + setActiveId(id); + setDraft(""); + setTab("sessions"); + }; + + const iosChrome = platform === "ios"; + + const listPane = ( +
+ + + + } + /> +
+ {iosChrome ?

{t(locale, "sessions.title")}

: null} + {sessions.length === 0 ? ( + } + title={t(locale, "sessions.emptyTitle")} + description={t(locale, "sessions.empty")} + actions={ + + } + /> + ) : ( + + )} +
+ +
+ ); + + const detailPane = active ? ( +
+ setActiveId(null)} + onDraftChange={setDraft} + onSend={() => void send()} + onOpenApproval={() => setApprovalOpen(true)} + /> +
+ ) : wide ? ( +
+ {t(locale, "sessions.selectHint")} +
+ ) : null; + + return ( +
+
+ {tab === "sessions" && ( +
+ {listPane} + {detailPane} +
+ )} + + {tab === "nodes" && ( +
+ setPairOpen(true)}> + + + } + /> +
+ {iosChrome ?

{t(locale, "nodes.title")}

: null} + {pairedNodes.length === 0 ? ( + } + title={t(locale, "nodes.emptyTitle")} + description={t(locale, "nodes.empty")} + actions={ + <> + +

+ {t(locale, "nodes.pairHint")} +

+ + } + /> + ) : ( +
+
+ {pairedNodes.map((n) => ( +
+ + + + +
{n.name}
+
+ {n.baseUrl} +
+
+ + + {n.online + ? t(locale, "nodes.online") + : t(locale, "nodes.offline")} + + {n.fingerprint ? ( + <> + · + + {t(locale, "nodes.fingerprint")}: {n.fingerprint.slice(0, 12)} + + + ) : null} +
+
+ + +
+
+
+ ))} +
+

{t(locale, "nodes.pairHint")}

+
+ )} +
+ +
+ )} + + {tab === "providers" && ( +
+ +
+ {iosChrome ? ( +

{t(locale, "providers.title")}

+ ) : null} + } + title={t(locale, "providers.emptyTitle")} + description={t(locale, "providers.empty")} + actions={ + + } + /> +
+
+ )} + + {tab === "settings" && ( +
+ + +
+ )} +
+ + { + setTab(next); + if (next !== "sessions" && !wide) setActiveId(null); + }} + /> + + !creating && setSheetOpen(false)} + onCreate={(input) => void createSession(input)} + /> + + setPairOpen(false)} + onPaired={(p) => { + const node: PairedNode = { + id: p.id, + name: p.name, + baseUrl: p.baseUrl, + fingerprint: p.fingerprint, + online: p.online, + pairedAt: new Date().toISOString(), + }; + setPairedNodes((prev) => { + const rest = prev.filter((x) => x.id !== node.id && x.baseUrl !== node.baseUrl); + return [node, ...rest]; + }); + }} + /> + + setApprovalOpen(false)} + onAllow={() => void resolveApproval(true)} + onDeny={() => void resolveApproval(false)} + /> +
+ ); +} diff --git a/mobile/src/backend/session-backend.test.ts b/mobile/src/backend/session-backend.test.ts new file mode 100644 index 0000000000..481a8ada59 --- /dev/null +++ b/mobile/src/backend/session-backend.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { LocalBackend } from "./session-backend.ts"; +import { LOCAL_CAPABILITIES } from "../protocol/types.ts"; + +async function main() { + const backend = new LocalBackend(); + assert.equal(backend.runtime, "local"); + + await assert.rejects( + () => backend.createSession({ runtime: "remote" }), + /only creates local/, + ); + + const d = await backend.createSession({ runtime: "local", title: "t1" }); + assert.equal(d.runtime, "local"); + assert.deepEqual(d.capabilities, [...LOCAL_CAPABILITIES]); + + const events: unknown[] = []; + const unsub = backend.subscribe(d.id, (e, seq) => { + events.push({ e, seq }); + }); + await backend.submit(d.id, { text: "hello" }, "req-1"); + assert.ok(events.length >= 2); + assert.equal((events[0] as { seq: number }).seq, 1); + unsub(); + + const snap = await backend.snapshot(d.id); + assert.equal(snap.descriptor.id, d.id); + assert.ok((snap.lastEventSeq ?? 0) >= 1); + + // Approval path: dangerous text pauses until approve/deny. + const d2 = await backend.createSession({ runtime: "local", title: "t2" }); + let approvalId = ""; + const unsub2 = backend.subscribe(d2.id, (e) => { + const ev = e as { kind?: string; approval?: { id?: string } }; + if (ev.kind === "approval_request" && ev.approval?.id) { + approvalId = ev.approval.id; + void backend.approve(d2.id, { id: approvalId, allow: true }, "req-appr"); + } + }); + await backend.submit(d2.id, { text: "delete tmp/scratch.log" }, "req-2"); + unsub2(); + assert.ok(approvalId, "expected approval_request"); + const snap2 = await backend.snapshot(d2.id); + assert.equal(snap2.descriptor.status, "idle"); + + console.log("session-backend.test.ts: ok"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/mobile/src/backend/session-backend.ts b/mobile/src/backend/session-backend.ts new file mode 100644 index 0000000000..34f7b22e23 --- /dev/null +++ b/mobile/src/backend/session-backend.ts @@ -0,0 +1,394 @@ +/** + * SessionBackend is the only runtime surface the mobile UI depends on. + * LocalBackend talks to mobilecore; RemoteBackend talks to reasonix node. + * Runtime is immutable after create — migration always creates a new session. + */ + +import { + LOCAL_CAPABILITIES, + MOBILE_PROTOCOL_VERSION, + REMOTE_CAPABILITIES, + type CreateSessionArgs, + type MobileEnvelope, + type SessionDescriptor, + type SessionRuntime, + type SnapshotPayload, + type SubmitArgs, + newEnvelope, +} from "../protocol/types"; +import { + isDangerousWrite, + riskFromTool, + type ApprovalRequest, +} from "../lib/approval"; + +export type SessionEventHandler = (event: unknown, seq: number) => void; + +export interface SessionBackend { + readonly runtime: SessionRuntime; + createSession(args: CreateSessionArgs): Promise; + restoreSession(id: string): Promise; + submit(sessionId: string, args: SubmitArgs, requestId: string): Promise; + cancel(sessionId: string, requestId: string): Promise; + approve( + sessionId: string, + args: { id: string; allow: boolean; session?: boolean; persist?: boolean }, + requestId: string, + ): Promise; + snapshot(sessionId: string): Promise; + listModels(): Promise<{ id: string; label?: string }[]>; + subscribe(sessionId: string, handler: SessionEventHandler): () => void; +} + +function newId(prefix: string): string { + return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +function needsDemoApproval(text: string): ApprovalRequest | null { + const lower = text.toLowerCase(); + // Explicit demo trigger or tool-like dangerous intent. + const forced = + lower.includes("approve:") || + lower.includes("/approve") || + /\b(rm\s|delete\s|sudo\s|write_file|shell\s)/i.test(text) || + text.includes("删除") || + text.includes("刪除"); + if (!forced) return null; + + let tool = "shell"; + let subject = text.slice(0, 120); + let command: string | undefined = text; + let diff: string | undefined; + if (/\bwrite_file\b|write\s+/i.test(text) || text.includes("写入")) { + tool = "write_file"; + subject = "src/example.ts"; + command = undefined; + diff = [ + "--- a/src/example.ts", + "+++ b/src/example.ts", + "@@ -1,3 +1,4 @@", + " export function main() {", + "- return 0", + "+ console.log('updated')", + "+ return 1", + " }", + ].join("\n"); + } else if (/\bdelete\b|rm\s|删除|刪除/i.test(text)) { + tool = "delete_file"; + subject = "tmp/scratch.log"; + command = `rm -f ${subject}`; + } + + const risk = riskFromTool(tool, subject); + return { + id: newId("appr"), + sessionId: "", + tool, + subject, + reason: "Local demo: tool requires confirmation before side effects.", + risk, + command, + diff, + dangerousWrite: isDangerousWrite(risk, tool), + }; +} + +/** + * In-memory LocalBackend used until the Capacitor mobilecore plugin is wired. + * Shape matches the Go mobilecore JSON API so the bridge swap is mechanical. + */ +export class LocalBackend implements SessionBackend { + readonly runtime: SessionRuntime = "local"; + private sessions = new Map(); + private handlers = new Map>(); + private seq = new Map(); + private pending = new Map< + string, + { approvalId: string; resolve: (allow: boolean) => void } + >(); + + async createSession(args: CreateSessionArgs): Promise { + if (args.runtime !== "local") { + throw new Error("LocalBackend only creates local sessions"); + } + const d: SessionDescriptor = { + id: newId("local"), + runtime: "local", + providerRef: args.providerRef, + capabilities: [...LOCAL_CAPABILITIES], + revision: 1, + lastEventSeq: 0, + title: args.title || "Local session", + status: "idle", + updatedAt: new Date().toISOString(), + }; + this.sessions.set(d.id, d); + this.seq.set(d.id, 0); + return d; + } + + async restoreSession(id: string): Promise { + return this.snapshot(id); + } + + async submit(sessionId: string, args: SubmitArgs, requestId: string): Promise { + const d = this.require(sessionId); + if (!args.text.trim()) throw new Error("text is required"); + d.status = "running"; + d.updatedAt = new Date().toISOString(); + this.emit(sessionId, { kind: "turn_started", requestId }); + + const approval = needsDemoApproval(args.text); + if (approval) { + approval.sessionId = sessionId; + d.status = "pending_approval"; + // Register the waiter before emit so sync subscribers can resolve immediately. + const allowed = await new Promise((resolve) => { + this.pending.set(sessionId, { approvalId: approval.id, resolve }); + this.emit(sessionId, { + kind: "approval_request", + approval: { + id: approval.id, + tool: approval.tool, + subject: approval.subject, + reason: approval.reason, + risk: approval.risk, + command: approval.command, + diff: approval.diff, + dangerousWrite: approval.dangerousWrite, + }, + }); + }); + if (!allowed) { + this.emit(sessionId, { + kind: "notice", + level: "warn", + text: "approval denied — turn cancelled", + }); + this.emit(sessionId, { kind: "turn_done", requestId, err: "denied" }); + d.status = "idle"; + d.lastEventSeq = this.seq.get(sessionId) ?? 0; + d.updatedAt = new Date().toISOString(); + return; + } + this.emit(sessionId, { + kind: "tool_dispatch", + text: `${approval.tool} ${approval.subject}`, + }); + this.emit(sessionId, { + kind: "tool_result", + text: `${approval.tool} completed`, + }); + } else { + this.emit(sessionId, { + kind: "notice", + level: "info", + text: "local backend accepted message (mobilecore stream pending)", + }); + } + + this.emit(sessionId, { kind: "turn_done", requestId }); + d.status = "idle"; + d.lastEventSeq = this.seq.get(sessionId) ?? 0; + d.updatedAt = new Date().toISOString(); + } + + async cancel(sessionId: string, requestId: string): Promise { + const d = this.require(sessionId); + const p = this.pending.get(sessionId); + if (p) { + this.pending.delete(sessionId); + p.resolve(false); + } + d.status = "idle"; + this.emit(sessionId, { kind: "notice", text: "cancel", requestId }); + } + + async approve( + sessionId: string, + args: { id: string; allow: boolean }, + requestId: string, + ): Promise { + this.require(sessionId); + const p = this.pending.get(sessionId); + if (p && p.approvalId === args.id) { + this.pending.delete(sessionId); + p.resolve(args.allow); + } + this.emit(sessionId, { + kind: "notice", + text: `approval ${args.id} allow=${args.allow}`, + requestId, + }); + } + + async snapshot(sessionId: string): Promise { + const d = this.require(sessionId); + return { + descriptor: { ...d }, + lastEventSeq: d.lastEventSeq, + revision: d.revision, + running: d.status === "running" || d.status === "pending_approval", + }; + } + + async listModels(): Promise<{ id: string; label?: string }[]> { + return []; + } + + subscribe(sessionId: string, handler: SessionEventHandler): () => void { + let set = this.handlers.get(sessionId); + if (!set) { + set = new Set(); + this.handlers.set(sessionId, set); + } + set.add(handler); + return () => set!.delete(handler); + } + + private require(id: string): SessionDescriptor { + const d = this.sessions.get(id); + if (!d) throw new Error("session not found"); + return d; + } + + private emit(sessionId: string, event: unknown): void { + const next = (this.seq.get(sessionId) ?? 0) + 1; + this.seq.set(sessionId, next); + const d = this.sessions.get(sessionId); + if (d) d.lastEventSeq = next; + for (const h of this.handlers.get(sessionId) ?? []) { + h(event, next); + } + } +} + +/** + * RemoteBackend connects to reasonix node over the mobile WebSocket protocol. + * Direct LAN/Tailscale and official relay share this same application protocol. + */ +export class RemoteBackend implements SessionBackend { + readonly runtime: SessionRuntime = "remote"; + private baseUrl: string; + private handlers = new Map>(); + private lastAck = new Map(); + + constructor(baseUrl: string) { + this.baseUrl = baseUrl.replace(/\/$/, ""); + } + + get nodeBaseUrl(): string { + return this.baseUrl; + } + + async createSession(args: CreateSessionArgs): Promise { + if (args.runtime !== "remote") { + throw new Error("RemoteBackend only creates remote sessions"); + } + const env = await this.postCommand("create_session", args, { + requestId: newId("req"), + }); + if (env.type === "error") { + throw new Error(errorMessage(env)); + } + const snap = env.payload as SnapshotPayload | undefined; + if (!snap?.descriptor) { + throw new Error("missing snapshot descriptor"); + } + return { + ...snap.descriptor, + capabilities: snap.descriptor.capabilities?.length + ? snap.descriptor.capabilities + : [...REMOTE_CAPABILITIES], + }; + } + + async restoreSession(id: string): Promise { + return this.snapshot(id); + } + + async submit(sessionId: string, args: SubmitArgs, requestId: string): Promise { + const env = await this.postCommand("submit", args, { sessionId, requestId }); + if (env.type === "error") throw new Error(errorMessage(env)); + if (typeof env.ack === "number") this.lastAck.set(sessionId, env.ack); + } + + async cancel(sessionId: string, requestId: string): Promise { + const env = await this.postCommand("cancel", {}, { sessionId, requestId }); + if (env.type === "error") throw new Error(errorMessage(env)); + } + + async approve( + sessionId: string, + args: { id: string; allow: boolean; session?: boolean; persist?: boolean }, + requestId: string, + ): Promise { + const env = await this.postCommand("approve", args, { sessionId, requestId }); + if (env.type === "error") throw new Error(errorMessage(env)); + } + + async snapshot(sessionId: string): Promise { + const env = await this.postCommand("snapshot", {}, { sessionId, requestId: newId("req") }); + if (env.type === "error") throw new Error(errorMessage(env)); + return env.payload as SnapshotPayload; + } + + async listModels(): Promise<{ id: string; label?: string }[]> { + const env = await this.postCommand("list_models", {}, { requestId: newId("req") }); + if (env.type === "error") throw new Error(errorMessage(env)); + const payload = env.payload as { models?: { id: string; label?: string }[] }; + return payload?.models ?? []; + } + + subscribe(sessionId: string, handler: SessionEventHandler): () => void { + let set = this.handlers.get(sessionId); + if (!set) { + set = new Set(); + this.handlers.set(sessionId, set); + } + set.add(handler); + return () => set!.delete(handler); + } + + private async postCommand( + name: string, + args: unknown, + meta: { sessionId?: string; requestId?: string }, + ): Promise { + const envelope = newEnvelope("command", { + requestId: meta.requestId, + sessionId: meta.sessionId, + payload: { name, args }, + }); + const res = await fetch(`${this.baseUrl}/mobile/command`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(envelope), + }); + if (!res.ok) { + throw new Error(`node HTTP ${res.status}`); + } + const body = (await res.json()) as MobileEnvelope; + if (body.version !== MOBILE_PROTOCOL_VERSION && body.version > MOBILE_PROTOCOL_VERSION) { + throw new Error(`unsupported protocol version ${body.version}`); + } + return body; + } +} + +function errorMessage(env: MobileEnvelope): string { + const p = env.payload as { message?: string; code?: string } | undefined; + return p?.message || p?.code || "remote error"; +} + +/** Factory used by UI when creating a session after runtime selection. */ +export function backendForRuntime( + runtime: SessionRuntime, + remoteBaseUrl?: string, +): SessionBackend { + if (runtime === "local") return new LocalBackend(); + if (!remoteBaseUrl) { + throw new Error("remote runtime requires node base URL"); + } + return new RemoteBackend(remoteBaseUrl); +} diff --git a/mobile/src/components/ApprovalSheet.tsx b/mobile/src/components/ApprovalSheet.tsx new file mode 100644 index 0000000000..86c54dcb39 --- /dev/null +++ b/mobile/src/components/ApprovalSheet.tsx @@ -0,0 +1,193 @@ +import { AlertTriangle, Check, ShieldAlert, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ApprovalRequest } from "../lib/approval"; +import { t, type Locale } from "../i18n/messages"; +import { BottomSheet } from "./BottomSheet"; + +const HOLD_MS = 900; + +export function ApprovalSheet({ + open, + locale, + request, + busy, + onClose, + onAllow, + onDeny, +}: { + open: boolean; + locale: Locale; + request: ApprovalRequest | null; + busy?: boolean; + onClose: () => void; + onAllow: () => void; + onDeny: () => void; +}) { + const [holdProgress, setHoldProgress] = useState(0); + const holdTimer = useRef(null); + const raf = useRef(null); + const startRef = useRef(0); + + const clearHold = useCallback(() => { + if (holdTimer.current) window.clearTimeout(holdTimer.current); + if (raf.current) cancelAnimationFrame(raf.current); + holdTimer.current = null; + raf.current = null; + setHoldProgress(0); + }, []); + + useEffect(() => () => clearHold(), [clearHold]); + useEffect(() => { + if (!open) clearHold(); + }, [open, clearHold]); + + if (!request) { + return ( + + {null} + + ); + } + + const needsHold = request.dangerousWrite; + const riskLabel = + request.risk === "high" + ? t(locale, "approval.riskHigh") + : request.risk === "medium" + ? t(locale, "approval.riskMedium") + : t(locale, "approval.riskLow"); + + const startHold = () => { + if (!needsHold || busy) return; + startRef.current = performance.now(); + const tick = (now: number) => { + const p = Math.min(1, (now - startRef.current) / HOLD_MS); + setHoldProgress(p); + if (p < 1) { + raf.current = requestAnimationFrame(tick); + } + }; + raf.current = requestAnimationFrame(tick); + holdTimer.current = window.setTimeout(() => { + clearHold(); + onAllow(); + }, HOLD_MS); + }; + + const endHold = () => { + if (holdProgress < 1) clearHold(); + }; + + return ( + +
+
+ + {riskLabel} + {request.dangerousWrite ? ( + {t(locale, "approval.dangerous")} + ) : null} +
+ +
+
+ {t(locale, "approval.tool")} + {request.tool} +
+
+ {t(locale, "approval.target")} + {request.subject} +
+ {request.reason ? ( +
+ {t(locale, "approval.reason")} + {request.reason} +
+ ) : null} +
+ + {request.command ? ( +
+
{t(locale, "approval.command")}
+
{request.command}
+
+ ) : null} + + {request.diff ? ( +
+
{t(locale, "approval.diff")}
+
{request.diff}
+
+ ) : null} + + {needsHold ? ( +

+ + {t(locale, "approval.holdHint")} +

+ ) : null} + +
+ {needsHold ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/mobile/src/components/BottomSheet.tsx b/mobile/src/components/BottomSheet.tsx new file mode 100644 index 0000000000..6868e33327 --- /dev/null +++ b/mobile/src/components/BottomSheet.tsx @@ -0,0 +1,103 @@ +import { X } from "lucide-react"; +import { + useEffect, + useId, + useState, + type ReactNode, +} from "react"; + +/** + * Animated bottom sheet with enter/exit (120/180/260ms tokens). + * Keeps children mounted until exit animation completes. + */ +export function BottomSheet({ + open, + title, + description, + localeCloseLabel, + onClose, + children, + wide = false, +}: { + open: boolean; + title: string; + description?: string; + localeCloseLabel: string; + onClose: () => void; + children: ReactNode; + /** Taller panel for approval content */ + wide?: boolean; +}) { + const titleId = useId(); + const [render, setRender] = useState(open); + const [phase, setPhase] = useState<"in" | "out">("in"); + + useEffect(() => { + if (open) { + setRender(true); + setPhase("in"); + return; + } + if (!render) return; + setPhase("out"); + const t = window.setTimeout(() => setRender(false), 260); + return () => window.clearTimeout(t); + }, [open, render]); + + useEffect(() => { + if (!render) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [render, onClose]); + + useEffect(() => { + if (!render) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [render]); + + if (!render) return null; + + return ( +
+ +
+ {children} + + + ); +} diff --git a/mobile/src/components/ChatView.tsx b/mobile/src/components/ChatView.tsx new file mode 100644 index 0000000000..c46bc6368f --- /dev/null +++ b/mobile/src/components/ChatView.tsx @@ -0,0 +1,183 @@ +import { ArrowLeft, ArrowUp, ShieldAlert } from "lucide-react"; +import { useEffect, useRef } from "react"; +import type { SessionDescriptor } from "../protocol/types"; +import { t, type Locale } from "../i18n/messages"; +import { IconButton } from "./IconButton"; +import { TopBar } from "./TopBar"; +import { useKeyboardInset } from "../lib/useKeyboardInset"; + +export interface ChatLine { + id: string; + kind: string; + text: string; + role?: "user" | "assistant" | "system" | "tool"; +} + +function lineRole(line: ChatLine): ChatLine["role"] { + if (line.role) return line.role; + if (line.kind === "user") return "user"; + if ( + line.kind === "tool_dispatch" || + line.kind === "tool_result" || + line.kind === "tool_progress" || + line.kind === "notice" || + line.kind === "approval_request" + ) { + return "tool"; + } + if (line.kind === "turn_started" || line.kind === "turn_done") return "system"; + return "assistant"; +} + +export function ChatView({ + session, + lines, + draft, + sending, + locale, + showBack, + pendingApproval, + onBack, + onDraftChange, + onSend, + onOpenApproval, +}: { + session: SessionDescriptor; + lines: ChatLine[]; + draft: string; + sending: boolean; + locale: Locale; + showBack: boolean; + pendingApproval: boolean; + onBack: () => void; + onDraftChange: (v: string) => void; + onSend: () => void; + onOpenApproval?: () => void; +}) { + const streamRef = useRef(null); + const keyboardInset = useKeyboardInset(); + const subtitle = + session.runtime === "local" + ? t(locale, "sessions.runtimeLocal") + : t(locale, "sessions.runtimeRemote"); + + useEffect(() => { + const el = streamRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [lines, keyboardInset, pendingApproval]); + + return ( +
+ + + + ) : ( + + ) + } + trailing={ + sending ? ( + + ) : ( + + ) + } + /> + + {pendingApproval ? ( + + ) : null} + +
+ {lines.length === 0 ? ( +
+

{t(locale, "sessions.chatEmpty")}

+

{t(locale, "sessions.chatEmptyHint")}

+
+ ) : ( + lines.map((line, i) => { + const role = lineRole(line); + const delay = Math.min(i, 8) * 20; + if (role === "tool") { + return ( +
+ {line.text} +
+ ); + } + return ( +
+ {line.text} +
+ ); + }) + )} + {sending && !pendingApproval ? ( +
+ + + +
+ ) : null} +
+
+
+