agentp 0.9.0 → 0.11.0
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 +54 -8
- package/bin/agentp +159 -20
- package/bin/ocmux +56 -41
- package/bin/tgagentp +971 -206
- package/docs/specification.md +391 -0
- package/lib/ocmux.js +113 -2
- package/lib/opencode.js +79 -12
- package/package.json +9 -2
- package/AGENTS.md +0 -177
- package/CHANGELOG.md +0 -24
- package/debugging_tg_integration.md +0 -5594
- package/tgagentp.log +0 -2156
|
@@ -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
|
};
|