agentp 0.1.2 → 0.2.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,38 +1,48 @@
1
1
  # agentp — agent notes
2
2
 
3
- ## What it is
3
+ ## Two binaries, both registered
4
4
 
5
- Single-file Node.js CLI (`bin/agentp`) that pipes stdin into a running OpenCode TUI session and streams the assistant answer to stdout. Zero npm dependencies, CommonJS, no build step.
5
+ - `bin/agentp` (Node.js) — registered in `package.json` `"bin"` — pipes stdin → opencode TUI
6
+ - `bin/ocmux` (Node.js) — registered in `package.json` `"bin"` — manages opencode tmux sessions
6
7
 
7
- ## Entrypoint
8
+ ## Package facts
8
9
 
9
- `bin/agentp` — the only source file. No other modules.
10
+ - **Zero npm dependencies.** Both scripts use only Node.js stdlib (`http`, `readline`, `url`, `child_process`, `fs`, `path`, `crypto`, `os`).
11
+ - **No tests, no CI, no lint, no formatter.** `npm test` is a stub that exits 1.
12
+ - **No lockfile.** Versions resolve at install time.
13
+ - **`"type": "commonjs"`**, requires Node >= 18.
14
+ - **Linux and macOS compatible.** Both tools use portable Node.js APIs only.
10
15
 
11
- ## Commands
16
+ ## `.ocmux.json`
12
17
 
13
- | Action | Command |
14
- |---|---|
15
- | Dev install | `npm link` or `npm install -g .` |
16
- | Run | `printf "prompt" \| agentp` |
18
+ Runtime state file created by `bin/ocmux` (contains URL, log path, and window index). **Not in `.gitignore`** — avoid committing it.
17
19
 
18
- No tests, no lint, no typecheck, no codegen. `npm test` is a noop placeholder.
20
+ ## OpenCode HTTP protocol (agentp)
19
21
 
20
- ## Requirements
22
+ These endpoints are consumed but not documented elsewhere in the repo:
21
23
 
22
- - Node.js 18+
23
- - `opencode --serve` running locally (default port `4096`). See `.ocmux.json` for the local server URL/log path.
24
- - `opencode --attach` optional but useful to monitor conversations.
24
+ - `POST /tui/clear-prompt`
25
+ - `POST /tui/append-prompt`
26
+ - `POST /tui/submit-prompt`
27
+ - `GET /event` (SSE stream)
25
28
 
26
- ## Architecture
29
+ ## OpenCode tmux session model (ocmux)
27
30
 
28
- - Communicates with OpenCode via HTTP: `/tui/clear-prompt`, `/tui/append-prompt`, `/tui/submit-prompt`, `/event` (SSE).
29
- - Default server `http://localhost:4096`; override as positional arg with a port number (`agentp 4097`) or full URL (`agentp http://host:4096`)
30
- - `--qa` flag prints human/agent separator rows before streaming output.
31
- - Streams assistant `text` parts from SSE, filters out user echo by tracking `message.updated` events with role `user`.
32
- - Stops on `session.idle` event.
31
+ - Single tmux session named `"Opencode"`
32
+ - Windows named by full project directory path
33
+ - Server pane runs `opencode serve --port 0`, TUI pane runs `opencode attach --continue`
34
+ - Per-project `.ocmux.json` with upward directory search (git-like)
35
+ - Auto-restarts dead TUI panes on switch
36
+ - Auto-zooms TUI pane on switch/create
33
37
 
34
- ## Gotchas
38
+ ## Commands
35
39
 
36
- - Stdin is consumed line-byline via `readline`. Each line is sent as a separate `append-prompt` request (no batching).
37
- - The script does NOT handle OpenCode not being running — it will crash with a connection error.
38
- - No test suite exists; manual testing via `printf "hi" | node bin/agentp`.
40
+ | Command | What |
41
+ |---|---|
42
+ | `npm link` | local dev install |
43
+ | `npm install -g .` | alternative local install |
44
+ | `agentp [--qa] [port]` | pipe stdin → opencode TUI, stream answer to stdout |
45
+ | `ocmux new [dir]` | start opencode serve in a tmux window |
46
+ | `ocmux kill [dir]` | kill server + tmux window |
47
+ | `ocmux list [-l]` | list running servers |
48
+ | `ocmux [dir]` | switch to existing opencode tmux window |
package/README.md CHANGED
@@ -1,11 +1,9 @@
1
1
  # agentp
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/agentp.svg)](https://www.npmjs.com/package/agentp)
4
- [![npm license](https://img.shields.io/npm/l/agentp.svg)](https://www.npmjs.com/package/agentp)
5
- [![npm downloads](https://img.shields.io/npm/dm/agentp.svg)](https://www.npmjs.com/package/agentp)
6
- [![node version](https://img.shields.io/node/v/agentp.svg)](https://www.npmjs.com/package/agentp)
3
+ This package provides two CLI tools:
7
4
 
8
- `agentp` is a tiny CLI that pipes prompt text into a running OpenCode TUI session and streams the assistant final answer back to stdout.
5
+ - **`agentp`** — pipes prompt text into a running OpenCode TUI session and streams the assistant final answer back to stdout
6
+ - **`ocmux`** — manages OpenCode server + TUI sessions in tmux (create, switch, kill, list)
9
7
 
10
8
  It is designed for prompt-driven workflows where you want to do things like:
11
9
 
@@ -42,7 +40,7 @@ Notes:
42
40
  ## Usage
43
41
 
44
42
  ```bash
45
- agentp [options] [url]
43
+ agentp [options] [port]
46
44
  ```
47
45
 
48
46
  Options:
@@ -52,7 +50,7 @@ Options:
52
50
 
53
51
  Arguments:
54
52
 
55
- - `url`: OpenCode TUI server URL or port number (defaults to `4096`). Examples: `4096`, `http://localhost:4096`, `http://192.168.1.50:4096`
53
+ - `port`: OpenCode TUI port (defaults to `4096`)
56
54
 
57
55
  ## Examples
58
56
 
@@ -87,12 +85,6 @@ Capture answer to a file:
87
85
  cat prompt.txt | agentp > answer.txt
88
86
  ```
89
87
 
90
- Connect to a remote OpenCode server:
91
-
92
- ```bash
93
- cat prompt.txt | agentp http://192.168.1.50:4096
94
- ```
95
-
96
88
  From Vim/Neovim, send the current visual selection and replace it in place with the assistant answer:
97
89
 
98
90
  ```vim
@@ -105,7 +97,33 @@ From Vim/Neovim, send the current visual selection and keep QA separators in the
105
97
  :'<,'>!agentp --qa
106
98
  ```
107
99
 
108
- ## How It Works
100
+ ## ocmux
101
+
102
+ Manage OpenCode server + TUI in tmux for project directories.
103
+
104
+ ```bash
105
+ ocmux [-l] [<subcommand>] [<directory>]
106
+ ```
107
+
108
+ Without arguments, searches upward from `<directory>` (default: `$PWD`) for `.ocmux.json` and switches to that server's tmux window.
109
+
110
+ Subcommands:
111
+
112
+ - **`new [dir]`** — Create a new server in `dir` (default: `$PWD`). Errors if one already exists there. Warns if a parent directory already has a server.
113
+ - **`kill [dir]`** — Kill the server found upward from `dir`. Removes its tmux window and state file.
114
+ - **`list [-l]`** — List all running servers with their directories, URLs, and status.
115
+
116
+ ### Requirements
117
+
118
+ - Node.js 18+
119
+ - [tmux](https://github.com/tmux/tmux) — session multiplexer
120
+ - `opencode` binary in PATH (the OpenCode CLI)
121
+
122
+ ### State file
123
+
124
+ `ocmux` stores per-project state in `.ocmux.json` (contains URL, log path, window index). It searches upward from the target directory, similar to git.
125
+
126
+ ## How agentp Works
109
127
 
110
128
  1. Clears the current TUI prompt input.
111
129
  2. Appends each line from stdin to the TUI prompt.
package/bin/agentp CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const http = require('http');
4
4
  const { URL } = require('url');
5
+ const { version } = require('../package.json');
5
6
 
6
7
  function makeRequest(options, data) {
7
8
  return new Promise((resolve, reject) => {
@@ -46,7 +47,8 @@ async function submitPrompt(server) {
46
47
  await makeRequest(buildJsonRequest(url, 'POST', '{}'), '{}');
47
48
  }
48
49
 
49
- async function sendPrompt(server) {
50
+ async function sendPrompt(port) {
51
+ const server = `http://localhost:${port}`;
50
52
  let promptText = '';
51
53
 
52
54
  await clearPrompt(server);
@@ -69,9 +71,9 @@ const HUMAN_ROW = '👤:  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄
69
71
  const AGENT_ROW = '🤖:  ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄\n'
70
72
  const FINAL_ROW = ' ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄ ̄\n';
71
73
 
72
- function listenForFinalAnswer(server, showFinalRow) {
74
+ function listenForFinalAnswer(port, showFinalRow) {
73
75
  return new Promise((resolve, reject) => {
74
- const url = `${server}/event`;
76
+ const url = `http://localhost:${port}/event`;
75
77
  const parsed = new URL(url);
76
78
 
77
79
  const req = http.request({
@@ -153,39 +155,42 @@ function listenForFinalAnswer(server, showFinalRow) {
153
155
  }
154
156
 
155
157
  async function main() {
156
- let serverBase = 'http://localhost:4096';
158
+ let port = 4096;
157
159
  let qaMode = false;
158
160
 
159
161
  const args = process.argv.slice(2);
160
162
  for (let i = 0; i < args.length; i++) {
161
- if (args[i] === '--help') {
162
- console.error('Usage: agentp [options] [url]');
163
+ if (args[i] === '--version') {
164
+ console.log(version);
165
+ process.exit(0);
166
+ } else if (args[i] === '--help') {
167
+ console.error('Usage: agentp [options] [port]');
163
168
  console.error('');
164
- console.error('Pipe prompt text to an OpenCode TUI session and stream the assistant response to stdout.');
169
+ console.error('Pipe prompt text to an OpenCode TUI session running on localhost.');
170
+ console.error('The assistant response is streamed to stdout.');
165
171
  console.error('');
166
172
  console.error('Options:');
173
+ console.error(' --version Show version');
167
174
  console.error(' --qa Print question/answer separators before streaming output');
168
175
  console.error(' --help Show this help message');
169
176
  console.error('');
170
177
  console.error('Arguments:');
171
- console.error(' url OpenCode TUI server URL or port number (default: 4096)');
172
- console.error(' Examples: 4096, http://localhost:4096, http://192.168.1.50:4096');
178
+ console.error(' port OpenCode TUI port (default: 4096)');
179
+ console.error('');
180
+ console.error('See also: ocmux -- manage OpenCode tmux sessions (ocmux --help)');
173
181
  console.error('');
174
182
  console.error('Examples:');
175
183
  console.error(' printf "Summarize this file" | agentp');
176
184
  console.error(' cat prompt.txt | agentp --qa 4096');
177
- console.error(' cat prompt.txt | agentp http://192.168.1.50:4096');
178
185
  process.exit(0);
179
186
  } else if (args[i] === '--qa') {
180
187
  qaMode = true;
181
- } else if (args[i].includes('://')) {
182
- serverBase = args[i].replace(/\/+$/, '');
183
- } else if (/^\d+$/.test(args[i])) {
184
- serverBase = 'http://localhost:' + args[i];
188
+ } else if (!isNaN(args[i]) && /^\d+$/.test(args[i])) {
189
+ port = parseInt(args[i], 10);
185
190
  }
186
191
  }
187
192
 
188
- const promptText = await sendPrompt(serverBase);
193
+ const promptText = await sendPrompt(port);
189
194
 
190
195
  if (qaMode) {
191
196
  process.stdout.write(HUMAN_ROW);
@@ -193,7 +198,7 @@ async function main() {
193
198
  process.stdout.write(AGENT_ROW);
194
199
  }
195
200
 
196
- await listenForFinalAnswer(serverBase, qaMode);
201
+ await listenForFinalAnswer(port, qaMode);
197
202
  }
198
203
 
199
204
  main().catch((err) => {
package/bin/ocmux ADDED
@@ -0,0 +1,526 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const readline = require('readline');
7
+ const { spawnSync, execSync } = require('child_process');
8
+ const os = require('os');
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Constants
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const { version } = require('../package.json');
15
+
16
+ const SESSION = 'Opencode';
17
+ const SCRIPT = path.basename(process.argv[1] || 'ocmux');
18
+ const HOME = os.homedir();
19
+ const URL_RE = /opencode server listening on (http:\/\/[^\s]*)/;
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Helpers
23
+ // ---------------------------------------------------------------------------
24
+
25
+ function tmux(args) {
26
+ const result = spawnSync('tmux', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 });
27
+ if (result.error && result.error.code === 'ENOENT') {
28
+ console.error('Error: tmux is not installed or not in PATH');
29
+ process.exit(1);
30
+ }
31
+ return result;
32
+ }
33
+
34
+ function sleep(seconds) {
35
+ execSync(`sleep ${seconds}`, { stdio: 'ignore' });
36
+ }
37
+
38
+ function hashDir(dir) {
39
+ return crypto.createHash('md5').update(dir).digest('hex').slice(0, 12);
40
+ }
41
+
42
+ function logfileFor(dir) {
43
+ return `/tmp/opencode-serve-${hashDir(dir)}.log`;
44
+ }
45
+
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
+ function findParentStatefile(startDir) {
59
+ let dir = path.resolve(startDir);
60
+ while (true) {
61
+ if (fs.existsSync(path.join(dir, '.ocmux.json'))) {
62
+ return path.join(dir, '.ocmux.json');
63
+ }
64
+ if (dir === HOME || dir === path.dirname(dir)) return null;
65
+ dir = path.dirname(dir);
66
+ }
67
+ }
68
+
69
+ function windowByDir(dir) {
70
+ const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}\t#{window_name}']);
71
+ if (r.status !== 0) return null;
72
+ for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
73
+ const [idx, name] = line.split('\t');
74
+ if (name === dir) return parseInt(idx, 10);
75
+ }
76
+ return null;
77
+ }
78
+
79
+ function windowNameByIndex(idx) {
80
+ const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}\t#{window_name}']);
81
+ if (r.status !== 0) return null;
82
+ for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
83
+ const [wi, name] = line.split('\t');
84
+ if (parseInt(wi, 10) === idx) return name;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ function activeWindowIndex() {
90
+ const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_index} #{window_active}']);
91
+ if (r.status !== 0) return null;
92
+ for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
93
+ const [idx, active] = line.split(' ');
94
+ if (active === '1') return parseInt(idx, 10);
95
+ }
96
+ return null;
97
+ }
98
+
99
+ function tuiPaneId(windowIndex) {
100
+ const r = tmux(['list-panes', '-t', `${SESSION}:${windowIndex}`, '-F', '#{pane_id}\t#{pane_current_command}']);
101
+ if (r.status !== 0) return null;
102
+ for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
103
+ const [paneId, cmd] = line.split('\t');
104
+ if (cmd === 'node') return paneId;
105
+ }
106
+ return null;
107
+ }
108
+
109
+ function paneCount(windowIndex) {
110
+ const r = tmux(['list-panes', '-t', `${SESSION}:${windowIndex}`, '-F', '#{pane_id}']);
111
+ if (r.status !== 0) return 0;
112
+ return r.stdout.trim().split('\n').filter(Boolean).length;
113
+ }
114
+
115
+ function findWindowsByBasename(name) {
116
+ const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_name}']);
117
+ if (r.status !== 0) return [];
118
+ return r.stdout.trim().split('\n').filter(Boolean).filter(w => path.basename(w) === name);
119
+ }
120
+
121
+ function resolveDir(arg) {
122
+ const resolved = path.resolve(arg);
123
+ try {
124
+ if (fs.statSync(resolved).isDirectory()) return resolved;
125
+ } catch {
126
+ // not a valid path or inaccessible
127
+ }
128
+
129
+ if (!arg.includes('/')) {
130
+ const matches = findWindowsByBasename(arg);
131
+ if (matches.length === 1) return matches[0];
132
+ if (matches.length > 1) {
133
+ console.error(`Error: more than one match for '${arg}':`);
134
+ for (const m of matches) console.error(` ${m}`);
135
+ process.exit(1);
136
+ }
137
+ }
138
+
139
+ console.error(`Error: invalid directory '${arg}'`);
140
+ console.error(`Run '${SCRIPT} -h' for help.`);
141
+ process.exit(1);
142
+ }
143
+
144
+ function ensureTuiZoomed(windowIndex) {
145
+ const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}|#{window_zoomed_flag}']);
146
+ if (r.status !== 0) return;
147
+ for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
148
+ const [wi, zoomed] = line.split('|');
149
+ if (parseInt(wi, 10) === windowIndex && zoomed === '1') return;
150
+ }
151
+ const paneId = tuiPaneId(windowIndex);
152
+ if (paneId) tmux(['resize-pane', '-Z', '-t', paneId]);
153
+ }
154
+
155
+ function pinWindowName(windowIndex) {
156
+ tmux(['set-window-option', '-t', `${SESSION}:${windowIndex}`, 'automatic-rename', 'off']);
157
+ }
158
+
159
+ function ensureSession() {
160
+ const r = tmux(['has-session', '-t', SESSION]);
161
+ if (r.status !== 0) {
162
+ const rc = tmux(['new-session', '-d', '-s', SESSION]);
163
+ if (rc.status !== 0) {
164
+ console.error('Error: failed to create tmux session');
165
+ process.exit(1);
166
+ }
167
+ }
168
+ }
169
+
170
+ function outputUrl(url, dir, longMode) {
171
+ if (longMode) {
172
+ console.log(`${url} → ${dir}`);
173
+ } else {
174
+ console.log(url);
175
+ }
176
+ }
177
+
178
+ function printHelp() {
179
+ console.error(`Usage: ${SCRIPT} [-l] [<subcommand>] [<directory>]`);
180
+ console.error('');
181
+ console.error('Manage opencode server + TUI in tmux for project directories.');
182
+ console.error('');
183
+ console.error('Without arguments, searches upward from <directory> (default: $PWD)');
184
+ console.error("for .ocmux.json and switches to that server's tmux window.");
185
+ console.error('If <directory> is not a valid path, ocmux will match it against the');
186
+ console.error("basenames of existing sessions' directories. If exactly one matches,");
187
+ console.error('that session is used.');
188
+ console.error('');
189
+ console.error('Subcommands:');
190
+ console.error(' new Create a new server in <directory> (default: $PWD).');
191
+ console.error(' Errors if one already exists there.');
192
+ console.error(' Warns if a parent directory already has a server.');
193
+ console.error(' kill Kill the server found upward from <directory>.');
194
+ console.error(' Removes its tmux window and state file.');
195
+ console.error(' list List all running servers with their directories and URLs.');
196
+ console.error('');
197
+ console.error(' -- Treat the next argument as a directory even if it matches');
198
+ console.error(' a subcommand name (e.g. "ocmux -- kill").');
199
+ console.error('');
200
+ console.error('Options:');
201
+ console.error(' -l Long output: append " → <dir>" after the URL.');
202
+ console.error(' --version Show version');
203
+ console.error(' -h Show this help');
204
+ console.error('');
205
+ console.error('See also: agentp -- pipe prompts into OpenCode from stdin');
206
+ process.exit(0);
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Subcommand: default — switch to an existing server
211
+ // ---------------------------------------------------------------------------
212
+
213
+ function cmdSwitch(statefile, longMode) {
214
+ const serverDir = path.dirname(statefile);
215
+ const state = readState(statefile);
216
+
217
+ if (!state || !state.url || state.window_index == null) {
218
+ console.error(`Error: invalid state file ${statefile}`);
219
+ process.exit(1);
220
+ }
221
+
222
+ let { url, window_index: winIdx } = state;
223
+
224
+ const actualName = windowNameByIndex(winIdx);
225
+ if (actualName !== serverDir) {
226
+ const found = windowByDir(serverDir);
227
+ if (found == null) {
228
+ console.error(`Error: tmux window for ${serverDir} not found`);
229
+ console.error("Run 'ocmux new' to create a new server.");
230
+ process.exit(1);
231
+ }
232
+ winIdx = found;
233
+ }
234
+
235
+ pinWindowName(winIdx);
236
+
237
+ const tuiPane = tuiPaneId(winIdx);
238
+ if (!tuiPane) {
239
+ console.error('TUI pane is dead, restarting...');
240
+ const count = paneCount(winIdx);
241
+ if (count >= 2) {
242
+ const panesR = tmux(['list-panes', '-t', `${SESSION}:${winIdx}`, '-F', '#{pane_id}']);
243
+ const panes = panesR.stdout.trim().split('\n').filter(Boolean);
244
+ const lastPane = panes[panes.length - 1];
245
+ const respawnR = tmux(['respawn-pane', '-k', '-t', lastPane, `opencode attach --continue '${url}'`]);
246
+ if (respawnR.status !== 0) {
247
+ tmux(['send-keys', '-t', lastPane, `opencode attach --continue '${url}'`, 'Enter']);
248
+ }
249
+ } else {
250
+ tmux(['split-window', '-v', '-t', `${SESSION}:${winIdx}`, '-c', serverDir,
251
+ `opencode attach --continue '${url}'`]);
252
+ }
253
+ }
254
+
255
+ const activeWin = activeWindowIndex();
256
+ if (activeWin !== winIdx) {
257
+ tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
258
+ }
259
+
260
+ ensureTuiZoomed(winIdx);
261
+ outputUrl(url, serverDir, longMode);
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Subcommand: list
266
+ // ---------------------------------------------------------------------------
267
+
268
+ function cmdList() {
269
+ const sessionR = tmux(['has-session', '-t', SESSION]);
270
+ if (sessionR.status !== 0) {
271
+ console.log('No opencode servers running.');
272
+ return;
273
+ }
274
+
275
+ const listR = tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}\t#{window_name}']);
276
+ if (listR.status !== 0 || !listR.stdout.trim()) {
277
+ console.log('No opencode servers running.');
278
+ return;
279
+ }
280
+
281
+ const rows = [];
282
+ for (const line of listR.stdout.trim().split('\n').filter(Boolean)) {
283
+ const [idx, dir] = line.split('\t');
284
+ const sf = path.join(dir, '.ocmux.json');
285
+ if (!fs.existsSync(sf)) continue;
286
+ const state = readState(sf);
287
+ if (!state || !state.url) continue;
288
+ const tui = tuiPaneId(parseInt(idx, 10));
289
+ rows.push({ url: state.url, dir, status: tui ? 'alive' : 'dead' });
290
+ }
291
+
292
+ if (rows.length === 0) {
293
+ console.log('No opencode servers running.');
294
+ return;
295
+ }
296
+
297
+ const urlW = Math.max(...rows.map(r => r.url.length), 3);
298
+ const dirW = Math.max(...rows.map(r => r.dir.length), 9);
299
+ const stsW = Math.max(...rows.map(r => r.status.length), 6);
300
+
301
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
302
+
303
+ console.log(`${pad('URL', urlW)} ${pad('Directory', dirW)} ${pad('Status', stsW)}`);
304
+ for (const row of rows) {
305
+ console.log(`${pad(row.url, urlW)} ${pad(row.dir, dirW)} ${pad(row.status, stsW)}`);
306
+ }
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // Subcommand: new
311
+ // ---------------------------------------------------------------------------
312
+
313
+ function cmdNew(targetDir, longMode) {
314
+ const sf = path.join(targetDir, '.ocmux.json');
315
+ if (fs.existsSync(sf)) {
316
+ console.error(`Error: a server already exists in ${targetDir}.`);
317
+ console.error("Run 'ocmux' without arguments to switch to it, or 'ocmux kill' to stop it first.");
318
+ process.exit(1);
319
+ }
320
+
321
+ const parent = findParentStatefile(path.dirname(targetDir));
322
+ if (parent) {
323
+ const parentDir = path.dirname(parent);
324
+ console.error(`Warning: there is already an opencode server in ${parentDir}.`);
325
+ console.error("Running 'ocmux' without arguments would switch to that server.");
326
+
327
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
328
+ rl.question('Are you sure you want to create a new server here? [y/N] ', (answer) => {
329
+ rl.close();
330
+ if (!/^[yY]([eE][sS])?$/.test(answer.trim())) {
331
+ console.error('Cancelled.');
332
+ process.exit(0);
333
+ }
334
+ doNew(targetDir, longMode);
335
+ });
336
+ } else {
337
+ doNew(targetDir, longMode);
338
+ }
339
+ }
340
+
341
+ function doNew(targetDir, longMode) {
342
+ ensureSession();
343
+ const lf = logfileFor(targetDir);
344
+
345
+ console.error(`Starting opencode server for ${targetDir}...`);
346
+
347
+ const rc = tmux(['new-window', '-d', '-t', SESSION, '-n', targetDir, '-c', targetDir,
348
+ `opencode serve --port 0 2>&1 | tee '${lf}'`]);
349
+ if (rc.status !== 0) {
350
+ console.error(`Error: failed to create tmux window: ${rc.stderr}`);
351
+ process.exit(1);
352
+ }
353
+
354
+ // Wait up to 10s for the URL in the logfile
355
+ let url = null;
356
+ for (let i = 0; i < 50; i++) {
357
+ sleep(0.2);
358
+ const log = (() => { try { return fs.readFileSync(lf, 'utf8'); } catch { return ''; } })();
359
+ const m = log.match(URL_RE);
360
+ if (m) { url = m[1]; break; }
361
+ }
362
+
363
+ if (!url) {
364
+ console.error('Error: opencode server did not start within 10 seconds');
365
+ const wi = windowByDir(targetDir);
366
+ if (wi != null) tmux(['kill-window', '-t', `${SESSION}:${wi}`]);
367
+ process.exit(1);
368
+ }
369
+
370
+ const winIdx = windowByDir(targetDir);
371
+ if (winIdx == null) {
372
+ console.error('Error: window was not created properly');
373
+ process.exit(1);
374
+ }
375
+
376
+ pinWindowName(winIdx);
377
+
378
+ const state = { url, logfile: lf, window_index: winIdx };
379
+ fs.writeFileSync(path.join(targetDir, '.ocmux.json'), JSON.stringify(state) + '\n');
380
+
381
+ tmux(['split-window', '-v', '-t', `${SESSION}:${winIdx}`, '-c', targetDir,
382
+ `opencode attach --continue '${url}'`]);
383
+ sleep(0.5);
384
+ ensureTuiZoomed(winIdx);
385
+
386
+ const activeWin = activeWindowIndex();
387
+ if (activeWin !== winIdx) {
388
+ tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
389
+ }
390
+
391
+ outputUrl(url, targetDir, longMode);
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // Subcommand: kill
396
+ // ---------------------------------------------------------------------------
397
+
398
+ function cmdKill(statefile) {
399
+ const serverDir = path.dirname(statefile);
400
+ const state = readState(statefile);
401
+
402
+ if (!state || !state.url || state.window_index == null) {
403
+ console.error(`Error: invalid state file ${statefile}`);
404
+ process.exit(1);
405
+ }
406
+
407
+ let { window_index: winIdx } = state;
408
+
409
+ console.error(`Stopping server and removing window for ${serverDir}...`);
410
+
411
+ const sessionR = tmux(['has-session', '-t', SESSION]);
412
+ if (sessionR.status === 0) {
413
+ const actualName = windowNameByIndex(winIdx);
414
+ if (actualName !== serverDir) {
415
+ const found = windowByDir(serverDir);
416
+ if (found != null) winIdx = found;
417
+ }
418
+
419
+ if (winIdx != null) {
420
+ tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
421
+ tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`, 'C-c']);
422
+ sleep(0.3);
423
+ tmux(['kill-window', '-t', `${SESSION}:${winIdx}`]);
424
+ console.error('Window removed.');
425
+ } else {
426
+ console.error(`Tmux window not found for ${serverDir} (cleaning up state file).`);
427
+ }
428
+ } else {
429
+ console.error("No tmux session '" + SESSION + "' found (cleaning up state file).");
430
+ }
431
+
432
+ fs.unlinkSync(statefile);
433
+ console.error('Done.');
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Argument parsing
438
+ // ---------------------------------------------------------------------------
439
+
440
+ function main() {
441
+ let longMode = false;
442
+ const positional = [];
443
+ let dirIsForced = false;
444
+
445
+ for (const arg of process.argv.slice(2)) {
446
+ if (arg === '-l') {
447
+ longMode = true;
448
+ } else if (arg === '--version') {
449
+ console.log(version);
450
+ process.exit(0);
451
+ } else if (arg === '-h' || arg === '--help') {
452
+ printHelp();
453
+ } else if (arg === '--') {
454
+ dirIsForced = true;
455
+ } else if (arg.startsWith('-')) {
456
+ if (arg === '--new' || arg === '--kill') {
457
+ console.error(`Error: '${arg}' is no longer a flag. Use 'ocmux ${arg.slice(2)}' instead.`);
458
+ } else {
459
+ console.error(`Error: unknown option '${arg}'`);
460
+ console.error(`Run '${SCRIPT} -h' for help.`);
461
+ }
462
+ process.exit(1);
463
+ } else {
464
+ positional.push(arg);
465
+ }
466
+ }
467
+
468
+ let subcommand = null;
469
+ let targetDir = null;
470
+
471
+ for (const arg of positional) {
472
+ if (dirIsForced) {
473
+ dirIsForced = false;
474
+ if (targetDir) {
475
+ console.error(`Error: unexpected argument '${arg}'`);
476
+ process.exit(1);
477
+ }
478
+ targetDir = resolveDir(arg);
479
+ } else if (arg === 'new' || arg === 'kill' || arg === 'list') {
480
+ if (subcommand) {
481
+ console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
482
+ process.exit(1);
483
+ }
484
+ subcommand = arg;
485
+ } else {
486
+ if (targetDir) {
487
+ console.error(`Error: unexpected argument '${arg}'`);
488
+ process.exit(1);
489
+ }
490
+ targetDir = resolveDir(arg);
491
+ }
492
+ }
493
+
494
+ if (!targetDir) targetDir = process.cwd();
495
+
496
+ // Dispatch
497
+ if (subcommand === 'list') {
498
+ if (targetDir !== process.cwd()) {
499
+ console.error("Error: 'list' does not accept a directory path.");
500
+ process.exit(1);
501
+ }
502
+ cmdList();
503
+ } else if (subcommand === 'kill') {
504
+ const sf = findParentStatefile(targetDir);
505
+ if (!sf) {
506
+ console.error(`Error: no opencode server found in ${targetDir} or any of its parents.`);
507
+ console.error("Run 'ocmux new' to create a new server.");
508
+ process.exit(1);
509
+ }
510
+ cmdKill(sf);
511
+ } else if (subcommand === 'new') {
512
+ cmdNew(targetDir, longMode);
513
+ } else {
514
+ // Default mode
515
+ const sf = findParentStatefile(targetDir);
516
+ if (!sf) {
517
+ console.error(`Error: no opencode server found in ${targetDir} or any of its parents.`);
518
+ console.error("Run 'ocmux new' to create a new server.");
519
+ process.exit(1);
520
+ }
521
+ ensureSession();
522
+ cmdSwitch(sf, longMode);
523
+ }
524
+ }
525
+
526
+ main();
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Pipe prompts into a running OpenCode TUI session and stream the final answer",
5
5
  "bin": {
6
- "agentp": "bin/agentp"
6
+ "agentp": "bin/agentp",
7
+ "ocmux": "bin/ocmux"
7
8
  },
8
9
  "keywords": [
9
10
  "ai",
package/.ocmux.json DELETED
@@ -1 +0,0 @@
1
- {"url":"http://127.0.0.1:4096","logfile":"/tmp/opencode-serve-6cdb98e4626e.log","window_index":1}