@infomity/remogent 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,201 +1,225 @@
1
- # Remogent
2
-
3
- <p align="center">
4
- <strong>Zero-Recurring-Cost Android Remote AI Agent & Terminal Client</strong><br>
5
- <em>Drive Gemini CLI, Claude Code CLI and Codex CLI directly from your phone over secure, pinned-TLS transport.</em>
6
- </p>
7
-
8
- <p align="center">
9
- <img src="docs/screenshots/01-new-chat.png" alt="Remogent new chat screen with agent, folder and permission-mode chips" width="190">
10
- <img src="docs/screenshots/02-sessions.png" alt="Remogent drawer listing chat sessions across Claude Code, Gemini CLI and Codex CLI" width="190">
11
- <img src="docs/screenshots/03-chat.png" alt="Remogent chat showing a Bash tool card and Claude Code's reply" width="190">
12
- <img src="docs/screenshots/04-approval.png" alt="Remogent tool approval sheet with diff preview and Deny, Allow once and For session buttons" width="190">
13
- </p>
14
-
15
- ---
16
-
17
- ## Overview
18
-
19
- **Remogent** enables software engineers to monitor, prompt, and approve actions from **Gemini CLI**, **Claude Code CLI** and **Codex CLI** from their Android phone without paying monthly cloud relay fees.
20
-
21
- Designed following the **Infomity Studio** ([infomity.github.io](https://infomity.github.io/)) aesthetic, Remogent features Obsidian Luxury Dark & Clean Studio Light palettes, subtle engineering grid backgrounds, frosted glass surfaces, and custom status capsules with pulsing live indicators.
22
-
23
- ### Core Pillars
24
- - **$0.00 Infrastructure Cost**: Direct peer-to-peer transport over local Wi-Fi, Tailscale, WireGuard, or Cloudflare tunnels. No intermediary cloud servers, no subscriptions.
25
- - **Three AI Engines**: First-class support for **Gemini CLI** (stdio JSON-RPC 2.0 ACP protocol), **Claude Code CLI** (bidirectional stream-json protocol) and **Codex CLI** (`codex app-server` JSON-RPC).
26
- - **Interactive Tool Approvals**: Destructive operations (`Bash`, file edits, network calls) trigger an audible/haptic approval bottom sheet on your phone with granular control: `Deny`, `Allow Once`, or `Allow for Session`.
27
- - **Biometric Guard**: Gate tool authorizations and sensitive executions behind on-device biometric fingerprint / face authentication.
28
- - **Hardware-Backed Keystore Storage**: Host pairing tokens and TLS credentials encrypted on-device via Android Jetpack Security (`MasterKey` + `EncryptedSharedPreferences`).
29
- - **Cryptographic Pinned TLS**: Every connection verifies the host's 64-character SHA-256 certificate fingerprint, eliminating MITM risks without requiring public CA certificates.
30
- - **Gap-Free Session Resync**: Transcripts use strict gap-free sequence ordering (`seq`). Disconnecting and reconnecting immediately replays unread deltas and pending permissions.
31
-
32
- ---
33
-
34
- ## Architecture
35
-
36
- <p align="center">
37
- <img src="docs/architecture.png" alt="Remogent architecture: Android app with pinned-TLS WebSocket client connected directly to the PC host companion, which drives Gemini CLI, Claude Code CLI and a PTY shell" width="100%">
38
- </p>
39
-
40
- <sub>Diagram source: [`docs/architecture.mmd`](docs/architecture.mmd) (Mermaid) &middot; vector version: [`docs/architecture.svg`](docs/architecture.svg) &middot; re-render with `docs\render_architecture.cmd`.</sub>
41
-
42
- ---
43
-
44
- ## Setup & Operation
45
-
46
- ### 1. Host Companion Setup (PC)
47
-
48
- **Prerequisites:** Node.js >= 20.x, and at least one of `gemini`, `claude` or `codex` installed (found automatically, no PATH editing needed).
49
-
50
- ```bash
51
- # Clone the repository
52
- git clone https://github.com/infomity/Remogent.git
53
- cd Remogent/companion
54
-
55
- # One command: install dependencies, build, check the machine (doctor), then start the host
56
- # now AND at every sign-in and show the pairing QR
57
- npm run setup
58
- ```
59
-
60
- `doctor` finds the Claude Code, Gemini and Codex CLIs by itself (PATH, then the native installer, npm -g, Volta, pnpm, nvm, Homebrew, scoop), so you never edit PATH, and tells you exactly what to install if something is missing. Run it any time with `npm run doctor`. `install` (the last step of `setup`) registers an autostart service that runs as your user (it needs your `claude` / `gemini` / `codex` logins), starts at sign-in and restarts if it crashes: Task Scheduler on Windows, launchd on macOS, `systemd --user` on Linux. After pairing, the host is simply there; no terminal to keep open. Related commands:
61
-
62
- ```bash
63
- node dist/index.js pair # show the pairing QR again (new phone, or you closed the setup window)
64
- node dist/index.js status # autostart state + whether the host answers (log: ~/.remogent/daemon.log)
65
- node dist/index.js uninstall # stop and remove the autostart registration
66
- node dist/index.js start --port 9876 --show-url # optional: run in the foreground instead (Ctrl+C stops it)
67
- ```
68
-
69
- `npm run pair` is the same as `node dist/index.js pair`. It prints the QR, waits for the phone to connect, then closes itself (`--no-wait` keeps it open, `--timeout MIN` changes the 5-minute limit, `--show-url` also prints the `remogent://` link, `--host IP` overrides the address in the code). The code contains your pairing secret, so do not share a screenshot of it.
70
-
71
- The daemon will:
72
- 1. Load or generate a persistent self-signed TLS certificate and random bearer token at `~/.remogent/identity.json`.
73
- 2. Advertise service on the local network via mDNS (`_remogent._tcp`).
74
- 3. Run hidden in the background, so it prints nothing itself. The pairing QR is shown at the end of `setup` / `install`, and any time later by `pair`. The code encodes a link like:
75
- ```text
76
- remogent://pair?host=192.168.x.y&port=9876&token=...&fp=...&name=my-pc&alt=100.x.y.z
77
- ```
78
- `alt` (optional, comma separated, up to 4) lists the PC's other usable addresses, such as a second LAN or Tailscale. The app tries `host` first and falls back to these when it does not answer, and tries the one that worked first on reconnects until the app is restarted. Pass `--host IP` to `install` / `pair` to pin a single address and omit `alt`.
79
-
80
- To rotate the authentication secret at any time:
81
- ```bash
82
- node dist/index.js install --rotate # re-registers the service with a new secret; re-pair the phone afterwards
83
- ```
84
-
85
- Chats survive daemon restarts: transcripts are saved to `~/.remogent/sessions.json` (owner-only file, newest ~2 MiB per chat, 14 days / 100 chats). Claude Code and Codex chats resume with their full context on the next prompt; Gemini chats come back as read-only history. Pending approvals and "Allow for session" grants are never restored. To keep nothing on disk, start with `--no-persist`.
86
-
87
- ---
88
-
89
- ### 2. Android App Installation & Pairing
90
-
91
- **Prerequisites:** Android 8.0+ (API 26+), Android Studio with JDK 17.
92
-
93
- ```bash
94
- cd android_app
95
- ./gradlew assembleDebug
96
- ```
97
- The compiled debug APK will be generated at:
98
- ```
99
- android_app/app/build/outputs/apk/debug/app-debug.apk
100
- ```
101
-
102
- To install directly to a connected device:
103
- ```bash
104
- ./gradlew installDebug
105
- ```
106
-
107
- #### Pairing Steps:
108
- 0. For the first pairing, put the phone and the PC on the **same Wi-Fi network** (the QR's main address is the PC's local one). To also use the phone away from home, set up Tailscale first: see [Using Remogent away from home](#using-remogent-away-from-home-tailscale-free) below.
109
- 1. On the PC, make sure the QR is on screen: it is printed at the end of `npm run setup`, or run `npm run pair` in `companion/` to show it again.
110
- 2. Open **Remogent** on your Android device, open the drawer, expand the host card and tap **Pair new host**.
111
- 3. Tap **SCAN QR CODE** and point the camera at the QR (or paste the `remogent://pair?...` link, which `pair --show-url` prints).
112
- 4. The app verifies the 64-character SHA-256 fingerprint, stores the credentials in the Android Keystore and connects. The `pair` window then closes itself.
113
- 5. The status indicator changes to **LIVE** with an emerald pulsing live dot.
114
-
115
- #### Using Remogent away from home (Tailscale, free)
116
-
117
- At home the phone talks to the PC over Wi-Fi. To keep working on mobile data or another network without any paid relay, add [Tailscale](https://tailscale.com/) (free personal plan; your traffic stays peer-to-peer and Remogent's pinned TLS still applies):
118
-
119
- 1. Install Tailscale on the **PC** and on the **phone** and sign both in to the same Tailscale account.
120
- 2. On the PC run `npm run pair` (or `node dist/index.js pair`). With Tailscale up, the QR automatically carries the PC's Tailscale address as a backup (`alt=100.x.y.z`); `npm run doctor` lists the addresses it will use.
121
- 3. Scan it in the app as in the steps above. Already paired before installing Tailscale? Just scan the new QR once; the app keeps one profile for the PC and adds the backup address to it.
122
-
123
- That is all. The app tries the Wi-Fi address first and, if it does not answer within about 5 seconds, switches to the Tailscale one by itself, so turning Wi-Fi off or leaving home needs no action. After the app is restarted it tries the last address that worked first. The drawer's host card shows which address is connected right now (for example `192.168.x.y` at home, `100.x.y.z` over Tailscale).
124
-
125
- Keep Tailscale running on the phone (Android may close VPN apps aggressively under battery saving; exempt it if the fallback never kicks in).
126
-
127
- #### If pairing or connecting fails
128
- - **Cannot connect away from home:** the phone needs Tailscale running and signed in to the same account as the PC, and the QR must have been made while Tailscale was up on the PC (`npm run doctor` should list a `100.x.y.z ... on Tailscale` candidate). If you installed Tailscale after pairing, run `npm run pair` and scan again.
129
- - **Same network:** without Tailscale, the phone and PC must be on the same Wi-Fi (or reachable through WireGuard). `npm run doctor` shows the address the QR will use and the other candidates; pass `--host IP` to `pair` or `install` to pin one address (no backup is added then).
130
- - **Scan works but the app cannot connect:** the Windows Firewall is the usual cause. `install` now tries to add an inbound rule for the host port (private networks, local subnet and Tailscale only). That needs administrator rights, so if it prints a firewall warning (or `npm run doctor` does), run `node dist/index.js firewall --elevate` and accept the Windows prompt. `uninstall` removes the rule again.
131
- - **Port 9876 is taken:** `install` picks the next free port by itself and stores it with the host identity, so every later command (`pair`, `status`, `doctor`, `start`) uses it. Phones paired on the old port must scan the QR once more. `--port N` forces a port.
132
- - **USB instead of Wi-Fi:** with a cable and USB debugging on, `adb reverse tcp:9876 tcp:9876` lets the phone reach the host as `127.0.0.1`.
133
- - **QR is gone or the host was rotated:** run `npm run pair` again (after `install --rotate`, re-pair every phone).
134
-
135
- ---
136
-
137
- ### 3. Running an AI Session
138
-
139
- 1. In the active host card, tap **NEW AI SESSION**.
140
- 2. Select your desired engine:
141
- - **Gemini CLI** (stdio ACP JSON-RPC)
142
- - **Claude Code CLI** (stdio stream-json)
143
- - **Codex CLI** (stdio `codex app-server`; sign in once with `codex login`)
144
- 3. Set the working directory (e.g. `.` or an absolute project path).
145
- 4. Choose the permission mode:
146
- - `Ask` (Default): Every mutating shell command or file edit prompts for your confirmation.
147
- - `Auto (YOLO)`: Automatically approves commands (Gemini `yolo` / Claude `bypassPermissions` / Codex no sandbox, never ask).
148
- - `Plan Only`: Read-only execution mode.
149
- 5. Tap **START SESSION**.
150
- 6. Prompt the agent in real time, inspect reasoning in collapsible thought bubbles, and monitor tool outputs.
151
-
152
- ---
153
-
154
- ## Testing & Verification
155
-
156
- ### Companion Daemon Verification
157
- ```bash
158
- cd companion
159
- npm run typecheck
160
- npm run build
161
- node scripts/smoke.mjs # Auth, TLS fingerprint & ping checks
162
- node scripts/sessions-test.mjs # Protocol v1 session management tests
163
- node scripts/persistence-test.mjs # Save/restore/resume, mid-turn crash, corrupt store (offline)
164
- node scripts/claude-persist-live.mjs # OPT-IN: real claude --resume across a daemon restart (2 model calls)
165
- ```
166
-
167
- ### Android App Verification
168
- ```bash
169
- cd android_app
170
- ./gradlew testDebugUnitTest # URI parser (incl. backup addresses), protocol & transcript tests
171
- ./gradlew assembleDebug # Full APK compilation and packaging
172
- ```
173
-
174
- ---
175
-
176
- ## Security Model
177
-
178
- 1. **Strict Certificate Pinning:** The Android client pins the SHA-256 fingerprint of the host's DER certificate. Untrusted or modified certificates are instantly rejected.
179
- 2. **Bearer Authentication:** All WebSocket upgrade requests require an authentic 32-byte cryptographically secure random token (`Authorization: Bearer <token>`). Unauthenticated requests are rejected before reaching the WebSocket handler.
180
- 3. **Hardware Storage:** Token secrets and server fingerprints are stored inside Android Keystore (`MasterKey` with AES-256-GCM and AES-256-SIV).
181
- 4. **Biometric Safeguard:** Optional fingerprint/face unlock required prior to authorizing destructive commands on the host machine.
182
- 5. **No Cloud Relays:** All communication is strictly peer-to-peer. Zero telemetry, zero analytics.
183
-
184
- ---
185
-
186
- ## Support
187
-
188
- Remogent is free and open-source, with no ads, no subscriptions and no cloud relay. If it helps your work, you can support ongoing development on [Patreon](https://www.patreon.com/Infomity). The Android app has the same button at the bottom of **Settings**.
189
-
190
- ---
191
-
192
- ## Privacy
193
-
194
- Remogent collects no data and runs no servers. Read the [Privacy Policy](https://infomity.github.io/remogent/privacy/) ([summary and Play Data safety answers](docs/PRIVACY_POLICY.md)); the app links to it from **Settings -> About Remogent**.
195
-
196
- ---
197
-
198
- ## License
199
-
200
- Apache-2.0. Built by the **Infomity Studio** team.
201
-
1
+ # Remogent
2
+
3
+ <p align="center">
4
+ <strong>Zero-Recurring-Cost Android Remote AI Agent & Terminal Client</strong><br>
5
+ <em>Drive Gemini CLI, Claude Code CLI and Codex CLI directly from your phone over secure, pinned-TLS transport.</em>
6
+ </p>
7
+
8
+ <p align="center">
9
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/infomity/Remogent?color=blue" alt="License: Apache-2.0"></a>
10
+ <a href="https://www.npmjs.com/package/@infomity/remogent"><img src="https://img.shields.io/npm/v/@infomity/remogent?logo=npm&label=npm" alt="npm version of @infomity/remogent"></a>
11
+ <img src="https://img.shields.io/node/v/@infomity/remogent?logo=nodedotjs&logoColor=white&label=node" alt="Supported Node.js versions">
12
+ <img src="https://img.shields.io/badge/android-8.0%2B-3DDC84?logo=android&logoColor=white" alt="Android 8.0 and newer">
13
+ <img src="https://img.shields.io/badge/telemetry-none-059669" alt="No telemetry">
14
+ <img src="https://img.shields.io/badge/cloud%20relay-none-059669" alt="No cloud relay">
15
+ </p>
16
+
17
+ <p align="center">
18
+ <img src="docs/screenshots/01-new-chat.png" alt="Remogent new chat screen with agent, folder and permission-mode chips" width="190">
19
+ <img src="docs/screenshots/02-sessions.png" alt="Remogent drawer listing chat sessions across Claude Code, Gemini CLI and Codex CLI" width="190">
20
+ <img src="docs/screenshots/03-chat.png" alt="Remogent chat showing a Bash tool card and Claude Code's reply" width="190">
21
+ <img src="docs/screenshots/04-approval.png" alt="Remogent tool approval sheet with diff preview and Deny, Allow once and For session buttons" width="190">
22
+ </p>
23
+
24
+ ---
25
+
26
+ ## Overview
27
+
28
+ **Remogent** enables software engineers to monitor, prompt, and approve actions from **Gemini CLI**, **Claude Code CLI** and **Codex CLI** from their Android phone without paying monthly cloud relay fees.
29
+
30
+ ### Core Pillars
31
+ - **$0.00 Infrastructure Cost**: Direct peer-to-peer transport over local Wi-Fi, Tailscale, WireGuard, or Cloudflare tunnels. No intermediary cloud servers, no subscriptions.
32
+ - **Three AI Engines**: First-class support for **Gemini CLI** (stdio JSON-RPC 2.0 ACP protocol), **Claude Code CLI** (bidirectional stream-json protocol) and **Codex CLI** (`codex app-server` JSON-RPC).
33
+ - **Per-Chat Model Choice**: The model list is read from each engine's own CLI on your PC (so it follows your logins and settings, including a custom Claude provider). Pick a model when you start a chat and switch it between turns from the chat details; without a choice the engine uses its own default.
34
+ - **Interactive Tool Approvals**: Destructive operations (`Bash`, file edits, network calls) trigger an audible/haptic approval bottom sheet on your phone with granular control: `Deny`, `Allow Once`, or `Allow for Session`.
35
+ - **Biometric Guard**: Gate tool authorizations and sensitive executions behind on-device biometric fingerprint / face authentication.
36
+ - **Hardware-Backed Keystore Storage**: Host pairing tokens and TLS credentials encrypted on-device via Android Jetpack Security (`MasterKey` + `EncryptedSharedPreferences`).
37
+ - **Cryptographic Pinned TLS**: Every connection verifies the host's 64-character SHA-256 certificate fingerprint, eliminating MITM risks without requiring public CA certificates.
38
+ - **Gap-Free Session Resync**: Transcripts use strict gap-free sequence ordering (`seq`). Disconnecting and reconnecting immediately replays unread deltas and pending permissions.
39
+
40
+ ---
41
+
42
+ ## Architecture
43
+
44
+ <p align="center">
45
+ <picture>
46
+ <source media="(prefers-color-scheme: dark)" srcset="docs/architecture-dark.svg">
47
+ <source media="(prefers-color-scheme: light)" srcset="docs/architecture-light.svg">
48
+ <img src="docs/architecture.png" alt="Remogent architecture: Android app (chat UI, approvals, biometric shield, secure storage, pinned-TLS WebSocket client) connected directly to the PC host (WSS server, session manager, Claude, Gemini and Codex adapters, autostart), which drives the Claude Code, Gemini and Codex CLIs. No cloud relay." width="100%">
49
+ </picture>
50
+ </p>
51
+
52
+ <sub>Follows your light/dark theme. Generated by [`docs/build_architecture.mjs`](docs/build_architecture.mjs) (no dependencies) &middot; adaptive vector: [`docs/architecture.svg`](docs/architecture.svg) &middot; regenerate with `docs\render_architecture.cmd`.</sub>
53
+
54
+ ---
55
+
56
+ ## Setup & Operation
57
+
58
+ ### 1. Host Companion Setup (PC)
59
+
60
+ **Prerequisites:** Node.js >= 20.x, and at least one of `gemini`, `claude` or `codex` installed (found automatically, no PATH editing needed).
61
+
62
+ ```bash
63
+ # Easiest: one command, no clone needed (npm package @infomity/remogent).
64
+ # It checks the machine (doctor), installs itself globally so the autostart path is stable,
65
+ # starts the host now AND at every sign-in, and shows the pairing QR
66
+ npx @infomity/remogent setup
67
+
68
+ # From then on the `remogent` command is available: remogent pair | status | doctor | uninstall
69
+ ```
70
+
71
+ Or from source:
72
+
73
+ ```bash
74
+ # Clone the repository
75
+ git clone https://github.com/infomity/Remogent.git
76
+ cd Remogent/companion
77
+
78
+ # One command: install dependencies, build, check the machine (doctor), then start the host
79
+ # now AND at every sign-in and show the pairing QR
80
+ npm run setup
81
+ ```
82
+
83
+ `doctor` finds the Claude Code, Gemini and Codex CLIs by itself (PATH, then the native installer, npm -g, Volta, pnpm, nvm, Homebrew, scoop), so you never edit PATH, and tells you exactly what to install if something is missing. Run it any time with `remogent doctor` (or `npm run doctor` from source). `install` (the last step of `setup`) registers an autostart service that runs as your user (it needs your `claude` / `gemini` / `codex` logins), starts at sign-in and restarts if it crashes: Task Scheduler on Windows, launchd on macOS, `systemd --user` on Linux. After pairing, the host is simply there; no terminal to keep open. Related commands (from source use `node dist/index.js` instead of `remogent`):
84
+
85
+ ```bash
86
+ remogent pair # show the pairing QR again (new phone, or you closed the setup window)
87
+ remogent status # autostart state + whether the host answers (log: ~/.remogent/daemon.log)
88
+ remogent uninstall # stop and remove the autostart registration
89
+ remogent start --port 9876 --show-url # optional: run in the foreground instead (Ctrl+C stops it)
90
+ ```
91
+
92
+ `remogent pair` (or `npm run pair` from source) prints the QR, waits for the phone to connect, then closes itself (`--no-wait` keeps it open, `--timeout MIN` changes the 5-minute limit, `--show-url` also prints the `remogent://` link, `--host IP` overrides the address in the code). The code contains your pairing secret, so do not share a screenshot of it.
93
+
94
+ The daemon will:
95
+ 1. Load or generate a persistent self-signed TLS certificate and random bearer token at `~/.remogent/identity.json`.
96
+ 2. Advertise service on the local network via mDNS (`_remogent._tcp`).
97
+ 3. Run hidden in the background, so it prints nothing itself. The pairing QR is shown at the end of `setup` / `install`, and any time later by `pair`. The code encodes a link like:
98
+ ```text
99
+ remogent://pair?host=192.168.x.y&port=9876&token=...&fp=...&name=my-pc&alt=100.x.y.z
100
+ ```
101
+ `alt` (optional, comma separated, up to 4) lists the PC's other usable addresses, such as a second LAN or Tailscale. The app tries `host` first and falls back to these when it does not answer, and tries the one that worked first on reconnects until the app is restarted. Pass `--host IP` to `install` / `pair` to pin a single address and omit `alt`.
102
+
103
+ To rotate the authentication secret at any time:
104
+ ```bash
105
+ node dist/index.js install --rotate # re-registers the service with a new secret; re-pair the phone afterwards
106
+ ```
107
+
108
+ Chats survive daemon restarts: transcripts are saved to `~/.remogent/sessions.json` (owner-only file, newest ~2 MiB per chat, 14 days / 100 chats). Claude Code and Codex chats resume with their full context on the next prompt; Gemini chats come back as read-only history. Pending approvals and "Allow for session" grants are never restored. To keep nothing on disk, start with `--no-persist`.
109
+
110
+ ---
111
+
112
+ ### 2. Android App Installation & Pairing
113
+
114
+ **Prerequisites:** Android 8.0+ (API 26+), Android Studio with JDK 17.
115
+
116
+ ```bash
117
+ cd android_app
118
+ ./gradlew assembleDebug
119
+ ```
120
+ The compiled debug APK will be generated at:
121
+ ```
122
+ android_app/app/build/outputs/apk/debug/app-debug.apk
123
+ ```
124
+
125
+ To install directly to a connected device:
126
+ ```bash
127
+ ./gradlew installDebug
128
+ ```
129
+
130
+ #### Pairing Steps:
131
+ 0. For the first pairing, put the phone and the PC on the **same Wi-Fi network** (the QR's main address is the PC's local one). To also use the phone away from home, set up Tailscale first: see [Using Remogent away from home](#using-remogent-away-from-home-tailscale-free) below.
132
+ 1. On the PC, make sure the QR is on screen: it is printed at the end of `npm run setup`, or run `npm run pair` in `companion/` to show it again.
133
+ 2. Open **Remogent** on your Android device, open the drawer, expand the host card and tap **Pair new host**.
134
+ 3. Tap **SCAN QR CODE** and point the camera at the QR (or paste the `remogent://pair?...` link, which `pair --show-url` prints).
135
+ 4. The app verifies the 64-character SHA-256 fingerprint, stores the credentials in the Android Keystore and connects. The `pair` window then closes itself.
136
+ 5. The status indicator changes to **LIVE** with an emerald pulsing live dot.
137
+
138
+ #### Using Remogent away from home (Tailscale, free)
139
+
140
+ At home the phone talks to the PC over Wi-Fi. To keep working on mobile data or another network without any paid relay, add [Tailscale](https://tailscale.com/) (free personal plan; your traffic stays peer-to-peer and Remogent's pinned TLS still applies):
141
+
142
+ 1. Install Tailscale on the **PC** and on the **phone** and sign both in to the same Tailscale account.
143
+ 2. On the PC run `npm run pair` (or `node dist/index.js pair`). With Tailscale up, the QR automatically carries the PC's Tailscale address as a backup (`alt=100.x.y.z`); `npm run doctor` lists the addresses it will use.
144
+ 3. Scan it in the app as in the steps above. Already paired before installing Tailscale? Just scan the new QR once; the app keeps one profile for the PC and adds the backup address to it.
145
+
146
+ That is all. The app tries the Wi-Fi address first and, if it does not answer within about 5 seconds, switches to the Tailscale one by itself, so turning Wi-Fi off or leaving home needs no action. After the app is restarted it tries the last address that worked first. The drawer's host card shows which address is connected right now (for example `192.168.x.y` at home, `100.x.y.z` over Tailscale).
147
+
148
+ Keep Tailscale running on the phone (Android may close VPN apps aggressively under battery saving; exempt it if the fallback never kicks in).
149
+
150
+ #### If pairing or connecting fails
151
+ - **Cannot connect away from home:** the phone needs Tailscale running and signed in to the same account as the PC, and the QR must have been made while Tailscale was up on the PC (`npm run doctor` should list a `100.x.y.z ... on Tailscale` candidate). If you installed Tailscale after pairing, run `npm run pair` and scan again.
152
+ - **Same network:** without Tailscale, the phone and PC must be on the same Wi-Fi (or reachable through WireGuard). `npm run doctor` shows the address the QR will use and the other candidates; pass `--host IP` to `pair` or `install` to pin one address (no backup is added then).
153
+ - **Scan works but the app cannot connect:** the Windows Firewall is the usual cause. `install` now tries to add an inbound rule for the host port (private networks, local subnet and Tailscale only). That needs administrator rights, so if it prints a firewall warning (or `npm run doctor` does), run `node dist/index.js firewall --elevate` and accept the Windows prompt. `uninstall` removes the rule again.
154
+ - **Port 9876 is taken:** `install` picks the next free port by itself and stores it with the host identity, so every later command (`pair`, `status`, `doctor`, `start`) uses it. Phones paired on the old port must scan the QR once more. `--port N` forces a port.
155
+ - **USB instead of Wi-Fi:** with a cable and USB debugging on, `adb reverse tcp:9876 tcp:9876` lets the phone reach the host as `127.0.0.1`.
156
+ - **QR is gone or the host was rotated:** run `npm run pair` again (after `install --rotate`, re-pair every phone).
157
+
158
+ ---
159
+
160
+ ### 3. Running an AI Session
161
+
162
+ 1. In the active host card, tap **NEW AI SESSION**.
163
+ 2. Select your desired engine:
164
+ - **Gemini CLI** (stdio ACP JSON-RPC)
165
+ - **Claude Code CLI** (stdio stream-json)
166
+ - **Codex CLI** (stdio `codex app-server`; sign in once with `codex login`)
167
+ 3. Optionally tap the model chip to pick one of the models the engine's CLI lists (skip it to use the engine's default).
168
+ 4. Set the working directory (e.g. `.` or an absolute project path).
169
+ 5. Choose the permission mode:
170
+ - `Ask` (Default): Every mutating shell command or file edit prompts for your confirmation.
171
+ - `Auto (YOLO)`: Automatically approves commands (Gemini `yolo` / Claude `bypassPermissions` / Codex no sandbox, never ask).
172
+ - `Plan Only`: Read-only execution mode.
173
+ 6. Tap **START SESSION**.
174
+ 7. Prompt the agent in real time, inspect reasoning in collapsible thought bubbles, and monitor tool outputs.
175
+
176
+ ---
177
+
178
+ ## Testing & Verification
179
+
180
+ ### Companion Daemon Verification
181
+ ```bash
182
+ cd companion
183
+ npm run typecheck
184
+ npm run build
185
+ node scripts/smoke.mjs # Auth, TLS fingerprint & ping checks
186
+ node scripts/sessions-test.mjs # Protocol v1 session management tests
187
+ node scripts/persistence-test.mjs # Save/restore/resume, mid-turn crash, corrupt store (offline)
188
+ node scripts/claude-persist-live.mjs # OPT-IN: real claude --resume across a daemon restart (2 model calls)
189
+ ```
190
+
191
+ ### Android App Verification
192
+ ```bash
193
+ cd android_app
194
+ ./gradlew testDebugUnitTest # URI parser (incl. backup addresses), protocol & transcript tests
195
+ ./gradlew assembleDebug # Full APK compilation and packaging
196
+ ```
197
+
198
+ ---
199
+
200
+ ## Security Model
201
+
202
+ 1. **Strict Certificate Pinning:** The Android client pins the SHA-256 fingerprint of the host's DER certificate. Untrusted or modified certificates are instantly rejected.
203
+ 2. **Bearer Authentication:** All WebSocket upgrade requests require an authentic 32-byte cryptographically secure random token (`Authorization: Bearer <token>`). Unauthenticated requests are rejected before reaching the WebSocket handler.
204
+ 3. **Hardware Storage:** Token secrets and server fingerprints are stored inside Android Keystore (`MasterKey` with AES-256-GCM and AES-256-SIV).
205
+ 4. **Biometric Safeguard:** Optional fingerprint/face unlock required prior to authorizing destructive commands on the host machine.
206
+ 5. **No Cloud Relays:** All communication is strictly peer-to-peer. Zero telemetry, zero analytics.
207
+
208
+ ---
209
+
210
+ ## Support
211
+
212
+ Remogent is free and open-source, with no ads, no subscriptions and no cloud relay. If it helps your work, you can support ongoing development on [Patreon](https://www.patreon.com/Infomity). The Android app has the same button at the bottom of **Settings**.
213
+
214
+ ---
215
+
216
+ ## Privacy
217
+
218
+ Remogent collects no data and runs no servers. Read the [Privacy Policy](https://infomity.github.io/remogent/privacy/) ([summary and Play Data safety answers](docs/PRIVACY_POLICY.md)); the app links to it from **Settings -> About Remogent**.
219
+
220
+ ---
221
+
222
+ ## License
223
+
224
+ Apache-2.0. Built by the **Infomity Studio** team.
225
+
@@ -105,9 +105,80 @@ function userSettingsEnv() {
105
105
  return {};
106
106
  }
107
107
  }
108
+ /**
109
+ * The `default` alias in Claude's model list means "no override": the CLI's own choice, which honours the user's
110
+ * `ANTHROPIC_MODEL` / provider settings. Passing the literal alias fails on custom providers (seen live against a router),
111
+ * so it is mapped to "no --model" at spawn and to a `set_model` without a model at runtime (resets to the default).
112
+ */
113
+ const DEFAULT_MODEL = "default";
114
+ /**
115
+ * Asks a short-lived `claude` process for its model list (the `models` of the stream-json `initialize` response; no model
116
+ * call is made). Same environment as a real session so a custom base URL / provider in the user's settings is honoured.
117
+ * `id` is the alias or id Claude accepts for `--model` / `set_model` (`default`, `opus`, `sonnet`, ...).
118
+ */
119
+ function queryClaudeModels(binary) {
120
+ return new Promise((resolve, reject) => {
121
+ const args = ["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--setting-sources", "project,local", "--permission-prompt-tool", "stdio", "--permission-mode", "default"];
122
+ const child = spawnCli(binary, args, homedir(), userSettingsEnv());
123
+ let done = false;
124
+ const timer = setTimeout(() => finish(new Error("claude did not answer in time")), 20_000);
125
+ const finish = (err, list) => {
126
+ if (done)
127
+ return;
128
+ done = true;
129
+ clearTimeout(timer);
130
+ killTree(child);
131
+ if (err)
132
+ reject(err);
133
+ else
134
+ resolve(list ?? []);
135
+ };
136
+ child.stdin.on("error", () => { });
137
+ child.on("error", (e) => finish(new Error(`claude failed to start: ${e.message}`)));
138
+ child.on("exit", (code) => finish(new Error(`claude exited (code ${code})`)));
139
+ createInterface({ input: child.stdout }).on("line", (line) => {
140
+ const l = line.trim();
141
+ if (!l.startsWith("{"))
142
+ return;
143
+ let m;
144
+ try {
145
+ m = JSON.parse(l);
146
+ }
147
+ catch {
148
+ return;
149
+ }
150
+ if (!isObj(m) || m["type"] !== "control_response" || !isObj(m["response"]))
151
+ return;
152
+ const r = m["response"];
153
+ if (r["request_id"] !== "models-1")
154
+ return;
155
+ if (r["subtype"] === "error")
156
+ return finish(new Error(str(r["error"]) ?? "claude refused the model query"));
157
+ const body = isObj(r["response"]) ? r["response"] : {};
158
+ const raw = Array.isArray(body["models"]) ? body["models"].filter(isObj) : [];
159
+ finish(undefined, raw.flatMap((x) => {
160
+ const id = str(x["value"]);
161
+ if (!id)
162
+ return [];
163
+ const description = str(x["description"]);
164
+ const levels = Array.isArray(x["supportedEffortLevels"]) ? x["supportedEffortLevels"].filter((v) => typeof v === "string") : [];
165
+ return [
166
+ {
167
+ id,
168
+ name: str(x["displayName"]) ?? id,
169
+ ...(description ? { description } : {}),
170
+ ...(id === "default" ? { isDefault: true } : {}),
171
+ ...(x["supportsEffort"] === true && levels.length ? { efforts: levels } : {}),
172
+ },
173
+ ];
174
+ }));
175
+ });
176
+ child.stdin.write(JSON.stringify({ type: "control_request", request_id: "models-1", request: { subtype: "initialize" } }) + "\n");
177
+ });
178
+ }
108
179
  export function createClaudeAdapter(binary) {
109
180
  /** Spawns one `claude` process (optionally resuming an earlier conversation) and completes the initialize handshake. */
110
- async function launch({ cwd, permissionMode }, sink, resumeId, onSessionId) {
181
+ async function launch({ cwd, permissionMode, model }, sink, resumeId, onSessionId) {
111
182
  // User-level settings are NOT loaded: a blanket allow-list there (e.g. `Bash(*)`) made tools run with no
112
183
  // can_use_tool request, so a phone user's approvals would silently not gate anything (seen live, 2026-11-13).
113
184
  // Project/local settings (the repo's own .claude/) still apply. The user's `env` block is passed through.
@@ -119,6 +190,7 @@ export function createClaudeAdapter(binary) {
119
190
  "--setting-sources", "project,local",
120
191
  "--permission-prompt-tool", "stdio",
121
192
  "--permission-mode", MODE_IDS[permissionMode],
193
+ ...(model && model !== DEFAULT_MODEL ? ["--model", model] : []),
122
194
  ...(resumeId ? ["--resume", resumeId] : []),
123
195
  ];
124
196
  const child = spawnCli(binary, args, cwd, userSettingsEnv());
@@ -129,6 +201,7 @@ export function createClaudeAdapter(binary) {
129
201
  let lastStderrAt = 0;
130
202
  let dead = false; // the process exited on its own
131
203
  let reqSeq = 0;
204
+ const ctlWaiters = new Map(); // control requests we sent (set_model)
132
205
  let turn;
133
206
  let initAck;
134
207
  child.stdin.on("error", () => { });
@@ -229,6 +302,14 @@ export function createClaudeAdapter(binary) {
229
302
  const r = isObj(m["response"]) ? m["response"] : {};
230
303
  if (r["request_id"] === "init-1")
231
304
  initAck?.();
305
+ const w = ctlWaiters.get(String(r["request_id"]));
306
+ if (w) {
307
+ ctlWaiters.delete(String(r["request_id"]));
308
+ if (r["subtype"] === "error")
309
+ w.reject(new Error(str(r["error"]) ?? "claude rejected the request"));
310
+ else
311
+ w.resolve();
312
+ }
232
313
  return;
233
314
  }
234
315
  case "control_request":
@@ -346,6 +427,30 @@ export function createClaudeAdapter(binary) {
346
427
  send({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
347
428
  });
348
429
  },
430
+ setModel(model) {
431
+ if (closed)
432
+ return Promise.reject(new Error("claude process is not running"));
433
+ if (turn)
434
+ return Promise.reject(new Error("a turn is already running"));
435
+ const id = `m${++reqSeq}`;
436
+ return new Promise((resolve, reject) => {
437
+ const timer = setTimeout(() => {
438
+ ctlWaiters.delete(id);
439
+ reject(new Error("claude did not confirm the model change"));
440
+ }, 15_000);
441
+ ctlWaiters.set(id, {
442
+ resolve: () => {
443
+ clearTimeout(timer);
444
+ resolve();
445
+ },
446
+ reject: (e) => {
447
+ clearTimeout(timer);
448
+ reject(e);
449
+ },
450
+ });
451
+ send({ type: "control_request", request_id: id, request: model === DEFAULT_MODEL ? { subtype: "set_model" } : { subtype: "set_model", model } });
452
+ });
453
+ },
349
454
  cancel() {
350
455
  if (closed || !turn)
351
456
  return;
@@ -362,24 +467,44 @@ export function createClaudeAdapter(binary) {
362
467
  return {
363
468
  engine: "claude",
364
469
  resumable: true,
470
+ listModels: () => queryClaudeModels(binary),
365
471
  async start(opts, sink) {
366
472
  let claudeSessionId = opts.resumeId;
473
+ let curModel = opts.model; // reapplied whenever the CLI is (re)spawned
367
474
  let closedByUser = false;
368
475
  const remember = (id) => {
369
476
  claudeSessionId = id;
370
477
  };
371
478
  // Resuming (after a host restart): spawn lazily on the first prompt instead of keeping an idle process per old chat.
372
- let current = opts.resumeId ? undefined : await launch(opts, sink, undefined, remember);
479
+ const launchOpts = () => ({ cwd: opts.cwd, permissionMode: opts.permissionMode, ...(curModel ? { model: curModel } : {}) });
480
+ let current = opts.resumeId ? undefined : await launch(launchOpts(), sink, undefined, remember);
373
481
  return {
374
482
  async prompt(text) {
375
483
  if (closedByUser)
376
484
  throw new Error("claude process is not running");
377
485
  if (!current || (current.hasDied() && claudeSessionId)) {
378
486
  // No process yet (restored chat) or it exited on its own (killed, crashed): continue the same conversation.
379
- current = await launch(opts, sink, claudeSessionId, remember);
487
+ current = await launch(launchOpts(), sink, claudeSessionId, remember);
380
488
  }
381
489
  return current.handle.prompt(text);
382
490
  },
491
+ async setModel(model) {
492
+ if (closedByUser)
493
+ throw new Error("claude process is not running");
494
+ // A live process switches in place; a restored/dead one picks the model up (--model) when it is respawned.
495
+ // Back to the default: a runtime reset ignores the user's ANTHROPIC_MODEL / provider (seen live against a
496
+ // router), a fresh spawn without --model honours it, so end the process and let the next prompt resume the
497
+ // same conversation without an override.
498
+ if (model === DEFAULT_MODEL) {
499
+ if (current && !current.hasDied()) {
500
+ current.handle.close();
501
+ current = undefined;
502
+ }
503
+ }
504
+ else if (current && !current.hasDied())
505
+ await current.handle.setModel?.(model);
506
+ curModel = model;
507
+ },
383
508
  cancel: () => current?.handle.cancel(),
384
509
  close() {
385
510
  closedByUser = true;
@@ -1,3 +1,4 @@
1
+ import { homedir } from "node:os";
1
2
  import { RpcError, RpcPeer } from "./rpc.js";
2
3
  import { findOnPath, killTree, spawnCli } from "./util.js";
3
4
  const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -124,13 +125,83 @@ function toolEnd(item) {
124
125
  ...(diff ? { diff } : {}),
125
126
  };
126
127
  }
128
+ /**
129
+ * Reads the `data[]` of `model/list` (verified on codex-cli 0.169.0): `{ id, model, displayName, description, hidden, isDefault,
130
+ * supportedReasoningEfforts: [{ reasoningEffort }] }`. `model` is what `thread/start` / `turn/start` take; hidden models are skipped.
131
+ */
132
+ function parseCodexModels(data) {
133
+ if (!Array.isArray(data))
134
+ return [];
135
+ return data.filter(isObj).flatMap((x) => {
136
+ if (x["hidden"] === true)
137
+ return [];
138
+ const id = str(x["model"]) ?? str(x["id"]);
139
+ if (!id)
140
+ return [];
141
+ const description = str(x["description"]);
142
+ const efforts = Array.isArray(x["supportedReasoningEfforts"])
143
+ ? x["supportedReasoningEfforts"].map((e) => (isObj(e) ? str(e["reasoningEffort"]) : str(e))).filter((e) => !!e)
144
+ : [];
145
+ return [
146
+ {
147
+ id,
148
+ name: str(x["displayName"]) ?? id,
149
+ ...(description ? { description } : {}),
150
+ ...(x["isDefault"] === true ? { isDefault: true } : {}),
151
+ ...(efforts.length ? { efforts } : {}),
152
+ },
153
+ ];
154
+ });
155
+ }
156
+ /** Asks a short-lived `codex app-server` for its model list (`model/list`; no model call is made). */
157
+ async function queryCodexModels(binary) {
158
+ const child = spawnCli(binary, ["app-server"], homedir());
159
+ const rpc = new RpcPeer(child, { jsonrpcField: false });
160
+ let stderrTail = "";
161
+ child.stderr.on("data", (d) => {
162
+ stderrTail = (stderrTail + d.toString()).slice(-2000);
163
+ });
164
+ child.on("error", (e) => rpc.failAll(new Error(`codex failed to start: ${e.message}`)));
165
+ child.on("exit", (code) => rpc.failAll(new Error(stderrTail ? `codex exited (code ${code}): ${lastLine(stderrTail)}` : `codex exited (code ${code})`)));
166
+ rpc.onRequest = async (method) => {
167
+ throw new RpcError(-32601, `unsupported method: ${method}`);
168
+ };
169
+ let timer;
170
+ try {
171
+ const work = (async () => {
172
+ await rpc.request("initialize", { clientInfo: { name: "remogent", title: "Remogent", version: process.env["npm_package_version"] ?? "dev" } });
173
+ rpc.notify("initialized");
174
+ const out = [];
175
+ let cursor;
176
+ for (let page = 0; page < 5; page++) {
177
+ const res = await rpc.request("model/list", cursor ? { cursor } : {});
178
+ out.push(...parseCodexModels(isObj(res) ? res["data"] : undefined));
179
+ cursor = isObj(res) ? str(res["nextCursor"]) : undefined;
180
+ if (!cursor)
181
+ break;
182
+ }
183
+ return out;
184
+ })();
185
+ const timeout = new Promise((_, reject) => {
186
+ timer = setTimeout(() => reject(new Error("codex did not answer in time")), 20_000);
187
+ });
188
+ return await Promise.race([work, timeout]);
189
+ }
190
+ finally {
191
+ if (timer)
192
+ clearTimeout(timer);
193
+ killTree(child);
194
+ }
195
+ }
127
196
  export function createCodexAdapter(binary) {
128
197
  return {
129
198
  engine: "codex",
130
199
  resumable: true,
131
- async start({ cwd, permissionMode, resumeId }, sink) {
200
+ listModels: () => queryCodexModels(binary),
201
+ async start({ cwd, permissionMode, resumeId, model }, sink) {
132
202
  const policy = POLICIES[permissionMode];
133
203
  let threadId = resumeId;
204
+ let curModel = model; // sent with thread/start|resume and every turn/start (a switch is just a new value)
134
205
  let conn;
135
206
  let ready;
136
207
  let closed = false; // intentionally closed by the host
@@ -336,7 +407,7 @@ export function createCodexAdapter(binary) {
336
407
  clientInfo: { name: "remogent", title: "Remogent", version: process.env["npm_package_version"] ?? "dev" },
337
408
  });
338
409
  rpc.notify("initialized");
339
- const settings = { cwd, approvalPolicy: policy.approvalPolicy, sandbox: policy.sandbox };
410
+ const settings = { cwd, approvalPolicy: policy.approvalPolicy, sandbox: policy.sandbox, ...(curModel ? { model: curModel } : {}) };
340
411
  if (threadId) {
341
412
  await rpc.request("thread/resume", { threadId, excludeTurns: true, ...settings });
342
413
  }
@@ -384,7 +455,11 @@ export function createCodexAdapter(binary) {
384
455
  lastMsgItem = undefined;
385
456
  deltaItems.clear();
386
457
  try {
387
- const res = await c.rpc.request("turn/start", { threadId, input: [{ type: "text", text, text_elements: [] }] });
458
+ const res = await c.rpc.request("turn/start", {
459
+ threadId,
460
+ input: [{ type: "text", text, text_elements: [] }],
461
+ ...(curModel ? { model: curModel } : {}),
462
+ });
388
463
  const tn = isObj(res) ? res["turn"] : undefined;
389
464
  const id = isObj(tn) ? str(tn["id"]) : undefined;
390
465
  if (turn === t && id) {
@@ -400,6 +475,13 @@ export function createCodexAdapter(binary) {
400
475
  }
401
476
  await done;
402
477
  },
478
+ async setModel(next) {
479
+ if (closed)
480
+ throw new Error("codex session is closed");
481
+ if (turn)
482
+ throw new Error("a turn is already running");
483
+ curModel = next; // applied by the next turn/start (and by thread/resume after a respawn)
484
+ },
403
485
  cancel() {
404
486
  const t = turn;
405
487
  const c = conn;
@@ -1,3 +1,4 @@
1
+ import { homedir } from "node:os";
1
2
  import { RpcError, RpcPeer } from "./rpc.js";
2
3
  import { findOnPath, killTree, spawnCli } from "./util.js";
3
4
  const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -101,10 +102,55 @@ function lastLine(s) {
101
102
  const lines = s.trim().split(/\r?\n/);
102
103
  return lines[lines.length - 1] ?? "";
103
104
  }
105
+ /**
106
+ * Reads `models` from a `session/new` result: `{ availableModels: [{ modelId, name, description? }], currentModelId }`
107
+ * (verified on Gemini CLI 0.68.0). The model the CLI starts with (`currentModelId`, usually `auto`) is marked as default.
108
+ */
109
+ function parseGeminiModels(res) {
110
+ const block = isObj(res) && isObj(res["models"]) ? res["models"] : undefined;
111
+ if (!block || !Array.isArray(block["availableModels"]))
112
+ return [];
113
+ const current = str(block["currentModelId"]);
114
+ return block["availableModels"].filter(isObj).flatMap((x) => {
115
+ const id = str(x["modelId"]);
116
+ if (!id)
117
+ return [];
118
+ const description = str(x["description"]);
119
+ return [{ id, name: str(x["name"]) ?? id, ...(description ? { description } : {}), ...(id === current ? { isDefault: true } : {}) }];
120
+ });
121
+ }
122
+ /** Asks a short-lived `gemini --acp` process for its model list (initialize + session/new; no model call is made). */
123
+ async function queryGeminiModels(binary) {
124
+ const child = spawnCli(binary, ["--acp"], homedir());
125
+ const rpc = new RpcPeer(child);
126
+ let stderrTail = "";
127
+ child.stderr.on("data", (d) => {
128
+ stderrTail = (stderrTail + d.toString()).slice(-2000);
129
+ });
130
+ child.on("error", (e) => rpc.failAll(new Error(`gemini failed to start: ${e.message}`)));
131
+ child.on("exit", (code) => rpc.failAll(new Error(stderrTail ? `gemini exited (code ${code}): ${lastLine(stderrTail)}` : `gemini exited (code ${code})`)));
132
+ let timer;
133
+ try {
134
+ const work = (async () => {
135
+ await rpc.request("initialize", { protocolVersion: 1, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } } });
136
+ return parseGeminiModels(await rpc.request("session/new", { cwd: homedir(), mcpServers: [] }));
137
+ })();
138
+ const timeout = new Promise((_, reject) => {
139
+ timer = setTimeout(() => reject(new Error("gemini did not answer in time")), 20_000);
140
+ });
141
+ return await Promise.race([work, timeout]);
142
+ }
143
+ finally {
144
+ if (timer)
145
+ clearTimeout(timer);
146
+ killTree(child);
147
+ }
148
+ }
104
149
  export function createGeminiAdapter(binary) {
105
150
  return {
106
151
  engine: "gemini",
107
- async start({ cwd, permissionMode }, sink) {
152
+ listModels: () => queryGeminiModels(binary),
153
+ async start({ cwd, permissionMode, model }, sink) {
108
154
  const child = spawnCli(binary, ["--acp"], cwd);
109
155
  const rpc = new RpcPeer(child);
110
156
  let closed = false; // set once the session is intentionally closed or has died
@@ -176,6 +222,11 @@ export function createGeminiAdapter(binary) {
176
222
  sessionId = isObj(res) ? str(res["sessionId"]) : undefined;
177
223
  if (!sessionId)
178
224
  throw new Error("gemini did not return a sessionId");
225
+ // set_model accepts any id without complaint (host validated it against the listed models); skip it when the
226
+ // CLI already runs that model.
227
+ const startModel = isObj(res) && isObj(res["models"]) ? str(res["models"]["currentModelId"]) : undefined;
228
+ if (model && model !== startModel)
229
+ await rpc.request("session/set_model", { sessionId, modelId: model });
179
230
  if (permissionMode !== "ask") {
180
231
  await rpc.request("session/set_mode", { sessionId, modeId: MODE_IDS[permissionMode] });
181
232
  }
@@ -218,6 +269,13 @@ export function createGeminiAdapter(binary) {
218
269
  if (!closed)
219
270
  rpc.notify("session/cancel", { sessionId: sid });
220
271
  },
272
+ async setModel(next) {
273
+ if (closed)
274
+ throw new Error("gemini process is not running");
275
+ if (inTurn)
276
+ throw new Error("a turn is already running");
277
+ await rpc.request("session/set_model", { sessionId: sid, modelId: next });
278
+ },
221
279
  close() {
222
280
  closed = true;
223
281
  killTree(child);
package/dist/protocol.js CHANGED
@@ -12,6 +12,7 @@ export class ProtocolError extends Error {
12
12
  const ENGINES = ["gemini", "claude", "codex"];
13
13
  const MODES = ["ask", "plan", "auto_edit", "auto"];
14
14
  const DECISIONS = ["deny", "allow_once", "allow_session"];
15
+ const MAX_MODEL_ID = 200;
15
16
  function isObj(v) {
16
17
  return typeof v === "object" && v !== null && !Array.isArray(v);
17
18
  }
@@ -49,6 +50,13 @@ export function parseClientMessage(raw) {
49
50
  const app = typeof raw["app"] === "string" ? raw["app"] : undefined;
50
51
  return { type, protocol, ...(app ? { app } : {}) };
51
52
  }
53
+ case "models_list": {
54
+ const reqId = reqIdOf(raw);
55
+ const engine = reqStr(raw, "engine", reqId);
56
+ if (!ENGINES.includes(engine))
57
+ throw new ProtocolError("invalid_message", `unknown engine "${engine}"`, reqId);
58
+ return { type, reqId, engine: engine };
59
+ }
52
60
  case "session_create": {
53
61
  const reqId = reqIdOf(raw);
54
62
  const engine = reqStr(raw, "engine", reqId);
@@ -59,7 +67,18 @@ export function parseClientMessage(raw) {
59
67
  if (typeof pm !== "string" || !MODES.includes(pm)) {
60
68
  throw new ProtocolError("invalid_message", "invalid permissionMode", reqId);
61
69
  }
62
- return { type, reqId, engine: engine, cwd, permissionMode: pm };
70
+ const model = raw["model"];
71
+ if (model !== undefined && model !== null && (typeof model !== "string" || model.length === 0 || model.length > MAX_MODEL_ID)) {
72
+ throw new ProtocolError("invalid_message", '"model" must be a non-empty string', reqId);
73
+ }
74
+ return {
75
+ type,
76
+ reqId,
77
+ engine: engine,
78
+ cwd,
79
+ permissionMode: pm,
80
+ ...(typeof model === "string" ? { model } : {}),
81
+ };
63
82
  }
64
83
  case "session_list":
65
84
  return { type, reqId: reqIdOf(raw) };
@@ -77,6 +96,14 @@ export function parseClientMessage(raw) {
77
96
  throw new ProtocolError("invalid_message", '"title" must be a string', reqId);
78
97
  return { type, reqId, sessionId, title };
79
98
  }
99
+ case "session_set_model": {
100
+ const reqId = reqIdOf(raw);
101
+ const sessionId = reqStr(raw, "sessionId", reqId);
102
+ const model = reqStr(raw, "model", reqId);
103
+ if (model.length > MAX_MODEL_ID)
104
+ throw new ProtocolError("invalid_message", '"model" is too long', reqId);
105
+ return { type, reqId, sessionId, model };
106
+ }
80
107
  case "session_attach": {
81
108
  const reqId = reqIdOf(raw);
82
109
  const sessionId = reqStr(raw, "sessionId", reqId);
package/dist/server.js CHANGED
@@ -35,8 +35,13 @@ async function handleMessage(msg, sessions, viewer, ws) {
35
35
  ws.close(1002, "protocol version");
36
36
  }
37
37
  return;
38
+ case "models_list": {
39
+ const list = await sessions.models(msg.engine, msg.reqId);
40
+ viewer.send({ type: "models", reqId: msg.reqId, engine: msg.engine, models: list.models, ...(list.defaultId ? { defaultId: list.defaultId } : {}) });
41
+ return;
42
+ }
38
43
  case "session_create": {
39
- const session = await sessions.create(msg.engine, msg.cwd, msg.permissionMode, msg.reqId);
44
+ const session = await sessions.create(msg.engine, msg.cwd, msg.permissionMode, msg.reqId, msg.model);
40
45
  viewer.send({ type: "session_created", reqId: msg.reqId, session });
41
46
  return;
42
47
  }
@@ -70,6 +75,10 @@ async function handleMessage(msg, sessions, viewer, ws) {
70
75
  sessions.delete(msg.sessionId, msg.reqId);
71
76
  viewer.send({ type: "ack", reqId: msg.reqId });
72
77
  return;
78
+ case "session_set_model":
79
+ await sessions.setModel(msg.sessionId, msg.model, msg.reqId);
80
+ viewer.send({ type: "ack", reqId: msg.reqId });
81
+ return;
73
82
  case "session_rename":
74
83
  sessions.rename(msg.sessionId, msg.title, msg.reqId);
75
84
  viewer.send({ type: "ack", reqId: msg.reqId });
package/dist/sessions.js CHANGED
@@ -1,7 +1,23 @@
1
- import { stat } from "node:fs/promises";
1
+ import { mkdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
2
3
  import { randomBytes } from "node:crypto";
4
+ import { REMOGENT_DIR } from "./identity.js";
3
5
  import { ProtocolError, } from "./protocol.js";
4
6
  import { autoTitle, customTitle } from "./title.js";
7
+ /**
8
+ * Folder a chat starts in when the phone does not choose one (it sends "." or an empty string).
9
+ * Never the host process's own cwd: the autostart service runs from a system directory such as
10
+ * C:\Windows\System32, which is a poor (and for some CLIs, unusable) workspace.
11
+ */
12
+ export const DEFAULT_PROJECTS_DIR = join(REMOGENT_DIR, "projects");
13
+ /** "." / "" mean "host default"; any other value is used as given. */
14
+ export function isHostDefaultCwd(cwd) {
15
+ const c = cwd.trim();
16
+ return c === "" || c === ".";
17
+ }
18
+ /** How long a model list is reused before the engine CLI is asked again. */
19
+ const MODELS_TTL_MS = 10 * 60_000;
20
+ const MAX_MODELS = 100;
5
21
  class Session {
6
22
  id;
7
23
  engine;
@@ -9,6 +25,8 @@ class Session {
9
25
  permissionMode;
10
26
  status = "idle";
11
27
  title;
28
+ /** Chosen model id (engine-specific); undefined = the engine's own default. */
29
+ model;
12
30
  createdAt;
13
31
  updatedAt;
14
32
  events = [];
@@ -43,6 +61,7 @@ class Session {
43
61
  cwd: this.cwd,
44
62
  status: this.status,
45
63
  ...(this.title ? { title: this.title } : {}),
64
+ ...(this.model ? { model: this.model } : {}),
46
65
  createdAt: this.createdAt,
47
66
  updatedAt: this.updatedAt,
48
67
  lastSeq: this.lastSeq,
@@ -55,6 +74,8 @@ export class SessionManager {
55
74
  adapters = new Map();
56
75
  /** Viewers that want `session_updated` pushes (list screens). */
57
76
  listeners = new Set();
77
+ modelCache = new Map();
78
+ modelFetches = new Map();
58
79
  saveTimer;
59
80
  watchdog;
60
81
  stallWarnMs;
@@ -66,7 +87,7 @@ export class SessionManager {
66
87
  */
67
88
  constructor(store, wd = {}) {
68
89
  this.store = store;
69
- this.stallWarnMs = wd.stallWarnMs ?? 60_000;
90
+ this.stallWarnMs = wd.stallWarnMs ?? 120_000;
70
91
  this.stallToolMs = wd.stallToolMs ?? 300_000;
71
92
  this.stallRepeatMs = wd.stallRepeatMs ?? 300_000;
72
93
  this.watchdog = setInterval(() => this.checkStalls(), wd.checkMs ?? 5_000);
@@ -94,6 +115,8 @@ export class SessionManager {
94
115
  s.updatedAt = p.updatedAt;
95
116
  if (p.title)
96
117
  s.title = p.title;
118
+ if (p.model)
119
+ s.model = p.model;
97
120
  s.events = p.events;
98
121
  s.permSeq = p.permSeq;
99
122
  s.status = p.status;
@@ -102,7 +125,7 @@ export class SessionManager {
102
125
  let resumed = false;
103
126
  if (p.status !== "completed" && adapter?.resumable && p.engineSessionId) {
104
127
  try {
105
- s.handle = await adapter.start({ cwd: p.cwd, permissionMode: p.permissionMode, resumeId: p.engineSessionId }, this.sinkFor(s));
128
+ s.handle = await adapter.start({ cwd: p.cwd, permissionMode: p.permissionMode, resumeId: p.engineSessionId, ...(p.model ? { model: p.model } : {}) }, this.sinkFor(s));
106
129
  resumed = true;
107
130
  }
108
131
  catch {
@@ -158,21 +181,79 @@ export class SessionManager {
158
181
  throw new ProtocolError("unknown_session", `unknown session "${sessionId}"`, reqId);
159
182
  return s;
160
183
  }
161
- async create(engine, cwd, permissionMode, reqId) {
184
+ /**
185
+ * The models an engine CLI offers, read from the CLI itself (cached for a few minutes; concurrent asks share one
186
+ * lookup). `defaultId` is the one the CLI picks when none is chosen. An engine without model support returns [].
187
+ */
188
+ async models(engine, reqId) {
162
189
  const adapter = this.adapters.get(engine);
163
190
  if (!adapter)
164
191
  throw new ProtocolError("engine_unavailable", `engine "${engine}" is not available on this host`, reqId);
192
+ if (!adapter.listModels)
193
+ return { models: [] };
194
+ const models = await this.modelList(engine, adapter, reqId);
195
+ const def = models.find((m) => m.isDefault);
196
+ return { models, ...(def ? { defaultId: def.id } : {}) };
197
+ }
198
+ async modelList(engine, adapter, reqId) {
199
+ const hit = this.modelCache.get(engine);
200
+ if (hit && Date.now() - hit.at < MODELS_TTL_MS)
201
+ return hit.list;
202
+ let p = this.modelFetches.get(engine);
203
+ if (!p) {
204
+ p = adapter.listModels().then((raw) => {
205
+ const seen = new Set();
206
+ const list = raw
207
+ .filter((m) => typeof m.id === "string" && m.id && typeof m.name === "string" && !seen.has(m.id) && !!seen.add(m.id))
208
+ .slice(0, MAX_MODELS);
209
+ this.modelCache.set(engine, { at: Date.now(), list });
210
+ return list;
211
+ });
212
+ this.modelFetches.set(engine, p);
213
+ const done = () => {
214
+ if (this.modelFetches.get(engine) === p)
215
+ this.modelFetches.delete(engine);
216
+ };
217
+ p.then(done, done);
218
+ }
165
219
  try {
220
+ return await p;
221
+ }
222
+ catch (e) {
223
+ throw new ProtocolError("engine_failed", `could not read the model list: ${e instanceof Error ? e.message : String(e)}`, reqId);
224
+ }
225
+ }
226
+ /** Throws unless `model` is one the engine CLI itself offers (some CLIs accept any id and fail only on the first turn). */
227
+ async checkModel(engine, adapter, model, reqId) {
228
+ if (!adapter.listModels)
229
+ throw new ProtocolError("invalid_message", `engine "${engine}" does not support model selection`, reqId);
230
+ const list = await this.modelList(engine, adapter, reqId);
231
+ if (!list.some((m) => m.id === model))
232
+ throw new ProtocolError("invalid_message", `unknown model "${model}" for ${engine}`, reqId);
233
+ }
234
+ async create(engine, cwd, permissionMode, reqId, model) {
235
+ const adapter = this.adapters.get(engine);
236
+ if (!adapter)
237
+ throw new ProtocolError("engine_unavailable", `engine "${engine}" is not available on this host`, reqId);
238
+ try {
239
+ if (isHostDefaultCwd(cwd)) {
240
+ cwd = DEFAULT_PROJECTS_DIR;
241
+ await mkdir(cwd, { recursive: true });
242
+ }
166
243
  if (!(await stat(cwd)).isDirectory())
167
244
  throw new Error("not a directory");
168
245
  }
169
246
  catch {
170
247
  throw new ProtocolError("cwd_denied", "cwd does not exist or is not a directory", reqId);
171
248
  }
249
+ if (model)
250
+ await this.checkModel(engine, adapter, model, reqId);
172
251
  const s = new Session("s" + randomBytes(6).toString("hex"), engine, cwd, permissionMode);
252
+ if (model)
253
+ s.model = model;
173
254
  this.sessions.set(s.id, s);
174
255
  try {
175
- s.handle = await adapter.start({ cwd, permissionMode }, this.sinkFor(s));
256
+ s.handle = await adapter.start({ cwd, permissionMode, ...(model ? { model } : {}) }, this.sinkFor(s));
176
257
  }
177
258
  catch (e) {
178
259
  this.sessions.delete(s.id);
@@ -265,6 +346,34 @@ export class SessionManager {
265
346
  this.pushUpdated(s);
266
347
  this.scheduleSave();
267
348
  }
349
+ /**
350
+ * Switches the model of a chat between turns. The id must be one the engine lists. The change applies from the next
351
+ * prompt on, is kept in the store, and is pushed to list listeners as `session_updated`.
352
+ */
353
+ async setModel(sessionId, model, reqId) {
354
+ const s = this.get(sessionId, reqId);
355
+ if (s.status === "completed" || !s.handle)
356
+ throw new ProtocolError("invalid_message", "session is closed", reqId);
357
+ if (s.status === "active")
358
+ throw new ProtocolError("invalid_message", "a turn is already running", reqId);
359
+ const adapter = this.adapters.get(s.engine);
360
+ if (!adapter || !s.handle.setModel)
361
+ throw new ProtocolError("invalid_message", `${s.engine} cannot switch models`, reqId);
362
+ await this.checkModel(s.engine, adapter, model, reqId);
363
+ if (s.model === model)
364
+ return;
365
+ try {
366
+ await s.handle.setModel(model);
367
+ }
368
+ catch (e) {
369
+ throw new ProtocolError("engine_failed", e instanceof Error ? e.message : String(e), reqId);
370
+ }
371
+ if (s.deleted)
372
+ return;
373
+ s.model = model;
374
+ this.pushUpdated(s);
375
+ this.scheduleSave();
376
+ }
268
377
  respond(sessionId, requestId, decision) {
269
378
  const s = this.get(sessionId);
270
379
  const p = s.pending.get(requestId);
@@ -378,6 +487,7 @@ function toPersisted(s) {
378
487
  cwd: s.cwd,
379
488
  permissionMode: s.permissionMode,
380
489
  ...(s.title ? { title: s.title } : {}),
490
+ ...(s.model ? { model: s.model } : {}),
381
491
  createdAt: s.createdAt,
382
492
  updatedAt: s.updatedAt,
383
493
  status: s.status,
package/package.json CHANGED
@@ -1,10 +1,19 @@
1
1
  {
2
2
  "name": "@infomity/remogent",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Remogent host: drive Claude Code, Gemini CLI and Codex CLI on your PC from the Remogent Android app. No cloud relay, no account.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
- "keywords": ["remogent", "claude-code", "gemini-cli", "codex", "remote", "android", "ai-agent", "cli"],
7
+ "keywords": [
8
+ "remogent",
9
+ "claude-code",
10
+ "gemini-cli",
11
+ "codex",
12
+ "remote",
13
+ "android",
14
+ "ai-agent",
15
+ "cli"
16
+ ],
8
17
  "homepage": "https://infomity.github.io/remogent/",
9
18
  "repository": {
10
19
  "type": "git",