@infomity/remogent 0.2.0 → 0.2.1
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 +225 -201
- package/dist/adapters/claude.js +128 -3
- package/dist/adapters/codex.js +85 -3
- package/dist/adapters/gemini.js +59 -1
- package/dist/protocol.js +28 -1
- package/dist/server.js +10 -1
- package/dist/sessions.js +97 -4
- package/package.json +11 -2
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="
|
|
10
|
-
<img src="
|
|
11
|
-
<img src="
|
|
12
|
-
<img src="
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
```bash
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
The
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
3.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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) · adaptive vector: [`docs/architecture.svg`](docs/architecture.svg) · 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
|
+
|
package/dist/adapters/claude.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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;
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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
|
-
|
|
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", {
|
|
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;
|
package/dist/adapters/gemini.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
@@ -2,6 +2,9 @@ import { stat } from "node:fs/promises";
|
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { ProtocolError, } from "./protocol.js";
|
|
4
4
|
import { autoTitle, customTitle } from "./title.js";
|
|
5
|
+
/** How long a model list is reused before the engine CLI is asked again. */
|
|
6
|
+
const MODELS_TTL_MS = 10 * 60_000;
|
|
7
|
+
const MAX_MODELS = 100;
|
|
5
8
|
class Session {
|
|
6
9
|
id;
|
|
7
10
|
engine;
|
|
@@ -9,6 +12,8 @@ class Session {
|
|
|
9
12
|
permissionMode;
|
|
10
13
|
status = "idle";
|
|
11
14
|
title;
|
|
15
|
+
/** Chosen model id (engine-specific); undefined = the engine's own default. */
|
|
16
|
+
model;
|
|
12
17
|
createdAt;
|
|
13
18
|
updatedAt;
|
|
14
19
|
events = [];
|
|
@@ -43,6 +48,7 @@ class Session {
|
|
|
43
48
|
cwd: this.cwd,
|
|
44
49
|
status: this.status,
|
|
45
50
|
...(this.title ? { title: this.title } : {}),
|
|
51
|
+
...(this.model ? { model: this.model } : {}),
|
|
46
52
|
createdAt: this.createdAt,
|
|
47
53
|
updatedAt: this.updatedAt,
|
|
48
54
|
lastSeq: this.lastSeq,
|
|
@@ -55,6 +61,8 @@ export class SessionManager {
|
|
|
55
61
|
adapters = new Map();
|
|
56
62
|
/** Viewers that want `session_updated` pushes (list screens). */
|
|
57
63
|
listeners = new Set();
|
|
64
|
+
modelCache = new Map();
|
|
65
|
+
modelFetches = new Map();
|
|
58
66
|
saveTimer;
|
|
59
67
|
watchdog;
|
|
60
68
|
stallWarnMs;
|
|
@@ -66,7 +74,7 @@ export class SessionManager {
|
|
|
66
74
|
*/
|
|
67
75
|
constructor(store, wd = {}) {
|
|
68
76
|
this.store = store;
|
|
69
|
-
this.stallWarnMs = wd.stallWarnMs ??
|
|
77
|
+
this.stallWarnMs = wd.stallWarnMs ?? 120_000;
|
|
70
78
|
this.stallToolMs = wd.stallToolMs ?? 300_000;
|
|
71
79
|
this.stallRepeatMs = wd.stallRepeatMs ?? 300_000;
|
|
72
80
|
this.watchdog = setInterval(() => this.checkStalls(), wd.checkMs ?? 5_000);
|
|
@@ -94,6 +102,8 @@ export class SessionManager {
|
|
|
94
102
|
s.updatedAt = p.updatedAt;
|
|
95
103
|
if (p.title)
|
|
96
104
|
s.title = p.title;
|
|
105
|
+
if (p.model)
|
|
106
|
+
s.model = p.model;
|
|
97
107
|
s.events = p.events;
|
|
98
108
|
s.permSeq = p.permSeq;
|
|
99
109
|
s.status = p.status;
|
|
@@ -102,7 +112,7 @@ export class SessionManager {
|
|
|
102
112
|
let resumed = false;
|
|
103
113
|
if (p.status !== "completed" && adapter?.resumable && p.engineSessionId) {
|
|
104
114
|
try {
|
|
105
|
-
s.handle = await adapter.start({ cwd: p.cwd, permissionMode: p.permissionMode, resumeId: p.engineSessionId }, this.sinkFor(s));
|
|
115
|
+
s.handle = await adapter.start({ cwd: p.cwd, permissionMode: p.permissionMode, resumeId: p.engineSessionId, ...(p.model ? { model: p.model } : {}) }, this.sinkFor(s));
|
|
106
116
|
resumed = true;
|
|
107
117
|
}
|
|
108
118
|
catch {
|
|
@@ -158,7 +168,57 @@ export class SessionManager {
|
|
|
158
168
|
throw new ProtocolError("unknown_session", `unknown session "${sessionId}"`, reqId);
|
|
159
169
|
return s;
|
|
160
170
|
}
|
|
161
|
-
|
|
171
|
+
/**
|
|
172
|
+
* The models an engine CLI offers, read from the CLI itself (cached for a few minutes; concurrent asks share one
|
|
173
|
+
* lookup). `defaultId` is the one the CLI picks when none is chosen. An engine without model support returns [].
|
|
174
|
+
*/
|
|
175
|
+
async models(engine, reqId) {
|
|
176
|
+
const adapter = this.adapters.get(engine);
|
|
177
|
+
if (!adapter)
|
|
178
|
+
throw new ProtocolError("engine_unavailable", `engine "${engine}" is not available on this host`, reqId);
|
|
179
|
+
if (!adapter.listModels)
|
|
180
|
+
return { models: [] };
|
|
181
|
+
const models = await this.modelList(engine, adapter, reqId);
|
|
182
|
+
const def = models.find((m) => m.isDefault);
|
|
183
|
+
return { models, ...(def ? { defaultId: def.id } : {}) };
|
|
184
|
+
}
|
|
185
|
+
async modelList(engine, adapter, reqId) {
|
|
186
|
+
const hit = this.modelCache.get(engine);
|
|
187
|
+
if (hit && Date.now() - hit.at < MODELS_TTL_MS)
|
|
188
|
+
return hit.list;
|
|
189
|
+
let p = this.modelFetches.get(engine);
|
|
190
|
+
if (!p) {
|
|
191
|
+
p = adapter.listModels().then((raw) => {
|
|
192
|
+
const seen = new Set();
|
|
193
|
+
const list = raw
|
|
194
|
+
.filter((m) => typeof m.id === "string" && m.id && typeof m.name === "string" && !seen.has(m.id) && !!seen.add(m.id))
|
|
195
|
+
.slice(0, MAX_MODELS);
|
|
196
|
+
this.modelCache.set(engine, { at: Date.now(), list });
|
|
197
|
+
return list;
|
|
198
|
+
});
|
|
199
|
+
this.modelFetches.set(engine, p);
|
|
200
|
+
const done = () => {
|
|
201
|
+
if (this.modelFetches.get(engine) === p)
|
|
202
|
+
this.modelFetches.delete(engine);
|
|
203
|
+
};
|
|
204
|
+
p.then(done, done);
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
return await p;
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
throw new ProtocolError("engine_failed", `could not read the model list: ${e instanceof Error ? e.message : String(e)}`, reqId);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Throws unless `model` is one the engine CLI itself offers (some CLIs accept any id and fail only on the first turn). */
|
|
214
|
+
async checkModel(engine, adapter, model, reqId) {
|
|
215
|
+
if (!adapter.listModels)
|
|
216
|
+
throw new ProtocolError("invalid_message", `engine "${engine}" does not support model selection`, reqId);
|
|
217
|
+
const list = await this.modelList(engine, adapter, reqId);
|
|
218
|
+
if (!list.some((m) => m.id === model))
|
|
219
|
+
throw new ProtocolError("invalid_message", `unknown model "${model}" for ${engine}`, reqId);
|
|
220
|
+
}
|
|
221
|
+
async create(engine, cwd, permissionMode, reqId, model) {
|
|
162
222
|
const adapter = this.adapters.get(engine);
|
|
163
223
|
if (!adapter)
|
|
164
224
|
throw new ProtocolError("engine_unavailable", `engine "${engine}" is not available on this host`, reqId);
|
|
@@ -169,10 +229,14 @@ export class SessionManager {
|
|
|
169
229
|
catch {
|
|
170
230
|
throw new ProtocolError("cwd_denied", "cwd does not exist or is not a directory", reqId);
|
|
171
231
|
}
|
|
232
|
+
if (model)
|
|
233
|
+
await this.checkModel(engine, adapter, model, reqId);
|
|
172
234
|
const s = new Session("s" + randomBytes(6).toString("hex"), engine, cwd, permissionMode);
|
|
235
|
+
if (model)
|
|
236
|
+
s.model = model;
|
|
173
237
|
this.sessions.set(s.id, s);
|
|
174
238
|
try {
|
|
175
|
-
s.handle = await adapter.start({ cwd, permissionMode }, this.sinkFor(s));
|
|
239
|
+
s.handle = await adapter.start({ cwd, permissionMode, ...(model ? { model } : {}) }, this.sinkFor(s));
|
|
176
240
|
}
|
|
177
241
|
catch (e) {
|
|
178
242
|
this.sessions.delete(s.id);
|
|
@@ -265,6 +329,34 @@ export class SessionManager {
|
|
|
265
329
|
this.pushUpdated(s);
|
|
266
330
|
this.scheduleSave();
|
|
267
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* Switches the model of a chat between turns. The id must be one the engine lists. The change applies from the next
|
|
334
|
+
* prompt on, is kept in the store, and is pushed to list listeners as `session_updated`.
|
|
335
|
+
*/
|
|
336
|
+
async setModel(sessionId, model, reqId) {
|
|
337
|
+
const s = this.get(sessionId, reqId);
|
|
338
|
+
if (s.status === "completed" || !s.handle)
|
|
339
|
+
throw new ProtocolError("invalid_message", "session is closed", reqId);
|
|
340
|
+
if (s.status === "active")
|
|
341
|
+
throw new ProtocolError("invalid_message", "a turn is already running", reqId);
|
|
342
|
+
const adapter = this.adapters.get(s.engine);
|
|
343
|
+
if (!adapter || !s.handle.setModel)
|
|
344
|
+
throw new ProtocolError("invalid_message", `${s.engine} cannot switch models`, reqId);
|
|
345
|
+
await this.checkModel(s.engine, adapter, model, reqId);
|
|
346
|
+
if (s.model === model)
|
|
347
|
+
return;
|
|
348
|
+
try {
|
|
349
|
+
await s.handle.setModel(model);
|
|
350
|
+
}
|
|
351
|
+
catch (e) {
|
|
352
|
+
throw new ProtocolError("engine_failed", e instanceof Error ? e.message : String(e), reqId);
|
|
353
|
+
}
|
|
354
|
+
if (s.deleted)
|
|
355
|
+
return;
|
|
356
|
+
s.model = model;
|
|
357
|
+
this.pushUpdated(s);
|
|
358
|
+
this.scheduleSave();
|
|
359
|
+
}
|
|
268
360
|
respond(sessionId, requestId, decision) {
|
|
269
361
|
const s = this.get(sessionId);
|
|
270
362
|
const p = s.pending.get(requestId);
|
|
@@ -378,6 +470,7 @@ function toPersisted(s) {
|
|
|
378
470
|
cwd: s.cwd,
|
|
379
471
|
permissionMode: s.permissionMode,
|
|
380
472
|
...(s.title ? { title: s.title } : {}),
|
|
473
|
+
...(s.model ? { model: s.model } : {}),
|
|
381
474
|
createdAt: s.createdAt,
|
|
382
475
|
updatedAt: s.updatedAt,
|
|
383
476
|
status: s.status,
|
package/package.json
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@infomity/remogent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
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": [
|
|
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",
|