agentp 0.4.0 → 0.5.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/AGENTS.md CHANGED
@@ -1,23 +1,56 @@
1
1
  # agentp — agent notes
2
2
 
3
- ## Two binaries, both registered
3
+ ## Three binaries, all registered
4
4
 
5
5
  - `bin/agentp` (Node.js) — registered in `package.json` `"bin"` — pipes stdin → opencode TUI
6
6
  - `bin/ocmux` (Node.js) — registered in `package.json` `"bin"` — manages opencode tmux sessions
7
+ - `bin/tgagentp` (Node.js) — registered in `package.json` `"bin"` — bridges Telegram bot ↔ opencode TUI
7
8
 
8
9
  ## Package facts
9
10
 
10
- - **Zero npm dependencies.** Both scripts use only Node.js stdlib (`http`, `readline`, `url`, `child_process`, `fs`, `path`, `crypto`, `os`).
11
+ - **Zero npm dependencies.** All scripts use only Node.js stdlib (`http`, `https`, `readline`, `url`, `child_process`, `fs`, `path`, `crypto`, `os`).
11
12
  - **No tests, no CI, no lint, no formatter.** `npm test` is a stub that exits 1.
12
13
  - **No lockfile.** Versions resolve at install time.
13
14
  - **`"type": "commonjs"`**, requires Node >= 18.
14
- - **Linux and macOS compatible.** Both tools use portable Node.js APIs only.
15
+ - **Linux and macOS compatible.** All tools use portable Node.js APIs only.
15
16
 
16
17
  ## `.ocmux.json`
17
18
 
18
19
  Runtime state file created by `bin/ocmux` (contains URL, log path, and window index). **Not in `.gitignore`** — avoid committing it.
19
20
 
20
- ## OpenCode HTTP protocol (agentp)
21
+ ## Shared `lib/opencode.js`
22
+
23
+ Extracted from `bin/agentp`; used by both `agentp` and `tgagentp`:
24
+
25
+ - `getAuthHeaders()` — reads `OPENCODE_SERVER_PASSWORD` / `OPENCODE_SERVER_USERNAME`
26
+ - `makeRequest(options, data)` — thin `http.request` wrapper returning a Promise
27
+ - `buildJsonRequest(url, method, body)` — builds request options for JSON POST/GET (no Content-Type/Length for bodyless requests)
28
+ - `clearPrompt(server)`, `appendPrompt(server, text)`, `submitPrompt(server)` — TUI prompt endpoints
29
+ - `sendText(server, text)` — convenience: clear + append + submit in one call
30
+ - `listenForFinalAnswer(server, onText?, cancelRef?)` — SSE event stream listener; `cancelRef` allows true server-side abort via `req.destroy()`
31
+ - `listSessions(server, directory?)` — `GET /session`; returns parsed JSON array; optional `?directory=` filter
32
+ - `createSession(server, title?)` — `POST /session`; returns the created `Session` object; optional `title`
33
+ - `updateSession(server, sessionId, title)` — `PATCH /session/:id`; returns the updated `Session` object
34
+ - `sendToSession(server, sessionId, text, agent?)` — `POST /session/:id/message`; returns concatenated text parts from response; optional `agent` to handle the message
35
+ - `selectSession(server, sessionId)` — `POST /session/:id/select`; tells the TUI to navigate to the given session (silently ignores 404 if the endpoint isn't available in older opencode versions)
36
+ - `listAgents(server)` — `GET /agent`; returns parsed JSON array of agent objects
37
+ - `listProviders(server)` — `GET /provider`; returns parsed JSON array of provider objects
38
+
39
+ ## Shared `lib/ocmux.js`
40
+
41
+ Shared ocmux session discovery; used by both `bin/ocmux` and `bin/tgagentp`:
42
+
43
+ - `readState(file)` — parses `.ocmux.json`, returns `null` on error
44
+ - `statefileFor(dir)` — `path.join(dir, '.ocmux.json')`
45
+ - `tuiPaneId(windowIndex)` — returns pane ID if TUI process (`node`) is running, `null` if dead
46
+ - `windowByDir(dir)` — returns window index by name match
47
+ - `windowNameByIndex(idx)` — reverse: name from index
48
+ - `activeWindowIndex()` — returns index of currently selected tmux window
49
+ - `paneCount(windowIndex)` — number of panes in a window
50
+ - `listServers()` — scans all tmux windows for `.ocmux.json` state files; returns `{ url, dir, index, status }` array
51
+ - `activateServer(dir, index, url)` — pin window name, restart dead TUI pane, select window, zoom pane
52
+
53
+ ## OpenCode HTTP protocol
21
54
 
22
55
  These endpoints are consumed but not documented elsewhere in the repo:
23
56
 
@@ -25,24 +58,83 @@ These endpoints are consumed but not documented elsewhere in the repo:
25
58
  - `POST /tui/append-prompt`
26
59
  - `POST /tui/submit-prompt`
27
60
  - `GET /event` (SSE stream)
61
+ - `GET /session` — list sessions (optional `?directory=` filter)
62
+ - `POST /session` — create a new session (optional `{ title? }` body)
63
+ - `PATCH /session/:id` — update session properties (`{ title }`)
64
+ - `POST /session/:id/select` — tell the TUI to navigate to a session (only with TUI attached)
65
+ - `POST /session/:id/message` — send message to session (returns parts synchronously; optional `agent` field)
66
+ - `GET /agent` — list available agents
67
+ - `GET /provider` — list connected providers/models
28
68
 
29
69
  ## OpenCode tmux session model (ocmux)
30
70
 
31
71
  - Single tmux session named `"Opencode"`
32
72
  - Windows named by full project directory path
33
- - Server pane runs `opencode serve --port 0`, TUI pane runs `opencode attach --continue`
73
+ - Server pane runs `opencode serve --port 0` (via send-keys → interactive shell for env var support), TUI pane runs `opencode attach --continue`
74
+ - Log file per project: `/tmp/opencode-serve-<hashDir(dir)>.log` — captured via `tee`, read by ocmux polling loop
34
75
  - Per-project `.ocmux.json` with upward directory search (git-like)
35
- - Auto-restarts dead TUI panes on switch
76
+ - Auto-restarts dead TUI panes on switch (respawn-pane or split-window)
36
77
  - Auto-zooms TUI pane on switch/create
37
78
 
38
79
  ## Commands
39
80
 
40
81
  | Command | What |
41
- |---|---|
82
+ |---|---|---|
42
83
  | `npm link` | local dev install |
43
84
  | `npm install -g .` | alternative local install |
44
85
  | `agentp [--qa] [port]` | pipe stdin → opencode TUI, stream answer to stdout |
45
- | `ocmux new [dir]` | start opencode serve in a tmux window |
86
+ | `tgagentp [port]` | bridge Telegram bot ↔ opencode TUI (needs `TELEGRAM_BOT_TOKEN`) |
87
+ | `tgagentp --dev` | enable `/shutdown` command for remote restart |
88
+ | `ocmux serve [dir]` | start opencode serve in a tmux window (primary verb) |
89
+ | `ocmux new [dir]` | alias for `serve` (backwards compat, to be removed in 1.0) |
46
90
  | `ocmux kill [dir]` | kill server + tmux window |
47
91
  | `ocmux list [-l]` | list running servers |
48
92
  | `ocmux [dir]` | switch to existing opencode tmux window |
93
+
94
+ ## TODO
95
+
96
+ ### Pending
97
+
98
+ *(none)*
99
+
100
+ ### Done
101
+
102
+ - /agents switch <name> — switch active agent on the active session
103
+ - /sessions new [name] — create a new session, TUI follows via /session/:id/select
104
+ - /sessions rename <name> — rename the current active session
105
+ - Merge /session into /sessions (keep /session as alias)
106
+ - Case-insensitive matching for all switch subcommands (/servers, /sessions, /agents)
107
+ - Fix: /sessions filter includes sessions with directory (TUI sessions) + active session exception
108
+ - Fix: /sessions now shows the active session even without agent set (exception in filter)
109
+ - Fix: /sessions switch and /sessions new call selectSession (TUI navigation doesn't work — see With Issues)
110
+ - Fix: active session highlighted with ▶ bullet in session list
111
+ - Message splitting (>4096 chars, breaks at newlines)
112
+ - lib/ocmux.js shared session discovery, refactor ocmux + tgagentp
113
+ - /cancel with true server-side SSE abort via cancelRef
114
+ - /shutdown (--dev mode) with Telegram offset acknowledgment
115
+
116
+ ### With Issues
117
+
118
+ - `/sessions switch` and `/sessions new` — `selectSession` tries `POST /session/:id/select` and `POST /tui/select-session` but the TUI pane doesn't navigate to the session. Message routing (via `activeSessionId` + `sendToSession`) works correctly in the background. This is a known limitation — the TUI must be navigated manually to see the conversation in the UI. The underlying opencode API may lack the needed TUI navigation endpoints.
119
+
120
+ ### Verified
121
+
122
+ - Password auth support (OPENCODE_SERVER_PASSWORD) for agentp, ocmux, tgagentp
123
+ - agentp HTTP Basic Auth headers on all requests (POST and SSE)
124
+ - agentp clear 401 error reporting
125
+ - ocmux interactive shells (send-keys) for env var propagation
126
+ - tgagentp Telegram bridge bot with long-polling
127
+ - Markdown→HTML converter (**bold**, *italic*, `code`, code blocks, headings, tables)
128
+ - Chat allowlist (TGAGENTP_ALLOWED_CHAT_IDS)
129
+ - Slash command system (/help, /servers, /sessions, /session, /agents, /agents switch, /models, /status, /cancel, /shutdown)
130
+ - Per-server state tracking (busy/idle, pendingResponse, activeSessionId, activeAgentName, taskId)
131
+ - Background async processing (non-commands fire without await)
132
+ - /servers switch activates tmux window (pin, restart TUI, select, zoom)
133
+ - Startup greeting with /status-style info persisted via temp file
134
+ - Fix port mismatch bug: truncate stale log file before send-keys in doNew
135
+ - Update README with full tgagentp features (slash commands, --dev, env vars)
136
+ - Update AGENTS.md with lib/ocmux.js, missing API endpoints, current architecture
137
+ - Single-user lock: first chat to connect owns the session
138
+ - Fix startup greeting markdown not being converted to HTML
139
+
140
+ *(Keep this TODO up to date: move items between categories as their status changes.)*
package/CHANGELOG.md CHANGED
@@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file.
5
5
  ## [Unreleased]
6
6
 
7
7
  - Rename package and CLI command from `aprompt` to `agentp`.
8
+ - Extract shared OpenCode client logic into `lib/opencode.js`.
9
+ - Add `tgagentp` — bridge a Telegram bot chat with an OpenCode TUI session.
8
10
 
9
11
  ## [0.1.0] - 2026-05-08
10
12
 
package/README.md CHANGED
@@ -5,10 +5,11 @@
5
5
  [![npm downloads](https://img.shields.io/npm/dm/agentp.svg)](https://www.npmjs.com/package/agentp)
6
6
  [![node version](https://img.shields.io/node/v/agentp.svg)](https://www.npmjs.com/package/agentp)
7
7
 
8
- This package provides two CLI tools:
8
+ This package provides three CLI tools:
9
9
 
10
10
  - **`agentp`** — pipes prompt text into a running OpenCode TUI session and streams the assistant final answer back to stdout
11
11
  - **`ocmux`** — manages OpenCode server + TUI sessions in tmux (create, switch, kill, list)
12
+ - **`tgagentp`** — bridges a Telegram bot chat with all running OpenCode TUI sessions (receives messages from Telegram, routes them to the active server, sends answers back). Supports slash commands for multi-server management, session switching, agent/model listing, and remote restart.
12
13
 
13
14
  It is designed for prompt-driven workflows where you want to do things like:
14
15
 
@@ -122,7 +123,7 @@ Without arguments, searches upward from `<directory>` (default: `$PWD`) for `.oc
122
123
 
123
124
  Subcommands:
124
125
 
125
- - **`serve [--git|--GIT] [dir]`** — Create a server in `dir` (default: `$PWD`) and attach a TUI pane. Aliased as `new` for backwards compatibility. Errors if one already exists there. Warns if a parent directory already has a server.
126
+ - **`serve [--git|--GIT] [dir]`** — Create a server in `dir` (default: `$PWD`) and attach a TUI pane. Aliased as `new` for backwards compatibility (to be removed in 1.0). Errors if one already exists there. Warns if a parent directory already has a server.
126
127
  - `--git` resolves `dir` to the nearest parent with a `.git` entry (file or dir); errors if none is found.
127
128
  - `--GIT` resolves `dir` to the nearest parent with a `.git` directory only; errors if none is found.
128
129
  - **`kill [dir]`** — Kill the server found upward from `dir`. Removes its tmux window and state file.
@@ -162,6 +163,70 @@ Operational hint:
162
163
 
163
164
  - You can keep a separate `opencode attach` view open to see the full run context while `agentp` is used from shell scripts or editor buffers.
164
165
 
166
+ ## tgagentp
167
+
168
+ Bridge a Telegram bot chat with OpenCode TUI sessions managed by `ocmux`.
169
+
170
+ ```bash
171
+ tgagentp [options]
172
+ ```
173
+
174
+ Keeps running indefinitely. On each text message from Telegram it:
175
+
176
+ 1. Routes non-command messages to the current active server's TUI (same protocol as `agentp`).
177
+ 2. Waits for the assistant to finish (background async — commands remain responsive).
178
+ 3. Sends the full answer back to the same Telegram chat.
179
+
180
+ Non-text Telegram updates (photos, stickers, etc.) are silently ignored.
181
+
182
+ ### Slash commands
183
+
184
+ | Command | Action |
185
+ |---|---|
186
+ | `/help [topic]` | Show general help or help for a topic (`servers`, `sessions`, `agents`, `models`) |
187
+ | `/servers` | List all running ocmux-served projects with URL + status (✅ idle / ⏳ busy) |
188
+ | `/servers switch <name>` | Switch active server; matches by full path, basename, or substring |
189
+ | `/sessions` | List recent sessions for the current server (max 50, with date headings) |
190
+ | `/sessions switch <number\|name>` | Switch active session by position or partial name match |
191
+ | `/agents` | List available agents (name, mode, model, description) |
192
+ | `/models` | List connected providers with model counts, context limits, and costs |
193
+ | `/status` | Show current server path, URL, busy status, and active session |
194
+ | `/cancel` | Cancel the current AI response for the active server |
195
+ | `/shutdown` | (requires `--dev`) Stop tgagentp gracefully; acknowledge offset and exit |
196
+
197
+ ### Per-server state
198
+
199
+ `tgagentp` tracks state independently per server URL: busy/idle status, pending response, active session ID, and cancellation token. Switching servers while one is busy stores the answer as pending — switching back delivers it.
200
+
201
+ ### Setup
202
+
203
+ 1. Create a bot with [@BotFather](https://t.me/BotFather) on Telegram and copy the token.
204
+ 2. Export the token and start `tgagentp`:
205
+
206
+ ```bash
207
+ export TELEGRAM_BOT_TOKEN="your-bot-token-here"
208
+ tgagentp
209
+ ```
210
+
211
+ ### Options
212
+
213
+ - `--version`: show version
214
+ - `--help`: show help message
215
+ - `--dev`: enable `/shutdown` command for remote restart (run via `while true; do tgagentp --dev; done`)
216
+
217
+ ### Telemetry
218
+
219
+ A startup greeting is sent to the last known chat on boot — includes /status-style server info. The chat ID is persisted at `/tmp/tgagentp-startup-chat`.
220
+
221
+ ### Environment variables
222
+
223
+ | Variable | Description |
224
+ |---|---|
225
+ | `TELEGRAM_BOT_TOKEN` | **Required.** Telegram bot token from @BotFather. |
226
+ | `OPENCODE_SERVER_PASSWORD` | Optional. OpenCode server HTTP Basic Auth password. |
227
+ | `OPENCODE_SERVER_USERNAME` | Optional. OpenCode server username (default: `opencode`). |
228
+ | `TGAGENTP_ALLOWED_CHAT_IDS` | Optional. Comma-separated Telegram chat IDs that are allowed to use the bot. |
229
+
165
230
  ## License
166
231
 
167
232
  MIT
package/bin/agentp CHANGED
@@ -1,73 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const http = require('http');
4
- const { URL } = require('url');
3
+ const readline = require('readline');
5
4
  const { version } = require('../package.json');
6
-
7
- function getAuthHeaders() {
8
- const password = process.env.OPENCODE_SERVER_PASSWORD;
9
- if (!password) return {};
10
- const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
11
- const encoded = Buffer.from(`${username}:${password}`).toString('base64');
12
- return { 'Authorization': `Basic ${encoded}` };
13
- }
14
-
15
- function makeRequest(options, data) {
16
- return new Promise((resolve, reject) => {
17
- const req = http.request(options, (res) => {
18
- let body = '';
19
- res.on('data', (chunk) => { body += chunk; });
20
- res.on('end', () => {
21
- if (res.statusCode === 401) {
22
- reject(new Error('OpenCode server authentication failed. Set OPENCODE_SERVER_PASSWORD to match the server password.'));
23
- } else {
24
- resolve({ status: res.statusCode, body });
25
- }
26
- });
27
- });
28
- req.on('error', reject);
29
- if (data) req.write(data);
30
- req.end();
31
- });
32
- }
33
-
34
- function buildJsonRequest(url, method, body) {
35
- const parsed = new URL(url);
36
- return {
37
- hostname: parsed.hostname,
38
- port: parsed.port,
39
- path: parsed.pathname,
40
- method: method,
41
- headers: {
42
- 'Content-Type': 'application/json',
43
- 'Content-Length': Buffer.byteLength(body),
44
- ...getAuthHeaders()
45
- }
46
- };
47
- }
48
-
49
- async function clearPrompt(server) {
50
- const url = `${server}/tui/clear-prompt`;
51
- await makeRequest(buildJsonRequest(url, 'POST', '{}'), '{}');
52
- }
53
-
54
- async function appendPrompt(server, text) {
55
- const url = `${server}/tui/append-prompt`;
56
- const body = JSON.stringify({ text });
57
- await makeRequest(buildJsonRequest(url, 'POST', body), body);
58
- }
59
-
60
- async function submitPrompt(server) {
61
- const url = `${server}/tui/submit-prompt`;
62
- await makeRequest(buildJsonRequest(url, 'POST', '{}'), '{}');
63
- }
5
+ const {
6
+ clearPrompt,
7
+ appendPrompt,
8
+ submitPrompt,
9
+ listenForFinalAnswer,
10
+ } = require('../lib/opencode');
64
11
 
65
12
  async function sendPrompt(server) {
66
13
  let promptText = '';
67
14
 
68
15
  await clearPrompt(server);
69
16
 
70
- const readline = require('readline');
71
17
  const rl = readline.createInterface({ input: process.stdin });
72
18
 
73
19
  for await (const line of rl) {
@@ -85,94 +31,6 @@ const HUMAN_ROW = '👤:  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
85
31
  const AGENT_ROW = '🤖:  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄\n'
86
32
  const FINAL_ROW = ' ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄\n';
87
33
 
88
- function listenForFinalAnswer(server, showFinalRow) {
89
- return new Promise((resolve, reject) => {
90
- const url = `${server}/event`;
91
- const parsed = new URL(url);
92
-
93
- const req = http.request({
94
- hostname: parsed.hostname,
95
- port: parsed.port,
96
- path: parsed.pathname,
97
- method: 'GET',
98
- headers: { 'Accept': 'text/event-stream', ...getAuthHeaders() }
99
- }, (res) => {
100
- if (res.statusCode === 401) {
101
- res.resume();
102
- reject(new Error('OpenCode server authentication failed. Set OPENCODE_SERVER_PASSWORD to match the server password.'));
103
- return;
104
- }
105
- let buffer = '';
106
- const userMessageIDs = new Set();
107
-
108
- res.on('data', (chunk) => {
109
- buffer += chunk.toString();
110
-
111
- // Split by double newline (SSE event separator)
112
- while (buffer.includes('\n\n')) {
113
- const eventEnd = buffer.indexOf('\n\n');
114
- const eventData = buffer.slice(0, eventEnd);
115
- buffer = buffer.slice(eventEnd + 2);
116
-
117
- // Extract data lines
118
- const lines = eventData.split('\n');
119
-
120
- let jsonStr = '';
121
-
122
- for (const line of lines) {
123
- if (line.startsWith('data: ')) {
124
- jsonStr += line.slice(6);
125
- }
126
- }
127
-
128
- if (!jsonStr.trim()) continue;
129
-
130
- try {
131
- const event = JSON.parse(jsonStr);
132
-
133
- // Track user message IDs
134
- if (event.type === 'message.updated' && event.properties?.info?.role === 'user') {
135
- userMessageIDs.add(event.properties.info.id);
136
- }
137
-
138
- // Check for session.idle to detect completion
139
- if (event.type === 'session.idle') {
140
- process.stdout.write('\n');
141
- if (showFinalRow) {
142
- process.stdout.write(`${FINAL_ROW}`);
143
- }
144
- req.destroy();
145
- res.destroy();
146
- resolve();
147
- return;
148
- }
149
-
150
- const part = event.properties?.part;
151
- if (!part) continue;
152
-
153
- // Filter out user input by checking messageID
154
- if (part.messageID && userMessageIDs.has(part.messageID)) {
155
- continue;
156
- }
157
-
158
- if (part.type === 'text' && part.text != null && part.text !== '') {
159
- process.stdout.write(part.text);
160
- }
161
- } catch (e) {
162
- }
163
- }
164
- });
165
-
166
- res.on('end', () => {
167
- resolve();
168
- });
169
- });
170
-
171
- req.on('error', reject);
172
- req.end();
173
- });
174
- }
175
-
176
34
  async function main() {
177
35
  let serverBase = 'http://localhost:4096';
178
36
  let qaMode = false;
@@ -221,7 +79,11 @@ async function main() {
221
79
  process.stdout.write(AGENT_ROW);
222
80
  }
223
81
 
224
- await listenForFinalAnswer(serverBase, qaMode);
82
+ await listenForFinalAnswer(serverBase, (chunk) => process.stdout.write(chunk));
83
+ process.stdout.write('\n');
84
+ if (qaMode) {
85
+ process.stdout.write(FINAL_ROW);
86
+ }
225
87
 
226
88
  if (!process.env.OPENCODE_SERVER_PASSWORD) {
227
89
  process.stderr.write('\n');
package/bin/ocmux CHANGED
@@ -6,6 +6,16 @@ const crypto = require('crypto');
6
6
  const readline = require('readline');
7
7
  const { spawnSync, execSync } = require('child_process');
8
8
  const os = require('os');
9
+ const {
10
+ readState,
11
+ statefileFor,
12
+ tuiPaneId,
13
+ windowByDir,
14
+ windowNameByIndex,
15
+ activeWindowIndex,
16
+ paneCount,
17
+ listServers,
18
+ } = require('../lib/ocmux');
9
19
 
10
20
  // ---------------------------------------------------------------------------
11
21
  // Constants
@@ -43,18 +53,6 @@ function logfileFor(dir) {
43
53
  return `/tmp/opencode-serve-${hashDir(dir)}.log`;
44
54
  }
45
55
 
46
- function statefileFor(dir) {
47
- return path.join(dir, '.ocmux.json');
48
- }
49
-
50
- function readState(file) {
51
- try {
52
- return JSON.parse(fs.readFileSync(file, 'utf8'));
53
- } catch {
54
- return null;
55
- }
56
- }
57
-
58
56
  function findParentStatefile(startDir) {
59
57
  let dir = path.resolve(startDir);
60
58
  while (true) {
@@ -112,52 +110,6 @@ function resolveGitDir(startDir, requireDir, flagLabel) {
112
110
  return result.dir;
113
111
  }
114
112
 
115
- function windowByDir(dir) {
116
- const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}\t#{window_name}']);
117
- if (r.status !== 0) return null;
118
- for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
119
- const [idx, name] = line.split('\t');
120
- if (name === dir) return parseInt(idx, 10);
121
- }
122
- return null;
123
- }
124
-
125
- function windowNameByIndex(idx) {
126
- const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}\t#{window_name}']);
127
- if (r.status !== 0) return null;
128
- for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
129
- const [wi, name] = line.split('\t');
130
- if (parseInt(wi, 10) === idx) return name;
131
- }
132
- return null;
133
- }
134
-
135
- function activeWindowIndex() {
136
- const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_index} #{window_active}']);
137
- if (r.status !== 0) return null;
138
- for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
139
- const [idx, active] = line.split(' ');
140
- if (active === '1') return parseInt(idx, 10);
141
- }
142
- return null;
143
- }
144
-
145
- function tuiPaneId(windowIndex) {
146
- const r = tmux(['list-panes', '-t', `${SESSION}:${windowIndex}`, '-F', '#{pane_id}\t#{pane_current_command}']);
147
- if (r.status !== 0) return null;
148
- for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
149
- const [paneId, cmd] = line.split('\t');
150
- if (cmd === 'node') return paneId;
151
- }
152
- return null;
153
- }
154
-
155
- function paneCount(windowIndex) {
156
- const r = tmux(['list-panes', '-t', `${SESSION}:${windowIndex}`, '-F', '#{pane_id}']);
157
- if (r.status !== 0) return 0;
158
- return r.stdout.trim().split('\n').filter(Boolean).length;
159
- }
160
-
161
113
  function findWindowsByBasename(name) {
162
114
  const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_name}']);
163
115
  if (r.status !== 0) return [];
@@ -316,29 +268,7 @@ function cmdSwitch(statefile, longMode) {
316
268
  // ---------------------------------------------------------------------------
317
269
 
318
270
  function cmdList() {
319
- const sessionR = tmux(['has-session', '-t', SESSION]);
320
- if (sessionR.status !== 0) {
321
- console.log('No opencode servers running.');
322
- return;
323
- }
324
-
325
- const listR = tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}\t#{window_name}']);
326
- if (listR.status !== 0 || !listR.stdout.trim()) {
327
- console.log('No opencode servers running.');
328
- return;
329
- }
330
-
331
- const rows = [];
332
- for (const line of listR.stdout.trim().split('\n').filter(Boolean)) {
333
- const [idx, dir] = line.split('\t');
334
- const sf = path.join(dir, '.ocmux.json');
335
- if (!fs.existsSync(sf)) continue;
336
- const state = readState(sf);
337
- if (!state || !state.url) continue;
338
- const tui = tuiPaneId(parseInt(idx, 10));
339
- rows.push({ url: state.url, dir, status: tui ? 'alive' : 'dead' });
340
- }
341
-
271
+ const rows = listServers();
342
272
  if (rows.length === 0) {
343
273
  console.log('No opencode servers running.');
344
274
  return;
@@ -409,6 +339,9 @@ function doNew(targetDir, longMode) {
409
339
 
410
340
  pinWindowName(winIdx);
411
341
 
342
+ // Truncate log file so stale URL from previous run can't be matched by polling
343
+ try { fs.truncateSync(lf); } catch { /* may not exist yet */ }
344
+
412
345
  // Send the command to the interactive shell (like typing it manually)
413
346
  tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`,
414
347
  `opencode serve --port 0 2>&1 | tee '${lf}'`, 'Enter']);