@anusornneal/chat-relay 0.9.0 → 0.9.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,31 +1,33 @@
1
- # chat-relay
1
+ # Chat Relay
2
2
 
3
- Cloudflare Worker + Durable Objects relay that exposes one or more local Windows or macOS agents to ChatGPT through remote MCP.
3
+ Remote MCP relay that lets ChatGPT work with a local Windows or macOS machine through a Cloudflare-hosted relay.
4
4
 
5
- ```
6
- ChatGPT / MCP client
7
- |
8
- | Streamable HTTP MCP
9
- v
10
- Cloudflare Worker
11
- |
12
- +-- Registry Durable Object (users, agents, grants)
13
- |
14
- +-- Relay Durable Object per agent
15
- |
16
- | WebSocket
17
- v
18
- Local Agent
19
- - terminal
20
- - files
21
- - processes
5
+ **Dashboard:** https://chat-relay.anusorn-hank.workers.dev/
6
+
7
+ ## Quick start
8
+
9
+ There are two parts:
10
+
11
+ 1. Connect ChatGPT to the relay.
12
+ 2. Run the local agent on the computer you want ChatGPT to access.
13
+
14
+ ### 1. Connect to ChatGPT
15
+
16
+ Add the production MCP server to ChatGPT:
17
+
18
+ ```text
19
+ https://chat-relay.anusorn-hank.workers.dev/mcp
22
20
  ```
23
21
 
24
- The relay is generic and has no Devflow-specific logic.
22
+ Complete the OAuth sign-in when ChatGPT opens the authorization flow.
25
23
 
26
- ## Quick start
24
+ ChatGPT MCP should connect to the plain `/mcp` endpoint. The legacy `/mcp?key=<USER_TOKEN>` flow remains available only during migration and should not be used for new connections.
25
+
26
+ ChatGPT connects to the cloud relay. To actually access files, terminal, processes, or desktop controls on a computer, that computer must also be running the local agent below.
27
+
28
+ ### 2. Run the local agent with `npx`
27
29
 
28
- The CLI is published on npm. You do **not** need to clone this repository or run `npm install`.
30
+ Requirements: Node.js 20 or newer.
29
31
 
30
32
  From any directory:
31
33
 
@@ -33,321 +35,152 @@ From any directory:
33
35
  npx @anusornneal/chat-relay@latest remote
34
36
  ```
35
37
 
36
- On the first run, Chat Relay opens a browser-based device login. Sign in, approve the computer, then return to the terminal. The CLI saves its credentials in your user profile and connects the local agent automatically.
38
+ On the first run, Chat Relay opens browser-based device authorization. Choose **Continue with Google and authorize computer**, sign in with the same Google account used for the ChatGPT connector, then return to the terminal. Device sign-in requires configured Google OAuth and does not accept a local username/password.
37
39
 
38
- Later, use the same command from anywhere:
40
+ The CLI saves the device credentials in your user profile, so later you can reconnect with the same command:
39
41
 
40
42
  ```bash
41
43
  npx @anusornneal/chat-relay@latest remote
42
44
  ```
43
45
 
44
- A successful connection looks like:
45
-
46
- ```text
47
- Chat Relay Remote
48
- -----------------
49
- Agent: Primary PC (default)
50
- Terminal: enabled
51
- Desktop: disabled
52
- Agent connected
53
- ```
54
-
55
- ## Zero-checkout CLI
56
-
57
- Other commands:
46
+ Enable desktop screenshot, mouse, and keyboard access:
58
47
 
59
48
  ```bash
60
- npx @anusornneal/chat-relay@latest login
61
- npx @anusornneal/chat-relay@latest status
62
- npx @anusornneal/chat-relay@latest logout
49
+ npx @anusornneal/chat-relay@latest remote --desktop
63
50
  ```
64
51
 
65
- On Windows the config defaults to `%LOCALAPPDATA%\chat-relay\config.json`. On macOS/Linux it uses `$XDG_CONFIG_HOME/chat-relay/config.json` or `~/.config/chat-relay/config.json`. Set `CHAT_RELAY_HOME` to override the location.
66
-
67
- Useful options:
52
+ Limit filesystem access to a specific root:
68
53
 
69
54
  ```bash
70
- chat-relay remote --root "C:\Users\you\Projects"
71
- chat-relay login --name "Work PC"
72
- chat-relay login --no-open
73
- chat-relay remote --desktop
74
- chat-relay remote --no-desktop
55
+ npx @anusornneal/chat-relay@latest remote --root "C:\Users\you\Projects"
75
56
  ```
76
57
 
77
- The published package locates its bundled local agent relative to the package itself, so commands work from any working directory. Existing repository users can still run `npm start`; legacy `.dev.vars` credentials are migrated once into the user-level config.
78
- ## Device lifecycle and recovery
79
-
80
- - `chat-relay status` shows the signed-in account, readable device name/id, relay URL, access state, online/offline state, last-seen time, and granted scopes without printing secrets.
81
- - One account can own multiple PCs. Each PC keeps its own agent id and credential, so devices remain independently visible and revocable.
82
- - Admins can rename a device from the dashboard without changing its agent id or grants.
83
- - Retiring a device invalidates its current machine credential and disconnects the live agent. That credential cannot reconnect until the device is authorized again.
84
- - Recovery after retirement does not require copying tokens: run `chat-relay login --force`, complete browser authorization, then run `chat-relay remote`.
85
- - A different account cannot reclaim another owner's active or retired agent id; a colliding login receives a separate device identity instead.
86
-
87
-
88
- ## Agent protocol and capabilities
89
-
90
- Local agents send a lightweight hello handshake when their WebSocket connects. The handshake reports the protocol version, package version, platform/architecture, and the capabilities that are actually enabled on that machine. Feature routing should use the advertised capability list rather than inferring support from the package version alone.
91
-
92
- Protocol v1 keeps legacy agents backward compatible: a connected agent that does not send hello metadata can still use the pre-handshake behavior. An agent that explicitly advertises an unsupported protocol is disconnected with a clear incompatibility reason instead of failing later on an unrelated tool call or entering a restart loop.
93
-
94
- ## Dashboard administrator sessions
95
-
96
- Browser dashboard access uses the same Chat Relay username/password accounts but requires an explicit global administrator entitlement. Successful dashboard login creates a short-lived opaque server-side session; the browser receives an HttpOnly, Secure, SameSite=Strict cookie plus a CSRF token for state-changing requests. `ADMIN_TOKEN` remains an operator/CLI recovery credential and must never be embedded in dashboard JavaScript, browser storage, or URLs.
97
- ## Authentication and multi-user model
98
-
99
- - Browser/device login uses a short-lived device code. Raw user or admin tokens are not typed into the CLI.
100
- - New accounts use a unique login plus password. Passwords are stored only as salted PBKDF2-SHA256 hashes.
101
- - Device start/approval requests and failed password attempts are rate-limited.
102
- - CLI user sessions are opaque random tokens stored server-side only as hashes and expire after 90 days.
103
- - Re-authentication revokes the previous CLI session when possible.
104
- - Each local machine has an independent `agentId` and agent token; agent tokens are stored server-side only as hashes.
105
- - Agent ownership is enforced before an existing machine identity can be reused, preventing shared users from rotating another owner's agent credential.
106
- - Grants map users to agents with scopes: `read`, `write`, `terminal`, `process`, `desktop_read`, `desktop_control`, or `*`. Explicit legacy grants do not gain desktop access automatically.
107
- - Logging out revokes the local user session and the owning machine credential.
108
- - `ADMIN_TOKEN` remains separate and protects administration routes.
109
- - The existing legacy owner token remains supported only as transitional compatibility while OAuth becomes the normal ChatGPT MCP path.
110
-
111
- ChatGPT MCP should connect to the plain `/mcp` endpoint. Compatible clients discover OAuth 2.1 automatically, then use authorization-code login with PKCE S256. OAuth access tokens are audience-bound to `/mcp`; `offline_access` issues rotating refresh tokens. Dynamic client registration and protected-resource/authorization-server discovery are exposed for compatible MCP clients. The legacy `/mcp?key=<USER_TOKEN>` flow remains available only during migration. Browser/device login is for the zero-checkout local CLI and creates the same registry user/agent model used by OAuth.
112
-
113
- ## MCP tools
114
-
115
- Identity and agent routing:
116
- - `whoami`
117
- - `list_agents`
118
- - `ping_agent`
119
- - `get_config`
120
- - `get_recent_tool_calls`
121
-
122
- Filesystem:
123
- - `stat_path`
124
- - `list_directory`
125
- - `read_file` - bounded by bytes; returns `truncated` and `nextOffset` when more lines remain
126
- - `read_multiple_files` - bounded aggregate response; supports string paths or per-file `{ path, offset, length, maxBytes }` entries plus `maxTotalBytes`
127
- - `start_search`
128
- - `get_more_search_results`
129
- - `write_file`
130
- - `edit_block`
131
- - `create_directory`
132
- - `move_path`
133
- - `delete_path`
134
-
135
- Processes:
136
- - `list_processes`
137
- - `kill_process`
138
-
139
- Terminal:
140
- - `terminal_exec`
141
- - `terminal_batch_start`
142
- - `terminal_batch_status`
143
- - `terminal_batch_read`
144
- - `terminal_batch_cancel`
145
- - `terminal_start`
146
- - `terminal_start_shell`
147
- - `terminal_read`
148
- - `terminal_write`
149
- - `terminal_list`
150
- - `terminal_kill`
151
-
152
- Desktop (Windows/macOS, opt-in):
153
- - `screenshot` - returns a bounded MCP image content block plus coordinate metadata
154
- - `mouse_click` - left/right/middle single or double click in desktop coordinates
155
- - `keyboard_input` - Unicode text or named key/modifier chord
156
-
157
- Desktop-Commander-compatible aliases:
158
- - `start_process`
159
- - `read_process_output`
160
- - `interact_with_process`
161
- - `list_sessions`
162
- - `force_terminate`
58
+ ## `npx` commands
163
59
 
164
- ## Administration
60
+ | Command | What it does |
61
+ | --- | --- |
62
+ | `npx @anusornneal/chat-relay@latest remote` | Connects this computer to Chat Relay and keeps the local agent running. Enables the capabilities allowed for this device, such as files, terminal, and processes. |
63
+ | `npx @anusornneal/chat-relay@latest remote --desktop` | Starts the agent with desktop screenshot/input support enabled. Desktop permissions must also be granted by the relay. |
64
+ | `npx @anusornneal/chat-relay@latest login` | Signs in and registers this computer without starting the long-running agent. |
65
+ | `npx @anusornneal/chat-relay@latest login --force` | Forces a fresh sign-in and device authorization. Useful when credentials were revoked or need to be replaced. |
66
+ | `npx @anusornneal/chat-relay@latest status` | Shows the signed-in account, device, relay URL, online state, scopes, agent version, protocol compatibility, lifecycle state, allowed roots, and desktop state. |
67
+ | `npx @anusornneal/chat-relay@latest drain` | Stops accepting new long-running work so active work can finish before a restart. |
68
+ | `npx @anusornneal/chat-relay@latest resume` | Cancels drain mode and resumes normal work admission. |
69
+ | `npx @anusornneal/chat-relay@latest restart` | Restarts the local agent after drain has completed and the agent is ready to restart. |
70
+ | `npx @anusornneal/chat-relay@latest logout` | Revokes this computer login and removes its local credentials. |
71
+ | `npx @anusornneal/chat-relay@latest help` | Shows CLI usage and available options. |
165
72
 
166
- Administration is intentionally separate from MCP. Set the Cloudflare `ADMIN_TOKEN` secret, keep the same value in the local ignored `.dev.vars` on the administrator machine, and use:
73
+ Useful options:
167
74
 
168
- ```
169
- npm run admin -- state
170
- npm run admin -- bootstrap default "Primary PC"
171
- npm run admin -- create-user "Alice"
172
- npm run admin -- create-agent "Work Laptop" work-laptop
173
- npm run admin -- grant <userId> <agentId> read,write,terminal,process
174
- npm run admin -- grant <userId> <agentId> desktop_read
175
- npm run admin -- grant <userId> <agentId> desktop_read,desktop_control
176
- npm run admin -- revoke <userId> <agentId>
177
- npm run admin -- enable-user <userId> false
178
- $env:CHAT_RELAY_PASSWORD="choose-a-password"; npm run admin -- set-login <userId> <login>
179
- npm run admin -- enable-agent <agentId> false
180
- npm run admin -- rotate-user <userId>
181
- npm run admin -- rotate-agent <agentId>
75
+ ```text
76
+ --root <path> Allowed filesystem root. Use semicolons for multiple roots.
77
+ --name <name> Computer display name.
78
+ --agent-id <id> Stable agent identifier.
79
+ --desktop Enable desktop access.
80
+ --no-desktop Disable desktop access.
81
+ --no-open Do not open the login browser automatically.
82
+ --force Force a new login.
83
+ --relay <url> Use another Chat Relay deployment.
182
84
  ```
183
85
 
184
- Create and rotate commands return the new raw token once. Store it on the corresponding client/agent; the registry retains only its hash.
185
-
186
- The initial `bootstrap` migrates the legacy `CALLER_TOKEN` and `AGENT_TOKEN` secrets into an owner user and the default agent. To move that existing owner to OAuth without creating a duplicate account, set `CHAT_RELAY_PASSWORD` in the administrator shell and run `npm run admin -- set-login owner <login>`. This attaches login credentials to the same owner user, preserving its grants and agent ownership.
187
-
188
- ## Worker routes
189
-
190
- | Route | Authentication | Purpose |
191
- | --- | --- | --- |
192
- | `GET /health` | none | Worker health |
193
- | `GET /.well-known/oauth-protected-resource[/mcp]` | none | MCP protected-resource metadata |
194
- | `GET /.well-known/oauth-authorization-server` | none | OAuth authorization-server metadata |
195
- | `POST /register` | none | Dynamic registration for public PKCE clients |
196
- | `GET/POST /authorize` | login/password | OAuth authorization-code sign-in |
197
- | `POST /token` | public client + PKCE/refresh token | Access/refresh token exchange |
198
- | `POST /auth/device/start` | none | Start CLI device authorization |
199
- | `GET /device?user_code=<code>` | none | Browser sign-in/approval page |
200
- | `POST /auth/device/approve` | login/password + device code | Approve or create a user account |
201
- | `POST /auth/device/token` | device code | Exchange approved device code for session/agent credentials |
202
- | `GET /auth/me` | user Bearer token | Current user and permitted agents |
203
- | `POST /auth/session/revoke` | user Bearer token | Revoke only the current user session |
204
- | `POST /auth/logout` | user Bearer token | Revoke local session and owning agent credential |
205
- | `POST /mcp` | OAuth Bearer token | Streamable HTTP MCP (normal path) |
206
- | `POST /mcp?key=<user token>` | legacy user token | Transitional Streamable HTTP MCP compatibility |
207
- | `GET /agent?agentId=<id>` | agent Bearer token | Local agent WebSocket |
208
- | `GET /status?agentId=<id>` | user token | Agent online status |
209
- | `POST /relay?agentId=<id>` | user token | Direct JSON relay with scope checks |
210
- | `/admin/*` | admin Bearer token | User/agent/grant administration |
211
-
212
- ## Desktop access (Windows/macOS, opt-in)
213
-
214
- Desktop interaction is disabled by default and requires two independent gates:
215
-
216
- 1. Enable the local agent with `chat-relay remote --desktop` or `DESKTOP_ENABLED=1`. The CLI persists this setting locally. Use `--no-desktop` to disable it again.
217
- 2. Grant `desktop_read` for screenshots and/or `desktop_control` for mouse/keyboard input. Generic read/write/terminal/process scopes do not imply desktop access.
218
-
219
- - `screenshot` captures the selected interactive display on Windows or macOS, scales/compresses it to a bounded JPEG, and returns it as an MCP image block. The accompanying metadata contains image size, desktop origin/size, and scale factors for converting screenshot pixels to desktop coordinates.
220
- - `mouse_click` accepts integer desktop x/y coordinates, button `left|right|middle`, and click count 1 or 2. Invalid/out-of-bounds input is rejected rather than coerced.
221
- - `keyboard_input` accepts either Unicode `text` or one named `key` with optional Ctrl/Alt/Shift/Win modifiers. Text and key cannot be supplied together.
222
- - `desktop_step` batches 1-20 actions in one local round trip, compacts adjacent text/waits, and uses adaptive settle delays. It does not capture by default; set `captureAfter: true` when a post-action screenshot is required. Timing metadata separates local queue, input, wait/settle, capture, and encode costs.
223
- - Windows uses the persistent PowerShell desktop worker. macOS uses built-in `screencapture`, `sips`, `osascript`/JXA, CoreGraphics, and System Events; no extra npm/native dependency is required.
224
- - macOS desktop capture requires Screen Recording permission for the terminal/Node process, while mouse, keyboard, and window control require Accessibility permission. Missing permissions return controlled errors instead of crashing the reconnect loop.
225
- - Linux desktop control is not implemented yet and returns `unsupported_platform`.
226
- - Screenshot bytes, typed text, key chords, and click coordinates are not stored in recentCalls. Only action/timing/success metadata is retained there.
227
- - This card does not add streaming video, OCR, remote-desktop viewer UI, clipboard sync, drag-and-drop, app-specific automation, or Session 0/service automation.
228
-
229
- ## Public plugin review
230
-
231
- Public ChatGPT onboarding uses the plain production `/mcp` URL and OAuth; the legacy `?key=` route is migration compatibility only. Submission/reviewer requirements, permission boundaries, privacy/retention behavior, domain-verification setup, positive/negative test cases, and clean-room demo steps are maintained in `docs/plugin-review.md`.
232
-
233
- The OpenAI domain verification token is served at `/.well-known/openai-apps-challenge` when `OPENAI_APPS_CHALLENGE` is configured. Keep reviewer credentials and challenge values separate from production administrator secrets.
234
- ## Runtime limits and controls
235
-
236
- - Per-user durable rate/quota controls are available before local-agent dispatch, but both are disabled by default (`rateLimit=0`, `dailyCallQuota=0`). Set `USER_RATE_LIMIT_PER_WINDOW` and/or `USER_DAILY_CALL_QUOTA` to a positive value to enable them; `USER_RATE_WINDOW_SECONDS` controls the rate window (1-3600 seconds).
237
- - Admins can inspect or override the effective policy with `GET/POST /admin/api/limits`; POST `{ "resetToDefaults": true }` returns to environment defaults. Rejections return HTTP 429 with `rate_limited` or `quota_exceeded`, `Retry-After`, and reset metadata, and are recorded as bounded usage events without dispatching agent work.
238
- - Relay request/response message: 64 KiB. The local agent caps serialized responses below that transport ceiling and returns `response_too_large` instead of allowing a silent timeout.
239
- - `read_file` defaults to a 32 KiB content budget (max 48 KiB) and exposes deterministic `nextOffset` continuation.
240
- - `read_multiple_files` defaults to a 48 KiB aggregate budget (max 48 KiB). If not all requested files fit, use `nextIndex`; if an individual file is truncated, continue it with that entry's `nextOffset`.
241
- - One-shot terminal command: max 20 seconds.
242
- - `terminal_batch_start` accepts 2-20 jobs in one MCP call to avoid N-call E2E dispatch overhead. Each agent owns an independent FIFO queue and bounded execution pool.
243
- - Batch concurrency defaults to 4 and is capped at 8 per agent. Override with `TERMINAL_BATCH_CONCURRENCY`; queued jobs default to 64 and are capped at 256 via `TERMINAL_BATCH_MAX_QUEUED`.
244
- - Queue overflow fails fast with `queue_full` instead of spawning unbounded processes. Use `terminal_batch_status`, `terminal_batch_read`, and `terminal_batch_cancel` for lifecycle control.
245
- - Batch output retained in memory is capped at 8 KiB per job; completed batches are capped at 64 per agent and also expire after 30 minutes.
246
- - Scaling is horizontal by agent: each connected machine has its own queue/concurrency budget, so additional agents add execution capacity without sharing one local hot queue.
247
- - Persistent terminal sessions: up to 8 running sessions per local agent.
248
- - Terminal output buffer: bounded in memory; completed sessions retained for 30 minutes.
249
- - Filesystem reads/writes are bounded and restricted to configured `ALLOWED_ROOTS`.
250
- - Search skips common heavy directories such as `.git`, `node_modules`, `.gradle`, `.idea`, and `.wrangler`.
251
- - The agent blocks a small set of high-risk system-management commands. This is defense in depth, not a security sandbox.
252
-
253
- ## Audit, retention, and recovery
254
-
255
- - Security/admin mutations are recorded in a dedicated `Audit` Durable Object with actor, action, target, result, and sanitized scalar metadata only. Passwords, tokens, cookies, CSRF values, commands, file content, and raw tool payloads are excluded.
256
- - Raw usage events default to 30-day retention while daily usage aggregates are preserved independently. Set `USAGE_RAW_RETENTION_DAYS` to change raw-event retention.
257
- - Audit events default to 180-day retention. Set `AUDIT_RETENTION_DAYS` to change that window.
258
- - `GET /admin/api/audit` returns bounded audit history. `GET /admin/api/operations` exposes component health and last cleanup state. `POST /admin/api/operations/cleanup` performs bounded idempotent cleanup across registry auth transients, raw usage events, and audit history.
259
- - Cleanup does not intentionally remove active users, grants, agents, live sessions, pending non-expired device authorization, or long-lived usage aggregates.
260
- - Durable Object state is the production source of truth. Git/npm artifacts do not back it up. Before destructive migration or account transfer, export any operator-required identity/config state separately and treat Cloudflare account/Durable Object recovery controls as the infrastructure recovery boundary.
261
- - Cleanup failures are surfaced through the operations endpoint and audit result instead of being silently treated as success.
262
- ## Tests
263
-
264
- The legacy relay integration test remains available:
86
+ ## What ChatGPT can access
265
87
 
266
- ```
267
- npm run dev:test
268
- python test/integration.py
269
- ```
270
-
271
- The full MCP/multi-user smoke test expects a local Worker on port 8795 plus an attached local agent:
88
+ Depending on the device grants and local configuration, Chat Relay can expose:
272
89
 
273
- ```
274
- node test/multiuser-smoke.mjs
275
- ```
90
+ - Filesystem operations inside configured allowed roots.
91
+ - Terminal commands and persistent terminal sessions. Short stateless commands automatically reuse a cross-platform shell pool; stateful commands fall back to isolated execution.
92
+ - Process inspection and termination.
93
+ - Windows/macOS screenshots, mouse input, and keyboard input when desktop access is explicitly enabled.
94
+ - Multiple computers under one account, with per-device routing and permissions.
95
+ - Batched filesystem, terminal, and desktop operations to reduce remote round trips.
276
96
 
277
- It verifies the MCP tool surface, agent routing, filesystem operations, search sessions, process listing, persistent terminals, scope enforcement, multi-agent selection, and token rotation.
97
+ Desktop access is opt-in. Linux desktop control is not currently implemented.
278
98
 
279
- Device/browser login is covered by:
99
+ ## How it works
280
100
 
281
- ```bash
282
- TEST_RELAY_URL=http://127.0.0.1:8796 npm run test:device-auth
101
+ ```text
102
+ ChatGPT
103
+ |
104
+ | Streamable HTTP MCP + OAuth
105
+ v
106
+ Cloudflare Worker
107
+ |
108
+ +-- Registry Durable Object
109
+ |
110
+ +-- Relay Durable Object
111
+ |
112
+ | WebSocket
113
+ v
114
+ Local Agent
115
+ - files
116
+ - terminal
117
+ - processes
118
+ - desktop
283
119
  ```
284
120
 
285
- OAuth discovery, DCR, PKCE, token exchange, refresh rotation, audience binding, MCP Bearer auth, and legacy-key compatibility are covered by:
121
+ ChatGPT talks only to the cloud MCP endpoint. The local computer opens the outbound WebSocket connection to the relay.
286
122
 
287
- ```bash
288
- TEST_RELAY_URL=http://127.0.0.1:8796 npm run test:oauth
289
- ```
123
+ ## Security model
290
124
 
291
- Per-user rate/quota enforcement, isolation, reset-window behavior, disabled policy, rejection observability, and pre-dispatch blocking are covered by:
125
+ - ChatGPT MCP authentication uses OAuth 2.1 authorization code flow with PKCE.
126
+ - Local computers use browser/device authorization; raw agent tokens do not need to be copied manually.
127
+ - Filesystem access is constrained to configured allowed roots.
128
+ - Access is scope-based: read, write, terminal, process, desktop read, and desktop control can be granted separately.
129
+ - Desktop access is disabled by default.
130
+ - Stored credentials are hashed server-side where applicable.
131
+ - Persisted usage/audit telemetry is metadata-only and excludes commands, file contents, screenshots, clipboard contents, and credentials.
292
132
 
293
- ```bash
294
- TEST_RELAY_URL=http://127.0.0.1:8804 npm run test:quota
295
- ```
133
+ Chat Relay is currently intended for small trusted teams rather than an enterprise zero-trust control plane.
296
134
 
297
- Desktop tool contracts, scope separation, disabled/unsupported gates, controlled image mapping, and input validation are covered by:
135
+ ## Local configuration
298
136
 
299
- ```bash
300
- TEST_RELAY_URL=http://127.0.0.1:8807 npm run test:desktop
301
- ```
137
+ Default CLI config locations:
302
138
 
303
- After `npm pack`, the zero-checkout package smoke test installs and runs the tarball from a temporary directory:
139
+ - Windows: `%LOCALAPPDATA%\chat-relay\config.json`
140
+ - macOS/Linux: `$XDG_CONFIG_HOME/chat-relay/config.json` or `~/.config/chat-relay/config.json`
304
141
 
305
- ```bash
306
- CHAT_RELAY_TARBALL=<path-to-tgz> TEST_RELAY_URL=http://127.0.0.1:8796 npm run test:zero-checkout
307
- ```
142
+ Set `CHAT_RELAY_HOME` to override the config directory.
308
143
 
309
- The npm package is configured as `@anusornneal/chat-relay`. Publishing requires an authenticated npm account with access to that scope.
144
+ A single account can own multiple computers. Each computer keeps its own agent identity and credential.
310
145
 
311
- ## CI and npm publishing
146
+ ## Development
312
147
 
313
- GitHub Actions runs package verification on Node 20 and Node 24. A separate integration job starts a local Worker and verifies device auth, OAuth, and the zero-checkout tarball flow.
148
+ Clone the repository only when developing Chat Relay itself. Normal users should use the zero-checkout `npx` flow above.
314
149
 
315
- Before publishing locally:
150
+ Common development commands:
316
151
 
317
152
  ```bash
153
+ npm install
154
+ npm start
155
+ npm run dev
318
156
  npm run verify:publish
319
157
  ```
320
158
 
321
- This checks the npm file set, CLI entrypoint, Worker dry-run build, and production dependency audit. The package whitelist excludes .dev.vars, Worker source, tests, and repository-only files.
159
+ `npm run verify:publish` checks the publish file set, CLI entrypoint, Worker dry-run build, review artifacts, and production dependency audit.
322
160
 
323
- The repository also includes a Publish npm workflow for version tags or manual dispatch. It supports npm trusted publishing through GitHub OIDC and can also use an NPM_TOKEN repository secret when configured.
161
+ ## Administration
162
+
163
+ Administration is separate from the public MCP interface. The admin tooling manages users, agents, grants, lifecycle, quotas, audit data, and operational cleanup.
324
164
 
325
- The first registry publish still requires npm authorization for the @anusornneal scope. After publishing, verify the exact public UX from a clean directory:
165
+ Operator commands are available through:
326
166
 
327
167
  ```bash
328
- npx @anusornneal/chat-relay@latest status
329
- npx @anusornneal/chat-relay@latest remote
168
+ npm run admin -- <command>
330
169
  ```
331
170
 
171
+ Keep `ADMIN_TOKEN` and other deployment secrets out of client configuration and source control.
332
172
 
333
- ### Safe local-agent restart
334
-
335
- Agent updates remain explicit and user-controlled. Check the current agent version, protocol compatibility, and lifecycle with `chat-relay status`.
336
-
337
- Before restarting an active remote, run `chat-relay drain`. Drain mode rejects new long-running work while existing terminal/session control remains available so bounded work can finish or be stopped safely. When `status` reports the lifecycle is ready to restart, run `chat-relay restart`. Use `chat-relay resume` to cancel a drain before restart.
338
-
339
- To install a newer package, stop/restart the wrapper with the desired npm version (for example `npx @anusornneal/chat-relay@latest remote --desktop`). The agent does not self-modify or automatically cross an incompatible protocol version.
340
-
341
-
342
- ### Trusted-team security baseline
343
-
344
- Chat Relay is currently designed for a small trusted internal team, not as an enterprise zero-trust control plane. The intentionally small baseline is:
173
+ ## Platform notes
345
174
 
346
- - Filesystem access is constrained to configured `allowedRoots`; canonical real paths are checked so `..` traversal and symlink/junction escapes are rejected.
347
- - Agent grants continue to enforce the existing read/write/process/terminal/desktop scopes. User and agent credentials can be revoked or rotated without retaining raw tokens server-side.
348
- - Persisted usage/audit/dashboard telemetry is metadata-only. Commands, tool arguments, payloads, stdout/stderr, file contents, clipboard contents, screenshots, credentials, and arbitrary remote messages are excluded.
349
- - Accidental runaway use is bounded by request/response size limits, rate/quota controls, per-capability queues, queue timeouts, terminal batch limits, and bounded history/query windows.
175
+ - Local agent: Windows and macOS.
176
+ - Desktop control: Windows and macOS.
177
+ - Linux desktop control: not implemented.
178
+ - MCP transport: Streamable HTTP over HTTPS.
179
+ - Local agent transport: outbound WebSocket.
180
+ - Node.js: 20+.
350
181
 
351
- Regression coverage is provided by the filesystem boundary checks in `test/multiuser-smoke.mjs`, credential/scope tests in the device/admin/multi-user smoke suites, telemetry privacy checks in `test/ops-smoke.mjs`, and queue/size tests in the terminal and filesystem smoke suites.
182
+ ## More information
352
183
 
353
- Deferred by design: enterprise role hierarchies, approval workflows, device-attestation/trust frameworks, and a general policy engine. Add those only if the deployment threat model changes.
184
+ - Public plugin/reviewer flow: `docs/plugin-review.md`
185
+ - Google sign-in setup: `docs/google-login.md`
186
+ - Source: https://github.com/anusornNeal/chat-relay
@@ -25,6 +25,9 @@ const MAX_RESPONSE_BYTES = 60 * 1024;
25
25
  const terminals = new TerminalManager({
26
26
  batchConcurrency: process.env.TERMINAL_BATCH_CONCURRENCY,
27
27
  maxQueuedJobs: process.env.TERMINAL_BATCH_MAX_QUEUED,
28
+ fastExec: process.env.TERMINAL_FAST_EXEC !== "0",
29
+ fastExecPoolSize: process.env.TERMINAL_FAST_EXEC_POOL_SIZE,
30
+ fastExecIdleMs: process.env.TERMINAL_FAST_EXEC_IDLE_MS,
28
31
  });
29
32
  const scheduler = new CapabilityScheduler({
30
33
  maxQueued: process.env.AGENT_MAX_QUEUED,
@@ -116,6 +119,7 @@ async function handlePayload(payload) {
116
119
  agentName,
117
120
  terminalEnabled,
118
121
  terminalBatch: terminals.getBatchConfig(),
122
+ terminalExec: terminals.getExecConfig(),
119
123
  concurrency: scheduler.snapshot(),
120
124
  desktop: desktop.getConfig(),
121
125
  allowedRoots: files.getRoots(),
@@ -313,6 +317,7 @@ function requireProtocolUpdate(details = {}) {
313
317
  const socket = activeSocket;
314
318
  activeSocket = null;
315
319
  try { socket?.terminate(); } catch {}
320
+ terminals.close();
316
321
  desktop.close();
317
322
  const expected = details.expectedProtocolVersion ?? "current";
318
323
  const received = details.receivedProtocolVersion ?? agentHello.protocolVersion;
@@ -329,6 +334,7 @@ function requireReauthorization(reason = "credential_revoked") {
329
334
  const socket = activeSocket;
330
335
  activeSocket = null;
331
336
  try { socket?.terminate(); } catch {}
337
+ terminals.close();
332
338
  desktop.close();
333
339
  console.error("Agent credential was revoked or rejected.");
334
340
  console.error('Recovery: run "chat-relay login --force", then "chat-relay remote".');
@@ -402,6 +408,7 @@ function connect() {
402
408
  handlerDurationMs,
403
409
  lane: scheduleMeta.lane,
404
410
  ...(timing ? { timing } : {}),
411
+ ...(typeof result?.execMode === "string" ? { execMode: result.execMode } : {}),
405
412
  ok: !(result && typeof result === "object" && result.ok === false),
406
413
  });
407
414
  if (recentCalls.length > 100) recentCalls.shift();
@@ -470,6 +477,7 @@ function restartAgentProcess() {
470
477
  const socket = activeSocket;
471
478
  activeSocket = null;
472
479
  try { socket?.close(1012, "restart_requested"); } catch {}
480
+ terminals.close();
473
481
  desktop.close();
474
482
  setTimeout(() => process.exit(AGENT_RESTART_EXIT_CODE), 50);
475
483
  }
@@ -483,12 +491,13 @@ function shutdown(reason) {
483
491
  const socket = activeSocket;
484
492
  activeSocket = null;
485
493
  try { socket?.close(1000, "client_shutdown"); } catch {}
494
+ terminals.close();
486
495
  desktop.close();
487
496
  setTimeout(() => process.exit(0), 50);
488
497
  }
489
498
 
490
499
  process.once("SIGINT", () => shutdown("SIGINT"));
491
500
  process.once("SIGTERM", () => shutdown("SIGTERM"));
492
- process.once("exit", () => desktop.close());
501
+ process.once("exit", () => { terminals.close(); desktop.close(); });
493
502
 
494
503
  connect();
@@ -13,6 +13,11 @@ const MAX_BATCH_CONCURRENCY = 8;
13
13
  const DEFAULT_BATCH_CONCURRENCY = 4;
14
14
  const DEFAULT_MAX_QUEUED_JOBS = 64;
15
15
  const MAX_QUEUED_JOBS = 256;
16
+ const DEFAULT_FAST_EXEC_POOL_SIZE = 2;
17
+ const MAX_FAST_EXEC_POOL_SIZE = 4;
18
+ const DEFAULT_FAST_EXEC_IDLE_MS = 60 * 1000;
19
+ const MIN_FAST_EXEC_IDLE_MS = 5 * 1000;
20
+ const MAX_FAST_EXEC_IDLE_MS = 10 * 60 * 1000;
16
21
  const MAX_BATCH_READ_CHARS = 24 * 1024;
17
22
  const MAX_JOB_READ_CHARS = 8 * 1024;
18
23
  const MAX_RETAINED_BATCHES = 64;
@@ -59,6 +64,7 @@ export function shellForPlatform(platform, preferredShell) {
59
64
  displayName: "powershell",
60
65
  commandArgs: (command) => ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command],
61
66
  interactiveArgs: ["-NoLogo", "-NoProfile", "-NoExit", "-Command", "-"],
67
+ persistentArgs: ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "-"],
62
68
  };
63
69
  }
64
70
  const file = preferredShell || (platform === "darwin" ? "/bin/zsh" : "/bin/sh");
@@ -67,6 +73,7 @@ export function shellForPlatform(platform, preferredShell) {
67
73
  displayName: path.basename(file),
68
74
  commandArgs: (command) => ["-lc", command],
69
75
  interactiveArgs: ["-l"],
76
+ persistentArgs: platform === "darwin" && path.basename(file) === "zsh" ? ["-f"] : [],
70
77
  };
71
78
  }
72
79
 
@@ -88,6 +95,410 @@ function terminateProcessTree(platform, pid) {
88
95
  }, 150);
89
96
  });
90
97
  }
98
+
99
+ function quotePowerShell(value) {
100
+ return "'" + String(value).replace(/'/g, "''") + "'";
101
+ }
102
+
103
+ function quotePosix(value) {
104
+ return "'" + String(value).replace(/'/g, "'\"'\"'") + "'";
105
+ }
106
+
107
+ function canUsePersistentExec(platform, command) {
108
+ if (typeof command !== "string" || command.includes("\0")) return false;
109
+ if (platform === "win32") {
110
+ return !/\b(exit|stop-process|taskkill(?:\.exe)?|start-process\s+-wait|set-location|push-location|pop-location|set-alias|new-alias|remove-alias|set-variable|new-variable|remove-variable|import-module|remove-module|set-psdebug|set-strictmode)\b|\$(?:env|global|script):/i.test(command);
111
+ }
112
+ return !/(^|[^&])&([^&]|$)/.test(command);
113
+ }
114
+
115
+ function persistentExecScript(platform, command, cwd, marker) {
116
+ const begin = marker + "_BEGIN__";
117
+ const endPrefix = marker + "_END_";
118
+ const endSuffix = "__";
119
+
120
+ if (platform === "win32") {
121
+ const psCommand = quotePowerShell(command);
122
+ const psCwd = quotePowerShell(cwd);
123
+ const psBegin = quotePowerShell(begin);
124
+ const psEndPrefix = quotePowerShell(endPrefix);
125
+ const psEndSuffix = quotePowerShell(endSuffix);
126
+ return [
127
+ "& {",
128
+ " [Console]::Out.Write(" + psBegin + ")",
129
+ " [Console]::Error.Write(" + psBegin + ")",
130
+ " $__ChatRelayOldLocation = (Get-Location).Path",
131
+ " $global:LASTEXITCODE = $null",
132
+ " $__ChatRelayExitCode = 0",
133
+ " try {",
134
+ " Set-Location -LiteralPath " + psCwd,
135
+ " & ([ScriptBlock]::Create(" + psCommand + "))",
136
+ " $__ChatRelaySucceeded = $?",
137
+ " if ($null -ne $global:LASTEXITCODE) {",
138
+ " $__ChatRelayExitCode = [Math]::Max(0, [Math]::Min(2147483647, [int]$global:LASTEXITCODE))",
139
+ " } elseif (-not $__ChatRelaySucceeded) {",
140
+ " $__ChatRelayExitCode = 1",
141
+ " }",
142
+ " } catch {",
143
+ " [Console]::Error.Write(($_ | Out-String))",
144
+ " $__ChatRelayExitCode = 1",
145
+ " } finally {",
146
+ " Set-Location -LiteralPath $__ChatRelayOldLocation -ErrorAction SilentlyContinue",
147
+ " }",
148
+ " $__ChatRelayEnd = " + psEndPrefix + " + [string]$__ChatRelayExitCode + " + psEndSuffix,
149
+ " [Console]::Out.Write($__ChatRelayEnd)",
150
+ " [Console]::Error.Write($__ChatRelayEnd)",
151
+ "}",
152
+ ].join("\n");
153
+ }
154
+
155
+ const shCommand = quotePosix(command);
156
+ const shCwd = quotePosix(cwd);
157
+ const shBegin = quotePosix(begin);
158
+ const shEndPrefix = quotePosix(endPrefix);
159
+ const shEndSuffix = quotePosix(endSuffix);
160
+ return [
161
+ "printf %s " + shBegin,
162
+ "printf %s " + shBegin + " >&2",
163
+ "(",
164
+ " cd -- " + shCwd + " || exit 200",
165
+ " eval " + shCommand,
166
+ ")",
167
+ "__chat_relay_exit_code=$?",
168
+ "printf '%s%s%s' " + shEndPrefix + " \"$__chat_relay_exit_code\" " + shEndSuffix,
169
+ "printf '%s%s%s' " + shEndPrefix + " \"$__chat_relay_exit_code\" " + shEndSuffix + " >&2",
170
+ ].join("\n");
171
+ }
172
+
173
+ class PersistentExecWorker {
174
+ constructor({ platform, shell }) {
175
+ this.platform = platform;
176
+ this.shell = shell;
177
+ this.child = null;
178
+ this.startPromise = null;
179
+ this.current = null;
180
+ this.reserved = false;
181
+ this.idleTimer = null;
182
+ }
183
+
184
+ get busy() {
185
+ return this.reserved || this.current !== null;
186
+ }
187
+
188
+ async run(command, cwd, timeoutMs) {
189
+ if (this.reserved || this.current) {
190
+ return { ok: false, exitCode: 1, stdout: "", stderr: "", error: "fast_exec_busy", fastExecUnavailable: true };
191
+ }
192
+ this.reserved = true;
193
+ try {
194
+ await this.#ensureStarted();
195
+ if (!this.child?.stdin?.writable) {
196
+ return { ok: false, exitCode: 1, stdout: "", stderr: "", error: "fast_exec_unavailable", fastExecUnavailable: true };
197
+ }
198
+
199
+ clearTimeout(this.idleTimer);
200
+ this.idleTimer = null;
201
+ const marker = "__CHAT_RELAY_" + randomUUID().replace(/-/g, "") + "__";
202
+ const begin = marker + "_BEGIN__";
203
+ const endPrefix = marker + "_END_";
204
+ const endSuffix = "__";
205
+
206
+ return await new Promise((resolve) => {
207
+ const current = {
208
+ resolve,
209
+ begin,
210
+ endPrefix,
211
+ endSuffix,
212
+ stdout: "",
213
+ stderr: "",
214
+ stdoutBuffer: "",
215
+ stderrBuffer: "",
216
+ stdoutStarted: false,
217
+ stderrStarted: false,
218
+ stdoutEnded: false,
219
+ stderrEnded: false,
220
+ exitCode: 0,
221
+ startedAt: Date.now(),
222
+ timer: null,
223
+ };
224
+ current.timer = setTimeout(() => {
225
+ if (this.current !== current) return;
226
+ this.current = null;
227
+ void this.#terminate();
228
+ resolve({
229
+ ok: false,
230
+ exitCode: 1,
231
+ stdout: current.stdout,
232
+ stderr: current.stderr,
233
+ error: "Command timed out after " + timeoutMs + "ms",
234
+ execMode: "persistent-shell",
235
+ });
236
+ }, timeoutMs);
237
+ this.current = current;
238
+
239
+ const script = persistentExecScript(this.platform, command, cwd, marker);
240
+ try {
241
+ this.child.stdin.write(script + os.EOL + os.EOL);
242
+ } catch (error) {
243
+ clearTimeout(current.timer);
244
+ this.current = null;
245
+ resolve({
246
+ ok: false,
247
+ exitCode: 1,
248
+ stdout: "",
249
+ stderr: "",
250
+ error: error instanceof Error ? error.message : "fast_exec_write_failed",
251
+ fastExecUnavailable: true,
252
+ });
253
+ }
254
+ });
255
+ } finally {
256
+ this.reserved = false;
257
+ }
258
+ }
259
+
260
+ scheduleIdleClose(idleMs) {
261
+ if (this.busy || !this.child) return;
262
+ clearTimeout(this.idleTimer);
263
+ this.idleTimer = setTimeout(() => {
264
+ if (!this.busy) void this.#terminate();
265
+ }, idleMs);
266
+ this.idleTimer.unref?.();
267
+ }
268
+
269
+ close() {
270
+ clearTimeout(this.idleTimer);
271
+ this.idleTimer = null;
272
+ void this.#terminate();
273
+ }
274
+
275
+ async #ensureStarted() {
276
+ if (this.child && !this.child.killed) return;
277
+ if (this.startPromise) return this.startPromise;
278
+
279
+ this.startPromise = new Promise((resolve, reject) => {
280
+ const child = spawn(this.shell.file, this.shell.persistentArgs || [], {
281
+ stdio: ["pipe", "pipe", "pipe"],
282
+ windowsHide: true,
283
+ detached: this.platform !== "win32",
284
+ });
285
+ const onError = (error) => {
286
+ cleanup();
287
+ if (this.child === child) this.child = null;
288
+ reject(error);
289
+ };
290
+ const onSpawn = () => {
291
+ cleanup();
292
+ this.child = child;
293
+ child.stdout.setEncoding("utf8");
294
+ child.stderr.setEncoding("utf8");
295
+ child.stdout.on("data", (chunk) => this.#onData("stdout", chunk));
296
+ child.stderr.on("data", (chunk) => this.#onData("stderr", chunk));
297
+ child.stdin.on("error", (error) => this.#onPipeError(child, error));
298
+ child.on("exit", (code, signal) => this.#onExit(child, code, signal));
299
+ child.on("error", () => {});
300
+ resolve();
301
+ };
302
+ const cleanup = () => {
303
+ child.off("error", onError);
304
+ child.off("spawn", onSpawn);
305
+ };
306
+ child.once("error", onError);
307
+ child.once("spawn", onSpawn);
308
+ }).finally(() => {
309
+ this.startPromise = null;
310
+ });
311
+
312
+ return this.startPromise;
313
+ }
314
+
315
+ #onData(stream, chunk) {
316
+ const current = this.current;
317
+ if (!current) return;
318
+ const bufferKey = stream + "Buffer";
319
+ const startedKey = stream + "Started";
320
+ const endedKey = stream + "Ended";
321
+ const outputKey = stream;
322
+
323
+ current[bufferKey] += String(chunk);
324
+ if (!current[startedKey]) {
325
+ const beginIndex = current[bufferKey].indexOf(current.begin);
326
+ if (beginIndex < 0) {
327
+ if (current[bufferKey].length > current.begin.length) {
328
+ current[bufferKey] = current[bufferKey].slice(-current.begin.length);
329
+ }
330
+ return;
331
+ }
332
+ current[bufferKey] = current[bufferKey].slice(beginIndex + current.begin.length);
333
+ current[startedKey] = true;
334
+ }
335
+
336
+ const endIndex = current[bufferKey].indexOf(current.endPrefix);
337
+ if (endIndex < 0) {
338
+ if (Buffer.byteLength(current[bufferKey], "utf8") > MAX_EXEC_BUFFER + current.endPrefix.length + 32) {
339
+ this.#overflow(stream, current);
340
+ }
341
+ return;
342
+ }
343
+
344
+ const tail = current[bufferKey].slice(endIndex + current.endPrefix.length);
345
+ const suffixIndex = tail.indexOf(current.endSuffix);
346
+ if (suffixIndex < 0) return;
347
+
348
+ current[outputKey] += current[bufferKey].slice(0, endIndex);
349
+ const parsedCode = Number.parseInt(tail.slice(0, suffixIndex), 10);
350
+ if (Number.isInteger(parsedCode)) current.exitCode = parsedCode;
351
+ current[bufferKey] = tail.slice(suffixIndex + current.endSuffix.length);
352
+ current[endedKey] = true;
353
+
354
+ if (Buffer.byteLength(current[outputKey], "utf8") > MAX_EXEC_BUFFER) {
355
+ this.#overflow(stream, current);
356
+ return;
357
+ }
358
+ this.#completeIfDone(current);
359
+ }
360
+
361
+ #overflow(stream, current) {
362
+ if (this.current !== current) return;
363
+ clearTimeout(current.timer);
364
+ const clipped = clipText(current[stream] + current[stream + "Buffer"], MAX_EXEC_BUFFER);
365
+ current[stream] = clipped.text;
366
+ this.current = null;
367
+ void this.#terminate();
368
+ current.resolve({
369
+ ok: false,
370
+ exitCode: 1,
371
+ stdout: current.stdout,
372
+ stderr: current.stderr,
373
+ error: stream + " maxBuffer exceeded",
374
+ execMode: "persistent-shell",
375
+ });
376
+ }
377
+
378
+ #completeIfDone(current) {
379
+ if (this.current !== current || !current.stdoutEnded || !current.stderrEnded) return;
380
+ clearTimeout(current.timer);
381
+ this.current = null;
382
+ current.resolve({
383
+ ok: current.exitCode === 0,
384
+ exitCode: current.exitCode,
385
+ stdout: current.stdout,
386
+ stderr: current.stderr,
387
+ error: current.exitCode === 0 ? null : "Command failed with exit code " + current.exitCode,
388
+ execMode: "persistent-shell",
389
+ shellMs: Math.max(0, Date.now() - current.startedAt),
390
+ });
391
+ }
392
+
393
+ #onPipeError(child, error) {
394
+ if (this.child !== child) return;
395
+ const current = this.current;
396
+ if (!current) return;
397
+ clearTimeout(current.timer);
398
+ this.current = null;
399
+ void this.#terminate();
400
+ current.resolve({
401
+ ok: false,
402
+ exitCode: 1,
403
+ stdout: current.stdout,
404
+ stderr: current.stderr,
405
+ error: "Persistent shell pipe failed: " + (error instanceof Error ? error.message : String(error)),
406
+ execMode: "persistent-shell",
407
+ });
408
+ }
409
+
410
+ #onExit(child, code, signal) {
411
+ if (this.child !== child) return;
412
+ this.child = null;
413
+ const current = this.current;
414
+ if (!current) return;
415
+ clearTimeout(current.timer);
416
+ this.current = null;
417
+ current.resolve({
418
+ ok: false,
419
+ exitCode: Number.isInteger(code) ? code : 1,
420
+ stdout: current.stdout,
421
+ stderr: current.stderr,
422
+ error: signal ? "Persistent shell exited with signal " + signal : "Persistent shell exited unexpectedly",
423
+ execMode: "persistent-shell",
424
+ });
425
+ }
426
+
427
+ async #terminate() {
428
+ const child = this.child;
429
+ this.child = null;
430
+ if (!child) return;
431
+ try { child.stdin?.end(); } catch {}
432
+ if (child.exitCode !== null || child.signalCode !== null) return;
433
+ await terminateProcessTree(this.platform, child.pid);
434
+ }
435
+ }
436
+
437
+ class PersistentExecPool {
438
+ constructor({ platform, shell, size, idleMs }) {
439
+ this.platform = platform;
440
+ this.shell = shell;
441
+ this.size = size;
442
+ this.idleMs = idleMs;
443
+ this.workers = [];
444
+ this.queue = [];
445
+ }
446
+
447
+ exec(command, cwd, timeoutMs) {
448
+ return new Promise((resolve) => {
449
+ this.queue.push({ command, cwd, timeoutMs, resolve });
450
+ this.#pump();
451
+ });
452
+ }
453
+
454
+ snapshot() {
455
+ return {
456
+ enabled: true,
457
+ size: this.size,
458
+ workers: this.workers.length,
459
+ busy: this.workers.filter((worker) => worker.busy).length,
460
+ queued: this.queue.length,
461
+ idleMs: this.idleMs,
462
+ };
463
+ }
464
+
465
+ close() {
466
+ for (const worker of this.workers) worker.close();
467
+ this.workers.length = 0;
468
+ while (this.queue.length > 0) {
469
+ const job = this.queue.shift();
470
+ job.resolve({ ok: false, exitCode: 1, stdout: "", stderr: "", error: "terminal_closed" });
471
+ }
472
+ }
473
+
474
+ #pump() {
475
+ while (this.queue.length > 0) {
476
+ let worker = this.workers.find((item) => !item.busy);
477
+ if (!worker && this.workers.length < this.size) {
478
+ worker = new PersistentExecWorker({ platform: this.platform, shell: this.shell });
479
+ this.workers.push(worker);
480
+ }
481
+ if (!worker) return;
482
+
483
+ const job = this.queue.shift();
484
+ worker.run(job.command, job.cwd, job.timeoutMs)
485
+ .then(job.resolve)
486
+ .catch((error) => job.resolve({
487
+ ok: false,
488
+ exitCode: 1,
489
+ stdout: "",
490
+ stderr: "",
491
+ error: error instanceof Error ? error.message : "fast_exec_unavailable",
492
+ fastExecUnavailable: true,
493
+ }))
494
+ .finally(() => {
495
+ worker.scheduleIdleClose(this.idleMs);
496
+ this.#pump();
497
+ });
498
+ }
499
+ }
500
+ }
501
+
91
502
  function clipText(value, limit) {
92
503
  const text = typeof value === "string" ? value : "";
93
504
  if (text.length <= limit) return { text, truncated: false };
@@ -128,6 +539,28 @@ export class TerminalManager {
128
539
  1,
129
540
  MAX_QUEUED_JOBS,
130
541
  );
542
+ this.fastExecEnabled = options.fastExec !== false;
543
+ this.fastExecPoolSize = normalizeInteger(
544
+ options.fastExecPoolSize,
545
+ DEFAULT_FAST_EXEC_POOL_SIZE,
546
+ 1,
547
+ MAX_FAST_EXEC_POOL_SIZE,
548
+ );
549
+ this.fastExecIdleMs = normalizeInteger(
550
+ options.fastExecIdleMs,
551
+ DEFAULT_FAST_EXEC_IDLE_MS,
552
+ MIN_FAST_EXEC_IDLE_MS,
553
+ MAX_FAST_EXEC_IDLE_MS,
554
+ );
555
+ this.fastExecPool = this.fastExecEnabled
556
+ ? new PersistentExecPool({
557
+ platform: this.platform,
558
+ shell: this.shell,
559
+ size: this.fastExecPoolSize,
560
+ idleMs: this.fastExecIdleMs,
561
+ })
562
+ : null;
563
+ this.fastExecStats = { persistent: 0, isolated: 0, unavailable: 0 };
131
564
  }
132
565
 
133
566
  async exec(command, cwd, timeoutMs) {
@@ -135,6 +568,20 @@ export class TerminalManager {
135
568
  const resolvedCwd = safeCwd(cwd);
136
569
  const timeout = normalizeTimeout(timeoutMs);
137
570
 
571
+ if (this.fastExecPool && canUsePersistentExec(this.platform, command)) {
572
+ const result = await this.fastExecPool.exec(command, resolvedCwd, timeout);
573
+ if (!result?.fastExecUnavailable) {
574
+ this.fastExecStats.persistent += 1;
575
+ return result;
576
+ }
577
+ this.fastExecStats.unavailable += 1;
578
+ }
579
+
580
+ this.fastExecStats.isolated += 1;
581
+ return this.#execIsolated(command, resolvedCwd, timeout);
582
+ }
583
+
584
+ #execIsolated(command, resolvedCwd, timeout) {
138
585
  return new Promise((resolve) => {
139
586
  execFile(
140
587
  this.shell.file,
@@ -152,12 +599,27 @@ export class TerminalManager {
152
599
  stdout: stdout ?? "",
153
600
  stderr: stderr ?? "",
154
601
  error: error ? error.message : null,
602
+ execMode: "isolated",
155
603
  });
156
604
  },
157
605
  );
158
606
  });
159
607
  }
160
608
 
609
+ getExecConfig() {
610
+ return {
611
+ enabled: this.fastExecEnabled,
612
+ poolSize: this.fastExecPoolSize,
613
+ idleMs: this.fastExecIdleMs,
614
+ stats: { ...this.fastExecStats },
615
+ pool: this.fastExecPool?.snapshot() || null,
616
+ };
617
+ }
618
+
619
+ close() {
620
+ this.fastExecPool?.close();
621
+ }
622
+
161
623
  start(command, cwd, observability) {
162
624
  validateCommand(command);
163
625
  return this.#spawnSession({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anusornneal/chat-relay",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Zero-checkout remote MCP agent for ChatGPT with terminal, filesystem, process, multi-user, and multi-agent support.",
5
5
  "type": "module",
6
6
  "bin": {