agentp 0.10.0 → 0.11.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/CONTRIBUTING.md +139 -0
- package/README.md +23 -6
- package/bin/agentp +93 -33
- package/bin/ocmux +56 -41
- package/bin/tgagentp +921 -251
- package/docs/specification.md +391 -0
- package/lib/ocmux.js +113 -2
- package/lib/opencode.js +60 -21
- package/package.json +9 -2
- package/AGENTS.md +0 -182
- package/CHANGELOG.md +0 -36
- package/devto-article.md +0 -121
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# agentp — Specification
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Three zero-dependency Node.js CLI tools that augment OpenCode with project-level server management (tmux) and a Telegram bridge.
|
|
6
|
+
|
|
7
|
+
- **`agentp`** — pipe stdin → OpenCode session → stdout answer
|
|
8
|
+
- **`ocmux`** — manage per-project OpenCode servers in tmux
|
|
9
|
+
- **`tgagentp`** — Telegram bot bridge with multi-chat, multi-server support
|
|
10
|
+
|
|
11
|
+
All tools share `lib/opencode.js` (HTTP session API client) and `lib/ocmux.js` (tmux management). Zero npm dependencies — only Node.js 18+ stdlib.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## File Reference
|
|
16
|
+
|
|
17
|
+
### `package.json`
|
|
18
|
+
|
|
19
|
+
**Version:** 0.13.0
|
|
20
|
+
|
|
21
|
+
Fields:
|
|
22
|
+
- `"bin"` — registers `agentp`, `ocmux`, `tgagentp`
|
|
23
|
+
- `"files"` — whitelist for npm publish: `bin/`, `lib/`, `README.md`
|
|
24
|
+
- `"type": "commonjs"`
|
|
25
|
+
- `"engines": { "node": ">=18" }`
|
|
26
|
+
|
|
27
|
+
### `bin/agentp` — Stdin-to-OpenCode pipe
|
|
28
|
+
|
|
29
|
+
Reads stdin, sends to the most recent or named OpenCode session, streams answer to stdout.
|
|
30
|
+
|
|
31
|
+
**Options:**
|
|
32
|
+
|
|
33
|
+
| Flag | Effect |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `--qa` | Print prompt/answer with rulers; auto-detect tgagentp |
|
|
36
|
+
| `--tg` | Forward answer via tgagentp gateway (error if unavailable) |
|
|
37
|
+
| `--no-tg` | Explicitly disable Telegram forwarding |
|
|
38
|
+
| `--flush` | Flush recorded buffer without prepending |
|
|
39
|
+
| `--getLast N` | Retrieve last N assistant answers from session history |
|
|
40
|
+
|
|
41
|
+
**Protocol:**
|
|
42
|
+
|
|
43
|
+
1. Reads all stdin → string
|
|
44
|
+
2. Calls `listSessions(server)` → finds most recently updated session (or creates one named `agentp`)
|
|
45
|
+
3. Calls `sendToSession(server, sessionId, text, agent?, cancelRef?)` → returns concatenated text parts
|
|
46
|
+
4. Prints answer to stdout
|
|
47
|
+
5. If `--tg` or `--qa` (auto-detect):
|
|
48
|
+
- POSTs `{ text, server }` to tgagentp gateway at `http://localhost:<port>/send`
|
|
49
|
+
- Gateway response includes `{ buffered }` — recorded conversation messages
|
|
50
|
+
- `--qa` prepends buffered context to stdout; `--flush` skips prepending
|
|
51
|
+
|
|
52
|
+
**HTTP timeout:** 5 seconds. Pre-send gate check + post-send warning for `--tg`.
|
|
53
|
+
|
|
54
|
+
### `bin/ocmux` — Tmux server manager
|
|
55
|
+
|
|
56
|
+
Manages per-project OpenCode servers in a persistent `Opencode` tmux session.
|
|
57
|
+
|
|
58
|
+
**Subcommands:**
|
|
59
|
+
|
|
60
|
+
| Command | Description |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `serve [dir]` | Create server + TUI pane in a new tmux window |
|
|
63
|
+
| `new [dir]` | Alias for `serve` (deprecated) |
|
|
64
|
+
| `kill [dir]` | Kill server, remove tmux window + `.ocmux.json` |
|
|
65
|
+
| `resurrect [dir]` | Recover dead server: kill old window, remove state file, create fresh server + TUI |
|
|
66
|
+
| `list [-l]` | List all running servers |
|
|
67
|
+
| _(no arg)_ | Switch to existing server (searches upward for `.ocmux.json`) |
|
|
68
|
+
|
|
69
|
+
**Flags:** `--git`, `--GIT`, `--print-logs`, `-l`, `--version`
|
|
70
|
+
|
|
71
|
+
**Window layout:**
|
|
72
|
+
|
|
73
|
+
- Pane 0: server (`opencode serve --port 0 2>&1 | tee <logfile>`)
|
|
74
|
+
- Pane 1+: TUI (`opencode attach --continue '<url>'`)
|
|
75
|
+
- Log: `/tmp/opencode-serve-<hashDir(dir)>.log`
|
|
76
|
+
- State: `<dir>/.ocmux.json` (contains `url`, `logfile`, `window_index`)
|
|
77
|
+
|
|
78
|
+
**State file discovery:** Upward from target directory, git-like.
|
|
79
|
+
|
|
80
|
+
### `bin/tgagentp` — Telegram bridge
|
|
81
|
+
|
|
82
|
+
Long-polling Telegram bot that routes messages to OpenCode servers.
|
|
83
|
+
|
|
84
|
+
**Options:**
|
|
85
|
+
|
|
86
|
+
| Flag | Effect |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `--dev` | Enable `/shutdown` for remote restart, verbose logging, and structured message traffic log to `/tmp/tgagentp-msg.log` |
|
|
89
|
+
| `--think` | Start with thinking forwarding enabled |
|
|
90
|
+
| `--verbose` | Detailed logs on stderr |
|
|
91
|
+
|
|
92
|
+
**Environment variables:**
|
|
93
|
+
|
|
94
|
+
| Variable | Required | Default | Description |
|
|
95
|
+
|---|---|---|---|
|
|
96
|
+
| `TELEGRAM_BOT_TOKEN` | Yes | — | Bot token from @BotFather |
|
|
97
|
+
| `TGAGENTP_ALLOWED_CHAT_IDS` | No | all | Comma-separated allowed chat IDs |
|
|
98
|
+
| `TGAGENTP_PORT` | No | random | Agentp gateway HTTP port |
|
|
99
|
+
| `TGAGENTP_DEBOUNCE_MS` | No | 5000 | Debounce for queued-agentp Telegram notifications |
|
|
100
|
+
| `OPENCODE_SERVER_PASSWORD` | No | — | HTTP Basic Auth for OpenCode + gateway |
|
|
101
|
+
| `OPENCODE_SERVER_USERNAME` | No | opencode | HTTP Basic Auth username |
|
|
102
|
+
|
|
103
|
+
### `lib/opencode.js` — HTTP session API client
|
|
104
|
+
|
|
105
|
+
Shared by `bin/agentp` and `bin/tgagentp`. Functions:
|
|
106
|
+
|
|
107
|
+
| Function | HTTP | Description |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `getAuthHeaders()` | — | Reads `OPENCODE_SERVER_PASSWORD/USERNAME` |
|
|
110
|
+
| `makeRequest(options, data)` | — | Thin `http.request` wrapper |
|
|
111
|
+
| `buildJsonRequest(url, method, body)` | — | Builds request options |
|
|
112
|
+
| `sendText(server, text)` | POST /tui/* | Convenience: clear + append + submit prompt |
|
|
113
|
+
| `listenForFinalAnswer(server, onText?, cancelRef?)` | GET /event | SSE listener; `cancelRef` enables abort |
|
|
114
|
+
| `listSessions(server, directory?)` | GET /session | Returns parsed JSON array |
|
|
115
|
+
| `createSession(server, title?)` | POST /session | Creates a session |
|
|
116
|
+
| `updateSession(server, id, title, agent?)` | PATCH /session/:id | Updates session properties |
|
|
117
|
+
| `sendToSession(server, id, text, agent?, cancelRef?)` | POST /session/:id/message | Synchronous message; optional abort |
|
|
118
|
+
| `sendToSessionAsync(server, id, text, agent?)` | POST /session/:id/prompt_async | Non-blocking (204); answer via SSE |
|
|
119
|
+
| `respondToPermission(server, id, permissionId, response)` | POST /session/:id/permissions/:id | Permission response |
|
|
120
|
+
| `selectSession(server, id)` | POST /session/:id/select | TUI navigation |
|
|
121
|
+
| `listAgents(server)` | GET /agent | Returns parsed array |
|
|
122
|
+
| `listProviders(server)` | GET /provider | Returns parsed array |
|
|
123
|
+
| `isServerAlive(url)` | GET /session (5s timeout) | Health check, resolves true/false |
|
|
124
|
+
| `listenForSessionEvents(server, id, callbacks, cancelRef?)` | GET /event | SSE with structured events |
|
|
125
|
+
|
|
126
|
+
### `lib/ocmux.js` — Tmux management library
|
|
127
|
+
|
|
128
|
+
Shared by `bin/ocmux` and `bin/tgagentp`. Functions:
|
|
129
|
+
|
|
130
|
+
| Function | Description |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `readState(file)` | Parses `.ocmux.json`; returns `null` on error |
|
|
133
|
+
| `statefileFor(dir)` | `path.join(dir, '.ocmux.json')` |
|
|
134
|
+
| `tuiPaneId(windowIndex)` | Returns pane ID of TUI pane (pane index != 0); `null` if dead |
|
|
135
|
+
| `windowByDir(dir)` | Returns tmux window index matching directory name |
|
|
136
|
+
| `windowNameByIndex(idx)` | Returns window name at given index |
|
|
137
|
+
| `activeWindowIndex()` | Returns index of currently selected tmux window |
|
|
138
|
+
| `paneCount(windowIndex)` | Number of panes in a window |
|
|
139
|
+
| `listServers()` | Scans all tmux windows for `.ocmux.json`; returns `{ url, dir, index, status }` |
|
|
140
|
+
| `activateServer(dir, index, url)` | Pin window name, restart dead TUI pane, select window, zoom |
|
|
141
|
+
| `hashDir(dir)` | MD5 hash (first 12 chars) |
|
|
142
|
+
| `logfileFor(dir)` | `/tmp/opencode-serve-<hash>.log` |
|
|
143
|
+
| `sleep(seconds)` | `execSync sleep` |
|
|
144
|
+
| `ensureSession()` | Create `Opencode` tmux session if missing |
|
|
145
|
+
| `pinWindowName(windowIndex)` | Disable tmux auto-rename for the window |
|
|
146
|
+
| `resurrectServer(dir, printLogs)` | Kill old window, remove state file, create fresh server + TUI; returns `{ url, dir }` |
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Persistence / State Files
|
|
151
|
+
|
|
152
|
+
### `{project_dir}/.ocmux.json`
|
|
153
|
+
|
|
154
|
+
Per-project state file created by `ocmux serve`:
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"url": "http://localhost:40999",
|
|
159
|
+
"logfile": "/tmp/opencode-serve-abc123.log",
|
|
160
|
+
"window_index": 5
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- **url:** The server's listen URL (assigned by `--port 0` — changes on every restart)
|
|
165
|
+
- **logfile:** Path to the server's tee'd log output (used for URL polling during startup)
|
|
166
|
+
- **window_index:** The tmux window index within the `Opencode` session
|
|
167
|
+
|
|
168
|
+
Discovered by upward directory search from the current/target directory (git-like). Used by `ocmux`, `tgagentp`, and `agentp` for server discovery.
|
|
169
|
+
|
|
170
|
+
### `/tmp/tgagentp-port`
|
|
171
|
+
|
|
172
|
+
Created by tgagentp on startup. Contains the HTTP port number of the agentp gateway. Read by `agentp --tg` to discover the gateway.
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
49152
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### `/tmp/tgagentp-connections.json`
|
|
179
|
+
|
|
180
|
+
Created and maintained by tgagentp. Persists chat↔server directory mappings across restarts.
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
{
|
|
184
|
+
"connections": [
|
|
185
|
+
{
|
|
186
|
+
"chatId": "123456789",
|
|
187
|
+
"threadId": null,
|
|
188
|
+
"dir": "/home/user/projects/myapp"
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
"chatId": "987654321",
|
|
192
|
+
"threadId": "42",
|
|
193
|
+
"dir": "/home/user/projects/other"
|
|
194
|
+
}
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
- **chatId:** Telegram chat ID (string)
|
|
200
|
+
- **threadId:** Telegram forum topic ID or `null`
|
|
201
|
+
- **dir:** Project directory (where `.ocmux.json` lives)
|
|
202
|
+
|
|
203
|
+
On restart, tgagentp reads this file, reads `.ocmux.json` from each `dir` to discover the fresh URL, and reconnects. If `.ocmux.json` is missing, the chat starts disconnected.
|
|
204
|
+
|
|
205
|
+
**Lifecycle:**
|
|
206
|
+
- Written on every `/servers switch` (via `setServerForChat`)
|
|
207
|
+
- Removed for displaced chats on force-takeover (via `removeConnection`)
|
|
208
|
+
- Removed for pruned chats on restart (secondary per URL)
|
|
209
|
+
- Cleared on `/shutdown clear` (via `clearConnections`)
|
|
210
|
+
- Updated on group migration (regular group → supergroup, e.g. enabling topics): all `chatId` references in the file are replaced with the new ID
|
|
211
|
+
|
|
212
|
+
### `/tmp/opencode-serve-<hashDir(dir)>.log`
|
|
213
|
+
|
|
214
|
+
Server log file. Continuously written by `tee` in the server pane. Polled by `doNew`/`resurrectServer` for URL extraction during startup:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
opencode server listening on http://localhost:40999
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Inter-Process Communication
|
|
223
|
+
|
|
224
|
+
### Agentp Gateway (tgagentp ↔ agentp)
|
|
225
|
+
|
|
226
|
+
tgagentp starts an HTTP server on `127.0.0.1:<port>` (random by default, configurable via `TGAGENTP_PORT`). `agentp --tg` POSTs answers to this gateway.
|
|
227
|
+
|
|
228
|
+
**Endpoint:** `POST /send`
|
|
229
|
+
|
|
230
|
+
**Request:**
|
|
231
|
+
```json
|
|
232
|
+
{
|
|
233
|
+
"text": "Answer from OpenCode",
|
|
234
|
+
"server": "http://localhost:40999"
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**Headers:**
|
|
239
|
+
- `Authorization: Basic <base64>` — verified against `OPENCODE_SERVER_PASSWORD`
|
|
240
|
+
|
|
241
|
+
**Response:**
|
|
242
|
+
```json
|
|
243
|
+
{
|
|
244
|
+
"buffered": [
|
|
245
|
+
{"role": "user", "text": "What is X?"},
|
|
246
|
+
{"role": "assistant", "text": "X is..."}
|
|
247
|
+
]
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**Flow:**
|
|
252
|
+
1. Gateway looks up `serverOwners` map to find which chat owns the target server
|
|
253
|
+
2. If found and it's the active server for that chat → forwards immediately via `sendLongMessage`
|
|
254
|
+
3. If not the active server → debounce-queues with notification after `TGAGENTP_DEBOUNCE_MS`
|
|
255
|
+
4. Returns recorded conversation buffer (if any) — `agentp --qa` prepends this to stdout
|
|
256
|
+
5. If no owner found (no chat connected to that server) → returns buffered data, drops the message
|
|
257
|
+
|
|
258
|
+
### Tmux Session Model (ocmux)
|
|
259
|
+
|
|
260
|
+
All servers live in a single tmux session named `Opencode`. Each project gets one window:
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
Session: Opencode
|
|
264
|
+
├── Window 3: /home/user/project-a
|
|
265
|
+
│ ├── Pane 0: opencode serve --port 0 ... (server)
|
|
266
|
+
│ └── Pane 1: opencode attach --continue ... (TUI, zoomed)
|
|
267
|
+
├── Window 4: /home/user/project-b
|
|
268
|
+
│ ├── Pane 0: opencode serve --port 0 ...
|
|
269
|
+
│ └── Pane 1: opencode attach --continue ...
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Window names are the full project directory path. Pane 0 is always the server; pane 1+ is the TUI. The TUI pane is zoomed on switch/create. Dead TUI panes are auto-restarted on switch.
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## Chat-Server Ownership Model (tgagentp)
|
|
277
|
+
|
|
278
|
+
### States
|
|
279
|
+
|
|
280
|
+
- **Disconnected:** `chatState.serverBase === null`. Only `/help`, `/servers`, `/start` work.
|
|
281
|
+
- **Connected:** `chatState.serverBase === <url>`. Server is owned by this chat.
|
|
282
|
+
- **Force-taken:** Previous owner gets `serverBase = null` and a Telegram notification.
|
|
283
|
+
|
|
284
|
+
### Data Structures
|
|
285
|
+
|
|
286
|
+
```javascript
|
|
287
|
+
serverOwners = {
|
|
288
|
+
"http://localhost:40999": { chatId: "123", threadId: null },
|
|
289
|
+
"http://localhost:41000": { chatId: "456", threadId: "42" },
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Maps server URL → owning chat. Used by the agentp gateway to route forwarded messages. Updated on every `/servers switch` and on startup restoration.
|
|
294
|
+
|
|
295
|
+
```javascript
|
|
296
|
+
chatStates = {
|
|
297
|
+
"123": {
|
|
298
|
+
chatId: "123",
|
|
299
|
+
threadId: null,
|
|
300
|
+
serverBase: "http://localhost:40999",
|
|
301
|
+
recording: { active: false, paused: false, messages: [], bytes: 0 },
|
|
302
|
+
ring: { messages: [], bytes: 0 },
|
|
303
|
+
},
|
|
304
|
+
"456:42": {
|
|
305
|
+
chatId: "456",
|
|
306
|
+
threadId: 42,
|
|
307
|
+
serverBase: null,
|
|
308
|
+
...
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Keyed by `convKey(chatId, threadId)` → `${chatId}:t${threadId}` (or just `chatId` for non-thread chats). Created on first message from each chat.
|
|
314
|
+
|
|
315
|
+
### Connection Flow
|
|
316
|
+
|
|
317
|
+
1. **First message** → `getChatState` creates state with `serverBase: null`
|
|
318
|
+
2. **`/servers switch <name>`** → `cmdServers`:
|
|
319
|
+
- Finds server directory by name match
|
|
320
|
+
- Checks `serverOwners` — warns if owned by different chat (unless `--force`)
|
|
321
|
+
- Calls `setServerForChat(chatState, url)`:
|
|
322
|
+
- Iterates all `chatStates`, sets `serverBase = null` for any other chat on same URL
|
|
323
|
+
- Sets `chatState.serverBase = url`
|
|
324
|
+
- Sets `serverOwners[url] = { chatId, threadId }`
|
|
325
|
+
- Saves connection to `/tmp/tgagentp-connections.json`
|
|
326
|
+
- Activates tmux window
|
|
327
|
+
- Flushes agentp queue for this server
|
|
328
|
+
3. **Restart** → reads `/tmp/tgagentp-connections.json`, restores each connection:
|
|
329
|
+
- Phase 1: restores all chat states (`cs.serverBase`) and populates `serverOwners`
|
|
330
|
+
- `serverOwners` picks non-thread (private chat) over topic threads as owner per URL
|
|
331
|
+
- Phase 2: prunes secondary connections — only the owner per URL survives, others have `serverBase` cleared and connection removed from file
|
|
332
|
+
- Phase 3: "Bot started" notification sent only to owner per URL (first-to-notify wins)
|
|
333
|
+
4. **`/force-switch <name>`** → same as `/servers switch --force <name>`: bypasses ownership check, takes over server, notifies previous owner
|
|
334
|
+
5. **`/disconnect`** → clears `serverBase`, removes ownership, deletes connection from file
|
|
335
|
+
|
|
336
|
+
### Disconnected Guard
|
|
337
|
+
|
|
338
|
+
Both command and non-command paths in the message loop check `cs.serverBase`:
|
|
339
|
+
|
|
340
|
+
- Commands: only `/help`, `/servers`, `/force-switch`, `/start`, `/comment`, `/shutdown` pass through without a server
|
|
341
|
+
- Non-commands: `🔌 Not connected. Use /servers to see available servers.`
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## Logging Conventions (tgagentp)
|
|
346
|
+
|
|
347
|
+
- **stdout** — informational messages (startup, discovery, session switches)
|
|
348
|
+
- **stderr** — errors (always shown) + trace/debug (only `--verbose` flag)
|
|
349
|
+
- Default usage: `tgagentp 2>/dev/null`
|
|
350
|
+
|
|
351
|
+
### Dev Mode Message Log
|
|
352
|
+
|
|
353
|
+
When `--dev` is active, tgagentp writes a structured JSON-lines message traffic log to `/tmp/tgagentp-msg.log`:
|
|
354
|
+
|
|
355
|
+
```
|
|
356
|
+
{"ts":"2026-06-09 06:25:01","type":"in","chatId":-1004291964025,"from":"user","text":"/status"}
|
|
357
|
+
{"ts":"2026-06-09 06:25:01","type":"out","chatId":-1004291964025,"from":"assistant","text":"**Server:** ..."}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Each line contains a timestamp, direction (`in`/`out`), chat ID, sender, and text content.
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
## Error Handling
|
|
365
|
+
|
|
366
|
+
### Server Health (tgagentp)
|
|
367
|
+
|
|
368
|
+
```javascript
|
|
369
|
+
// Pre-send check
|
|
370
|
+
if (st.serverDead) {
|
|
371
|
+
const alive = await isServerAlive(url); // GET /session, 5s timeout
|
|
372
|
+
if (alive) { st.serverDead = false; }
|
|
373
|
+
}
|
|
374
|
+
if (st.serverDead) {
|
|
375
|
+
// Auto-queue the message
|
|
376
|
+
st.messageQueue.push({ text, replyTo });
|
|
377
|
+
// Notify user with options (ocmux serve, /servers switch, /flush)
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Health check runs before every non-command message. Dead servers cause auto-queue with user notification. Connection errors in `processMessageAsync` mark `serverDead = true` and requeue.
|
|
382
|
+
|
|
383
|
+
### Busy Server
|
|
384
|
+
|
|
385
|
+
When `st.busy` is true, non-command messages are dropped with a "⏳ Busy" notice. Use `/queue <message>` to explicitly queue.
|
|
386
|
+
|
|
387
|
+
### Gateway Errors
|
|
388
|
+
|
|
389
|
+
- Missing owning chat → return buffered data, drop message
|
|
390
|
+
- Socket errors → caught by try-catch in gateway handler (no crash)
|
|
391
|
+
- HTTP timeout (5s) in agentp → post-send warning (not hard error)
|
package/lib/ocmux.js
CHANGED
|
@@ -2,18 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const child_process = require('child_process');
|
|
6
7
|
|
|
7
8
|
const SESSION = 'Opencode';
|
|
9
|
+
const URL_RE = /opencode server listening on (http:\/\/[^\s]*)/;
|
|
8
10
|
|
|
9
11
|
function _tmux(args) {
|
|
10
|
-
const result = spawnSync('tmux', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 });
|
|
12
|
+
const result = child_process.spawnSync('tmux', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 });
|
|
11
13
|
if (result.error && result.error.code === 'ENOENT') {
|
|
12
14
|
return { status: -1, stdout: '', stderr: 'tmux not found' };
|
|
13
15
|
}
|
|
14
16
|
return result;
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
function hashDir(dir) {
|
|
20
|
+
return crypto.createHash('md5').update(dir).digest('hex').slice(0, 12);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function logfileFor(dir) {
|
|
24
|
+
return `/tmp/opencode-serve-${hashDir(dir)}.log`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sleep(seconds) {
|
|
28
|
+
child_process.execSync(`sleep ${seconds}`, { stdio: 'ignore' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ensureSession() {
|
|
32
|
+
const r = _tmux(['has-session', '-t', SESSION]);
|
|
33
|
+
if (r.status !== 0) {
|
|
34
|
+
_tmux(['new-session', '-d', '-s', SESSION]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pinWindowName(windowIndex) {
|
|
39
|
+
_tmux(['set-window-option', '-t', `${SESSION}:${windowIndex}`, 'automatic-rename', 'off']);
|
|
40
|
+
}
|
|
41
|
+
|
|
17
42
|
function readState(file) {
|
|
18
43
|
try {
|
|
19
44
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -143,8 +168,88 @@ function activateServer(dir, index, url) {
|
|
|
143
168
|
return true;
|
|
144
169
|
}
|
|
145
170
|
|
|
171
|
+
// Recover a dead/crashed server: kill old window, remove state file,
|
|
172
|
+
// create fresh server + TUI in the same directory.
|
|
173
|
+
// Returns { url, dir } on success, throws on error.
|
|
174
|
+
function resurrectServer(dir, printLogs) {
|
|
175
|
+
const sf = path.join(dir, '.ocmux.json');
|
|
176
|
+
const state = readState(sf);
|
|
177
|
+
|
|
178
|
+
// Kill old tmux window if it exists
|
|
179
|
+
if (state && state.window_index != null) {
|
|
180
|
+
let winIdx = state.window_index;
|
|
181
|
+
const sessionR = _tmux(['has-session', '-t', SESSION]);
|
|
182
|
+
if (sessionR.status === 0) {
|
|
183
|
+
const actualName = windowNameByIndex(winIdx);
|
|
184
|
+
if (actualName !== dir) {
|
|
185
|
+
const found = windowByDir(dir);
|
|
186
|
+
if (found != null) winIdx = found;
|
|
187
|
+
}
|
|
188
|
+
if (winIdx != null) {
|
|
189
|
+
_tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
|
|
190
|
+
_tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`, 'C-c']);
|
|
191
|
+
sleep(0.3);
|
|
192
|
+
_tmux(['kill-window', '-t', `${SESSION}:${winIdx}`]);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Remove old state file so we can create a fresh one
|
|
198
|
+
try { fs.unlinkSync(sf); } catch {}
|
|
199
|
+
|
|
200
|
+
// Create fresh server + TUI
|
|
201
|
+
ensureSession();
|
|
202
|
+
const lf = logfileFor(dir);
|
|
203
|
+
|
|
204
|
+
const rc = _tmux(['new-window', '-d', '-t', SESSION, '-n', dir, '-c', dir]);
|
|
205
|
+
if (rc.status !== 0) throw new Error(`failed to create tmux window for ${dir}`);
|
|
206
|
+
|
|
207
|
+
const winIdx = windowByDir(dir);
|
|
208
|
+
if (winIdx == null) throw new Error('window was not created properly');
|
|
209
|
+
|
|
210
|
+
pinWindowName(winIdx);
|
|
211
|
+
|
|
212
|
+
try { fs.truncateSync(lf); } catch {}
|
|
213
|
+
|
|
214
|
+
const serveFlags = printLogs ? '--port 0 --print-logs' : '--port 0';
|
|
215
|
+
_tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`,
|
|
216
|
+
`opencode serve ${serveFlags} 2>&1 | tee '${lf}'`, 'Enter']);
|
|
217
|
+
|
|
218
|
+
let url = null;
|
|
219
|
+
for (let i = 0; i < 50; i++) {
|
|
220
|
+
sleep(0.2);
|
|
221
|
+
const log = (() => { try { return fs.readFileSync(lf, 'utf8'); } catch { return ''; } })();
|
|
222
|
+
const m = log.match(URL_RE);
|
|
223
|
+
if (m) url = m[1];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!url) {
|
|
227
|
+
const wi = windowByDir(dir);
|
|
228
|
+
if (wi != null) _tmux(['kill-window', '-t', `${SESSION}:${wi}`]);
|
|
229
|
+
throw new Error('opencode server did not start within 10 seconds');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const newState = { url, logfile: lf, window_index: winIdx };
|
|
233
|
+
fs.writeFileSync(sf, JSON.stringify(newState) + '\n');
|
|
234
|
+
|
|
235
|
+
const splitResult = _tmux(['split-window', '-v', '-P', '-F', '#{pane_id}', '-t', `${SESSION}:${winIdx}`, '-c', dir]);
|
|
236
|
+
const tuiPane = splitResult.stdout.trim();
|
|
237
|
+
sleep(0.2);
|
|
238
|
+
_tmux(['send-keys', '-t', tuiPane, `opencode attach --continue '${url}'`, 'Enter']);
|
|
239
|
+
sleep(0.5);
|
|
240
|
+
if (tuiPane) _tmux(['resize-pane', '-Z', '-t', tuiPane]);
|
|
241
|
+
|
|
242
|
+
const activeWin = activeWindowIndex();
|
|
243
|
+
if (activeWin !== null && activeWin !== winIdx) {
|
|
244
|
+
_tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { url, dir };
|
|
248
|
+
}
|
|
249
|
+
|
|
146
250
|
module.exports = {
|
|
147
251
|
SESSION,
|
|
252
|
+
URL_RE,
|
|
148
253
|
readState,
|
|
149
254
|
statefileFor,
|
|
150
255
|
tuiPaneId,
|
|
@@ -154,4 +259,10 @@ module.exports = {
|
|
|
154
259
|
paneCount,
|
|
155
260
|
listServers,
|
|
156
261
|
activateServer,
|
|
262
|
+
hashDir,
|
|
263
|
+
logfileFor,
|
|
264
|
+
sleep,
|
|
265
|
+
ensureSession,
|
|
266
|
+
pinWindowName,
|
|
267
|
+
resurrectServer,
|
|
157
268
|
};
|
package/lib/opencode.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const http = require('http');
|
|
4
|
+
const https = require('https');
|
|
4
5
|
const { URL } = require('url');
|
|
5
6
|
|
|
6
7
|
function getAuthHeaders() {
|
|
@@ -252,16 +253,25 @@ async function selectSession(server, sessionId) {
|
|
|
252
253
|
// Get a single session by ID, including its message history.
|
|
253
254
|
// Tries GET /session/:id first; falls back to filtering the sessions list.
|
|
254
255
|
async function getSession(server, sessionId) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
256
|
+
const encoded = encodeURIComponent(sessionId);
|
|
257
|
+
// Try several endpoint patterns that different opencode versions may expose
|
|
258
|
+
const urls = [
|
|
259
|
+
`${server}/session/${encoded}`,
|
|
260
|
+
`${server}/session/${encoded}/messages`,
|
|
261
|
+
`${server}/session/${encoded}/history`,
|
|
262
|
+
`${server}/session/${encoded}/conversation`,
|
|
263
|
+
`${server}/conversation/${encoded}`,
|
|
264
|
+
];
|
|
265
|
+
for (const url of urls) {
|
|
266
|
+
try {
|
|
267
|
+
const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'), null, null, 10000);
|
|
268
|
+
if (status === 200) {
|
|
269
|
+
try {
|
|
270
|
+
const parsed = JSON.parse(body);
|
|
271
|
+
if (parsed != null) return parsed;
|
|
272
|
+
} catch {}
|
|
273
|
+
}
|
|
274
|
+
} catch {}
|
|
265
275
|
}
|
|
266
276
|
// Fallback: get all sessions and find the one we need
|
|
267
277
|
const sessions = await listSessions(server);
|
|
@@ -310,8 +320,13 @@ async function respondToPermission(server, sessionId, permissionId, response, re
|
|
|
310
320
|
// permission requests. Resolves with the full collected text when the session goes idle.
|
|
311
321
|
// callbacks: { onText(chunk), onPermission(permission), onThinking(chunk), onConnected() }
|
|
312
322
|
// cancelRef — allows aborting the SSE stream via req.destroy()
|
|
313
|
-
|
|
314
|
-
|
|
323
|
+
// logFn — optional logger; if provided, used instead of process.stderr.write
|
|
324
|
+
function listenForSessionEvents(server, sessionId, callbacks, cancelRef, logFn) {
|
|
325
|
+
const sseLog = logFn || ((msg) => {
|
|
326
|
+
const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
|
|
327
|
+
process.stderr.write(`${ts} ${msg}`);
|
|
328
|
+
});
|
|
329
|
+
sseLog(` [SSE] connecting to ${server}/event for session ${sessionId}\n`);
|
|
315
330
|
return new Promise((resolve, reject) => {
|
|
316
331
|
const url = `${server}/event`;
|
|
317
332
|
const parsed = new URL(url);
|
|
@@ -328,7 +343,7 @@ function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
|
|
|
328
343
|
reject(new Error('OpenCode server authentication failed. Set OPENCODE_SERVER_PASSWORD to match the server password.'));
|
|
329
344
|
return;
|
|
330
345
|
}
|
|
331
|
-
|
|
346
|
+
sseLog(` [SSE] connected for session ${sessionId}\n`);
|
|
332
347
|
if (callbacks.onConnected) callbacks.onConnected();
|
|
333
348
|
let buffer = '';
|
|
334
349
|
const userMessageIDs = new Set();
|
|
@@ -339,7 +354,7 @@ function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
|
|
|
339
354
|
const safetyTimer = setTimeout(() => {
|
|
340
355
|
if (resolved) return;
|
|
341
356
|
resolved = true;
|
|
342
|
-
|
|
357
|
+
sseLog(` [SSE] safety timeout (90s) for session ${sessionId}, collected ${collected.length} chars\n`);
|
|
343
358
|
cleanup();
|
|
344
359
|
req.destroy();
|
|
345
360
|
res.destroy();
|
|
@@ -365,9 +380,9 @@ function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
|
|
|
365
380
|
const props = event.properties || {};
|
|
366
381
|
|
|
367
382
|
if (event.type === 'session.status') {
|
|
368
|
-
|
|
383
|
+
sseLog(` [SSE] session.status: type=${props.status?.type} status=${JSON.stringify(props.status)}\n`);
|
|
369
384
|
} else {
|
|
370
|
-
|
|
385
|
+
sseLog(` [SSE] event type=${event.type} sessionID=${props.sessionID} waitSession=${sessionId}\n`);
|
|
371
386
|
}
|
|
372
387
|
|
|
373
388
|
// Filter by sessionID when the event carries one (string-compare to handle type mismatches)
|
|
@@ -380,18 +395,18 @@ function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
|
|
|
380
395
|
|
|
381
396
|
if (event.type === 'permission.asked') {
|
|
382
397
|
const permStr = JSON.stringify(props);
|
|
383
|
-
|
|
398
|
+
sseLog(` [SSE] PERMISSION ASKED: ${permStr}\n`);
|
|
384
399
|
if (callbacks.onPermission) {
|
|
385
|
-
|
|
400
|
+
sseLog(` [SSE] calling onPermission callback\n`);
|
|
386
401
|
callbacks.onPermission(props);
|
|
387
402
|
} else {
|
|
388
|
-
|
|
403
|
+
sseLog(` [SSE] WARNING: no onPermission callback registered\n`);
|
|
389
404
|
}
|
|
390
405
|
continue;
|
|
391
406
|
}
|
|
392
407
|
|
|
393
408
|
if (event.type === 'permission.replied') {
|
|
394
|
-
|
|
409
|
+
sseLog(` [SSE] PERMISSION REPLIED: type=${props.type}\n`);
|
|
395
410
|
if (callbacks.onPermissionReplied) {
|
|
396
411
|
callbacks.onPermissionReplied(props);
|
|
397
412
|
}
|
|
@@ -453,7 +468,7 @@ function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
|
|
|
453
468
|
if (resolved) return;
|
|
454
469
|
resolved = true;
|
|
455
470
|
cleanup();
|
|
456
|
-
|
|
471
|
+
sseLog(` [SSE] response stream error for session ${sessionId}: ${err.message}\n`);
|
|
457
472
|
resolve(collected); // resolve with what we have
|
|
458
473
|
});
|
|
459
474
|
});
|
|
@@ -472,6 +487,29 @@ function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
|
|
|
472
487
|
});
|
|
473
488
|
}
|
|
474
489
|
|
|
490
|
+
// Check if an OpenCode server is reachable by making a GET /session request.
|
|
491
|
+
// Returns a Promise that resolves to `true` (any response) or `false` (connection error / timeout).
|
|
492
|
+
function isServerAlive(url) {
|
|
493
|
+
return new Promise((resolve) => {
|
|
494
|
+
const mod = url.startsWith('https') ? https : http;
|
|
495
|
+
const parsed = new URL(url + '/session');
|
|
496
|
+
const opts = {
|
|
497
|
+
hostname: parsed.hostname,
|
|
498
|
+
port: parsed.port,
|
|
499
|
+
path: parsed.pathname,
|
|
500
|
+
method: 'GET',
|
|
501
|
+
headers: { ...getAuthHeaders() },
|
|
502
|
+
};
|
|
503
|
+
const req = mod.request(opts, (res) => {
|
|
504
|
+
res.resume();
|
|
505
|
+
resolve(true);
|
|
506
|
+
});
|
|
507
|
+
req.on('error', () => resolve(false));
|
|
508
|
+
req.setTimeout(5000, () => { req.destroy(); resolve(false); });
|
|
509
|
+
req.end();
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
475
513
|
module.exports = {
|
|
476
514
|
getAuthHeaders,
|
|
477
515
|
makeRequest,
|
|
@@ -492,4 +530,5 @@ module.exports = {
|
|
|
492
530
|
listenForSessionEvents,
|
|
493
531
|
listAgents,
|
|
494
532
|
listProviders,
|
|
533
|
+
isServerAlive,
|
|
495
534
|
};
|