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.
@@ -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 { spawnSync } = require('child_process');
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
- // Try dedicated endpoint first (newer opencode versions)
256
- try {
257
- const url = `${server}/session/${encodeURIComponent(sessionId)}`;
258
- const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'), null, null, 10000);
259
- if (status === 200) {
260
- const parsed = JSON.parse(body);
261
- if (parsed) return parsed;
262
- }
263
- } catch {
264
- // Fall through
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
- function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
314
- process.stderr.write(` [SSE] connecting to ${server}/event for session ${sessionId}\n`);
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
- process.stderr.write(` [SSE] connected for session ${sessionId}\n`);
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
- process.stderr.write(` [SSE] safety timeout (90s) for session ${sessionId}, collected ${collected.length} chars\n`);
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
- process.stderr.write(` [SSE] session.status: type=${props.status?.type} status=${JSON.stringify(props.status)}\n`);
383
+ sseLog(` [SSE] session.status: type=${props.status?.type} status=${JSON.stringify(props.status)}\n`);
369
384
  } else {
370
- process.stderr.write(` [SSE] event type=${event.type} sessionID=${props.sessionID} waitSession=${sessionId}\n`);
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
- process.stderr.write(` [SSE] PERMISSION ASKED: ${permStr}\n`);
398
+ sseLog(` [SSE] PERMISSION ASKED: ${permStr}\n`);
384
399
  if (callbacks.onPermission) {
385
- process.stderr.write(` [SSE] calling onPermission callback\n`);
400
+ sseLog(` [SSE] calling onPermission callback\n`);
386
401
  callbacks.onPermission(props);
387
402
  } else {
388
- process.stderr.write(` [SSE] WARNING: no onPermission callback registered\n`);
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
- process.stderr.write(` [SSE] PERMISSION REPLIED: type=${props.type}\n`);
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
- process.stderr.write(` [SSE] response stream error for session ${sessionId}: ${err.message}\n`);
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
  };