agentp 0.1.2 → 0.3.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
@@ -5,7 +5,10 @@
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
- `agentp` is a tiny CLI that pipes prompt text into a running OpenCode TUI session and streams the assistant final answer back to stdout.
8
+ This package provides two CLI tools:
9
+
10
+ - **`agentp`** — pipes prompt text into a running OpenCode TUI session and streams the assistant final answer back to stdout
11
+ - **`ocmux`** — manages OpenCode server + TUI sessions in tmux (create, switch, kill, list)
9
12
 
10
13
  It is designed for prompt-driven workflows where you want to do things like:
11
14
 
@@ -36,8 +39,8 @@ npm link
36
39
  Notes:
37
40
 
38
41
  - `agentp` connects to the OpenCode event endpoint over HTTP.
39
- - In practice this means running `opencode --serve` (or equivalent serve mode) so the port is open.
40
- - `opencode --attach` is optional but useful to monitor the full conversation in another terminal/tmux pane.
42
+ - In practice this means running `opencode serve` (or equivalent serve mode) so the port is open.
43
+ - `opencode attach` is optional but useful to monitor the full conversation in another terminal/tmux pane.
41
44
 
42
45
  ## Usage
43
46
 
@@ -47,7 +50,8 @@ agentp [options] [url]
47
50
 
48
51
  Options:
49
52
 
50
- - `--qa`: print question/answer separators around the streamed answer
53
+ - `--qa`: print the original prompt and answer with labels (useful when used as a filter)
54
+ - `--version`: show version
51
55
  - `--help`: show help message
52
56
 
53
57
  Arguments:
@@ -75,10 +79,10 @@ Send a multi-line prompt from a file:
75
79
  cat prompt.txt | agentp
76
80
  ```
77
81
 
78
- Use QA separators and explicit port:
82
+ Use an explicit port:
79
83
 
80
84
  ```bash
81
- cat prompt.txt | agentp --qa 4096
85
+ cat prompt.txt | agentp 4096
82
86
  ```
83
87
 
84
88
  Capture answer to a file:
@@ -99,13 +103,53 @@ From Vim/Neovim, send the current visual selection and replace it in place with
99
103
  :'<,'>!agentp
100
104
  ```
101
105
 
102
- From Vim/Neovim, send the current visual selection and keep QA separators in the same file:
106
+ From Vim/Neovim, send the current visual selection and keep the prompt with the answer:
103
107
 
104
108
  ```vim
105
109
  :'<,'>!agentp --qa
106
110
  ```
107
111
 
108
- ## How It Works
112
+ ## ocmux
113
+
114
+ Manage OpenCode server + TUI in tmux for project directories.
115
+
116
+ ```bash
117
+ ocmux [-l] [<subcommand>] [<directory>]
118
+ ```
119
+
120
+ Without arguments, searches upward from `<directory>` (default: `$PWD`) for `.ocmux.json` and switches to that server's tmux window.
121
+
122
+ Subcommands:
123
+
124
+ - **`new [--git|--GIT] [dir]`** — Create a new server in `dir` (default: `$PWD`). Errors if one already exists there. Warns if a parent directory already has a server.
125
+ - `--git` resolves `dir` to the nearest parent with a `.git` entry (file or dir); errors if none is found.
126
+ - `--GIT` resolves `dir` to the nearest parent with a `.git` directory only; errors if none is found.
127
+ - **`kill [dir]`** — Kill the server found upward from `dir`. Removes its tmux window and state file.
128
+ - **`list`** — List all running servers with their directories, URLs, and status.
129
+
130
+ Options:
131
+
132
+ - `-l`: long output for default/new commands (append `→ <dir>` after the URL)
133
+ - `--version`: show version
134
+ - `-h`: show help message
135
+ - `--`: treat the next argument as a directory even if it matches a subcommand name
136
+
137
+ Notes:
138
+
139
+ - If `<directory>` is not a valid path, `ocmux` tries to match it against the basenames of existing sessions (exact unique match).
140
+ - When using `--git`/`--GIT`, `ocmux` refuses to create a new server above an existing `.ocmux.json` found while searching for the git root.
141
+
142
+ ### Requirements
143
+
144
+ - Node.js 18+
145
+ - [tmux](https://github.com/tmux/tmux) — session multiplexer
146
+ - `opencode` binary in PATH (the OpenCode CLI)
147
+
148
+ ### State file
149
+
150
+ `ocmux` stores per-project state in `.ocmux.json` (contains URL, log path, window index). It searches upward from the target directory, similar to git.
151
+
152
+ ## How agentp Works
109
153
 
110
154
  1. Clears the current TUI prompt input.
111
155
  2. Appends each line from stdin to the TUI prompt.
@@ -115,7 +159,7 @@ From Vim/Neovim, send the current visual selection and keep QA separators in the
115
159
 
116
160
  Operational hint:
117
161
 
118
- - 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.
162
+ - 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.
119
163
 
120
164
  ## License
121
165
 
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) => {
@@ -158,23 +159,30 @@ async function main() {
158
159
 
159
160
  const args = process.argv.slice(2);
160
161
  for (let i = 0; i < args.length; i++) {
161
- if (args[i] === '--help') {
162
+ if (args[i] === '--version') {
163
+ console.log(version);
164
+ process.exit(0);
165
+ } else if (args[i] === '--help') {
162
166
  console.error('Usage: agentp [options] [url]');
163
167
  console.error('');
164
168
  console.error('Pipe prompt text to an OpenCode TUI session and stream the assistant response to stdout.');
165
169
  console.error('');
166
170
  console.error('Options:');
167
- console.error(' --qa Print question/answer separators before streaming output');
171
+ console.error(' --version Show version');
172
+ console.error(' --qa Print the original prompt and answer with labels (useful as a filter)');
168
173
  console.error(' --help Show this help message');
169
174
  console.error('');
170
175
  console.error('Arguments:');
171
176
  console.error(' url OpenCode TUI server URL or port number (default: 4096)');
172
177
  console.error(' Examples: 4096, http://localhost:4096, http://192.168.1.50:4096');
173
178
  console.error('');
179
+ console.error('See also: ocmux -- manage OpenCode tmux sessions (ocmux --help)');
180
+ console.error('');
174
181
  console.error('Examples:');
175
182
  console.error(' printf "Summarize this file" | agentp');
176
- console.error(' cat prompt.txt | agentp --qa 4096');
177
- console.error(' cat prompt.txt | agentp http://192.168.1.50:4096');
183
+ console.error(' cat prompt.txt | agentp 4096');
184
+ console.error(' # Vim/Neovim filter, preserving prompt + answer:');
185
+ console.error(" :'<,'>!agentp --qa");
178
186
  process.exit(0);
179
187
  } else if (args[i] === '--qa') {
180
188
  qaMode = true;
package/bin/ocmux ADDED
@@ -0,0 +1,604 @@
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 findParentGitDir(startDir, requireDir) {
70
+ let dir = path.resolve(startDir);
71
+ let stateDir = null;
72
+ while (true) {
73
+ if (!stateDir && fs.existsSync(path.join(dir, '.ocmux.json'))) {
74
+ stateDir = dir;
75
+ }
76
+
77
+ const gitPath = path.join(dir, '.git');
78
+ if (fs.existsSync(gitPath)) {
79
+ if (!requireDir) {
80
+ return { dir, stateDir, gitPath };
81
+ }
82
+ try {
83
+ if (fs.statSync(gitPath).isDirectory()) {
84
+ return { dir, stateDir, gitPath };
85
+ }
86
+ } catch {
87
+ // ignore stat errors
88
+ }
89
+ }
90
+
91
+ if (dir === HOME || dir === path.dirname(dir)) return { dir: null, stateDir, gitPath: null };
92
+ dir = path.dirname(dir);
93
+ }
94
+ }
95
+
96
+ function resolveGitDir(startDir, requireDir, flagLabel) {
97
+ const result = findParentGitDir(startDir, requireDir);
98
+ if (!result.dir) {
99
+ const label = requireDir ? '.git directory' : '.git entry';
100
+ console.error(`Error: no ${label} found in ${startDir} or any of its parents.`);
101
+ console.error(`Run '${SCRIPT} new' without ${flagLabel} to create a server in the current directory.`);
102
+ process.exit(1);
103
+ }
104
+
105
+ if (result.stateDir && result.stateDir !== result.dir) {
106
+ console.error(`Error: found an existing ocmux server in ${result.stateDir}.`);
107
+ console.error(`Refusing to create a new server at ${result.dir} because it is above that session.`);
108
+ console.error(`Move outside ${result.stateDir} or pass an explicit directory.`);
109
+ process.exit(1);
110
+ }
111
+
112
+ return result.dir;
113
+ }
114
+
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
+ function findWindowsByBasename(name) {
162
+ const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_name}']);
163
+ if (r.status !== 0) return [];
164
+ return r.stdout.trim().split('\n').filter(Boolean).filter(w => path.basename(w) === name);
165
+ }
166
+
167
+ function resolveDir(arg) {
168
+ const resolved = path.resolve(arg);
169
+ try {
170
+ if (fs.statSync(resolved).isDirectory()) return resolved;
171
+ } catch {
172
+ // not a valid path or inaccessible
173
+ }
174
+
175
+ if (!arg.includes('/')) {
176
+ const matches = findWindowsByBasename(arg);
177
+ if (matches.length === 1) return matches[0];
178
+ if (matches.length > 1) {
179
+ console.error(`Error: more than one match for '${arg}':`);
180
+ for (const m of matches) console.error(` ${m}`);
181
+ process.exit(1);
182
+ }
183
+ }
184
+
185
+ console.error(`Error: invalid directory '${arg}'`);
186
+ console.error(`Run '${SCRIPT} -h' for help.`);
187
+ process.exit(1);
188
+ }
189
+
190
+ function ensureTuiZoomed(windowIndex) {
191
+ const r = tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}|#{window_zoomed_flag}']);
192
+ if (r.status !== 0) return;
193
+ for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
194
+ const [wi, zoomed] = line.split('|');
195
+ if (parseInt(wi, 10) === windowIndex && zoomed === '1') return;
196
+ }
197
+ const paneId = tuiPaneId(windowIndex);
198
+ if (paneId) tmux(['resize-pane', '-Z', '-t', paneId]);
199
+ }
200
+
201
+ function pinWindowName(windowIndex) {
202
+ tmux(['set-window-option', '-t', `${SESSION}:${windowIndex}`, 'automatic-rename', 'off']);
203
+ }
204
+
205
+ function ensureSession() {
206
+ const r = tmux(['has-session', '-t', SESSION]);
207
+ if (r.status !== 0) {
208
+ const rc = tmux(['new-session', '-d', '-s', SESSION]);
209
+ if (rc.status !== 0) {
210
+ console.error('Error: failed to create tmux session');
211
+ process.exit(1);
212
+ }
213
+ }
214
+ }
215
+
216
+ function outputUrl(url, dir, longMode) {
217
+ if (longMode) {
218
+ console.log(`${url} → ${dir}`);
219
+ } else {
220
+ console.log(url);
221
+ }
222
+ }
223
+
224
+ function printHelp() {
225
+ console.error(`Usage: ${SCRIPT} [-l] [<subcommand>] [<directory>]`);
226
+ console.error('');
227
+ console.error('Manage opencode server + TUI in tmux for project directories.');
228
+ console.error('');
229
+ console.error('Without arguments, searches upward from <directory> (default: $PWD)');
230
+ console.error("for .ocmux.json and switches to that server's tmux window.");
231
+ console.error('If <directory> is not a valid path, ocmux will match it against the');
232
+ console.error("basenames of existing sessions' directories. If exactly one matches,");
233
+ console.error('that session is used.');
234
+ console.error('');
235
+ console.error('Subcommands:');
236
+ console.error(' new Create a new server in <directory> (default: $PWD).');
237
+ console.error(' Errors if one already exists there.');
238
+ console.error(' Warns if a parent directory already has a server.');
239
+ console.error(' --git Use the nearest parent with a .git entry (file or dir).');
240
+ console.error(' --GIT Use the nearest parent with a .git directory only.');
241
+ console.error(' Refuses to create above an existing .ocmux.json found while searching.');
242
+ console.error(' kill Kill the server found upward from <directory>.');
243
+ console.error(' Removes its tmux window and state file.');
244
+ console.error(' list List all running servers with their directories and URLs.');
245
+ console.error('');
246
+ console.error(' -- Treat the next argument as a directory even if it matches');
247
+ console.error(' a subcommand name (e.g. "ocmux -- kill").');
248
+ console.error('');
249
+ console.error('Options:');
250
+ console.error(' -l Long output: append " → <dir>" after the URL (new/default).');
251
+ console.error(' --version Show version');
252
+ console.error(' -h Show this help');
253
+ console.error('');
254
+ console.error('See also: agentp -- pipe prompts into OpenCode from stdin');
255
+ process.exit(0);
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Subcommand: default — switch to an existing server
260
+ // ---------------------------------------------------------------------------
261
+
262
+ function cmdSwitch(statefile, longMode) {
263
+ const serverDir = path.dirname(statefile);
264
+ const state = readState(statefile);
265
+
266
+ if (!state || !state.url || state.window_index == null) {
267
+ console.error(`Error: invalid state file ${statefile}`);
268
+ process.exit(1);
269
+ }
270
+
271
+ let { url, window_index: winIdx } = state;
272
+
273
+ const actualName = windowNameByIndex(winIdx);
274
+ if (actualName !== serverDir) {
275
+ const found = windowByDir(serverDir);
276
+ if (found == null) {
277
+ console.error(`Error: tmux window for ${serverDir} not found`);
278
+ console.error("Run 'ocmux new' to create a new server.");
279
+ process.exit(1);
280
+ }
281
+ winIdx = found;
282
+ }
283
+
284
+ pinWindowName(winIdx);
285
+
286
+ const tuiPane = tuiPaneId(winIdx);
287
+ if (!tuiPane) {
288
+ console.error('TUI pane is dead, restarting...');
289
+ const count = paneCount(winIdx);
290
+ if (count >= 2) {
291
+ const panesR = tmux(['list-panes', '-t', `${SESSION}:${winIdx}`, '-F', '#{pane_id}']);
292
+ const panes = panesR.stdout.trim().split('\n').filter(Boolean);
293
+ const lastPane = panes[panes.length - 1];
294
+ const respawnR = tmux(['respawn-pane', '-k', '-t', lastPane, `opencode attach --continue '${url}'`]);
295
+ if (respawnR.status !== 0) {
296
+ tmux(['send-keys', '-t', lastPane, `opencode attach --continue '${url}'`, 'Enter']);
297
+ }
298
+ } else {
299
+ tmux(['split-window', '-v', '-t', `${SESSION}:${winIdx}`, '-c', serverDir,
300
+ `opencode attach --continue '${url}'`]);
301
+ }
302
+ }
303
+
304
+ const activeWin = activeWindowIndex();
305
+ if (activeWin !== winIdx) {
306
+ tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
307
+ }
308
+
309
+ ensureTuiZoomed(winIdx);
310
+ outputUrl(url, serverDir, longMode);
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Subcommand: list
315
+ // ---------------------------------------------------------------------------
316
+
317
+ function cmdList() {
318
+ const sessionR = tmux(['has-session', '-t', SESSION]);
319
+ if (sessionR.status !== 0) {
320
+ console.log('No opencode servers running.');
321
+ return;
322
+ }
323
+
324
+ const listR = tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}\t#{window_name}']);
325
+ if (listR.status !== 0 || !listR.stdout.trim()) {
326
+ console.log('No opencode servers running.');
327
+ return;
328
+ }
329
+
330
+ const rows = [];
331
+ for (const line of listR.stdout.trim().split('\n').filter(Boolean)) {
332
+ const [idx, dir] = line.split('\t');
333
+ const sf = path.join(dir, '.ocmux.json');
334
+ if (!fs.existsSync(sf)) continue;
335
+ const state = readState(sf);
336
+ if (!state || !state.url) continue;
337
+ const tui = tuiPaneId(parseInt(idx, 10));
338
+ rows.push({ url: state.url, dir, status: tui ? 'alive' : 'dead' });
339
+ }
340
+
341
+ if (rows.length === 0) {
342
+ console.log('No opencode servers running.');
343
+ return;
344
+ }
345
+
346
+ const urlW = Math.max(...rows.map(r => r.url.length), 3);
347
+ const dirW = Math.max(...rows.map(r => r.dir.length), 9);
348
+ const stsW = Math.max(...rows.map(r => r.status.length), 6);
349
+
350
+ const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
351
+
352
+ console.log(`${pad('URL', urlW)} ${pad('Directory', dirW)} ${pad('Status', stsW)}`);
353
+ for (const row of rows) {
354
+ console.log(`${pad(row.url, urlW)} ${pad(row.dir, dirW)} ${pad(row.status, stsW)}`);
355
+ }
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // Subcommand: new
360
+ // ---------------------------------------------------------------------------
361
+
362
+ function cmdNew(targetDir, longMode) {
363
+ const sf = path.join(targetDir, '.ocmux.json');
364
+ if (fs.existsSync(sf)) {
365
+ console.error(`Error: a server already exists in ${targetDir}.`);
366
+ console.error("Run 'ocmux' without arguments to switch to it, or 'ocmux kill' to stop it first.");
367
+ process.exit(1);
368
+ }
369
+
370
+ const parent = findParentStatefile(path.dirname(targetDir));
371
+ if (parent) {
372
+ const parentDir = path.dirname(parent);
373
+ console.error(`Warning: there is already an opencode server in ${parentDir}.`);
374
+ console.error("Running 'ocmux' without arguments would switch to that server.");
375
+
376
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
377
+ rl.question('Are you sure you want to create a new server here? [y/N] ', (answer) => {
378
+ rl.close();
379
+ if (!/^[yY]([eE][sS])?$/.test(answer.trim())) {
380
+ console.error('Cancelled.');
381
+ process.exit(0);
382
+ }
383
+ doNew(targetDir, longMode);
384
+ });
385
+ } else {
386
+ doNew(targetDir, longMode);
387
+ }
388
+ }
389
+
390
+ function doNew(targetDir, longMode) {
391
+ ensureSession();
392
+ const lf = logfileFor(targetDir);
393
+
394
+ console.error(`Starting opencode server for ${targetDir}...`);
395
+
396
+ const rc = tmux(['new-window', '-d', '-t', SESSION, '-n', targetDir, '-c', targetDir,
397
+ `opencode serve --port 0 2>&1 | tee '${lf}'`]);
398
+ if (rc.status !== 0) {
399
+ console.error(`Error: failed to create tmux window: ${rc.stderr}`);
400
+ process.exit(1);
401
+ }
402
+
403
+ // Wait up to 10s for the URL in the logfile
404
+ let url = null;
405
+ for (let i = 0; i < 50; i++) {
406
+ sleep(0.2);
407
+ const log = (() => { try { return fs.readFileSync(lf, 'utf8'); } catch { return ''; } })();
408
+ const m = log.match(URL_RE);
409
+ if (m) { url = m[1]; break; }
410
+ }
411
+
412
+ if (!url) {
413
+ console.error('Error: opencode server did not start within 10 seconds');
414
+ const wi = windowByDir(targetDir);
415
+ if (wi != null) tmux(['kill-window', '-t', `${SESSION}:${wi}`]);
416
+ process.exit(1);
417
+ }
418
+
419
+ const winIdx = windowByDir(targetDir);
420
+ if (winIdx == null) {
421
+ console.error('Error: window was not created properly');
422
+ process.exit(1);
423
+ }
424
+
425
+ pinWindowName(winIdx);
426
+
427
+ const state = { url, logfile: lf, window_index: winIdx };
428
+ fs.writeFileSync(path.join(targetDir, '.ocmux.json'), JSON.stringify(state) + '\n');
429
+
430
+ tmux(['split-window', '-v', '-t', `${SESSION}:${winIdx}`, '-c', targetDir,
431
+ `opencode attach --continue '${url}'`]);
432
+ sleep(0.5);
433
+ ensureTuiZoomed(winIdx);
434
+
435
+ const activeWin = activeWindowIndex();
436
+ if (activeWin !== winIdx) {
437
+ tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
438
+ }
439
+
440
+ outputUrl(url, targetDir, longMode);
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // Subcommand: kill
445
+ // ---------------------------------------------------------------------------
446
+
447
+ function cmdKill(statefile) {
448
+ const serverDir = path.dirname(statefile);
449
+ const state = readState(statefile);
450
+
451
+ if (!state || !state.url || state.window_index == null) {
452
+ console.error(`Error: invalid state file ${statefile}`);
453
+ process.exit(1);
454
+ }
455
+
456
+ let { window_index: winIdx } = state;
457
+
458
+ console.error(`Stopping server and removing window for ${serverDir}...`);
459
+
460
+ const sessionR = tmux(['has-session', '-t', SESSION]);
461
+ if (sessionR.status === 0) {
462
+ const actualName = windowNameByIndex(winIdx);
463
+ if (actualName !== serverDir) {
464
+ const found = windowByDir(serverDir);
465
+ if (found != null) winIdx = found;
466
+ }
467
+
468
+ if (winIdx != null) {
469
+ tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
470
+ tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`, 'C-c']);
471
+ sleep(0.3);
472
+ tmux(['kill-window', '-t', `${SESSION}:${winIdx}`]);
473
+ console.error('Window removed.');
474
+ } else {
475
+ console.error(`Tmux window not found for ${serverDir} (cleaning up state file).`);
476
+ }
477
+ } else {
478
+ console.error("No tmux session '" + SESSION + "' found (cleaning up state file).");
479
+ }
480
+
481
+ fs.unlinkSync(statefile);
482
+ console.error('Done.');
483
+ }
484
+
485
+ // ---------------------------------------------------------------------------
486
+ // Argument parsing
487
+ // ---------------------------------------------------------------------------
488
+
489
+ function main() {
490
+ let longMode = false;
491
+ let gitMode = null;
492
+ const positional = [];
493
+ let dirIsForced = false;
494
+
495
+ for (const arg of process.argv.slice(2)) {
496
+ if (arg === '-l') {
497
+ longMode = true;
498
+ } else if (arg === '--git') {
499
+ if (gitMode && gitMode !== 'nearest') {
500
+ console.error("Error: cannot combine '--git' with '--GIT'.");
501
+ process.exit(1);
502
+ }
503
+ gitMode = 'nearest';
504
+ } else if (arg === '--GIT') {
505
+ if (gitMode && gitMode !== 'root') {
506
+ console.error("Error: cannot combine '--git' with '--GIT'.");
507
+ process.exit(1);
508
+ }
509
+ gitMode = 'root';
510
+ } else if (arg === '--version') {
511
+ console.log(version);
512
+ process.exit(0);
513
+ } else if (arg === '-h' || arg === '--help') {
514
+ printHelp();
515
+ } else if (arg === '--') {
516
+ dirIsForced = true;
517
+ } else if (arg.startsWith('-')) {
518
+ if (arg === '--new' || arg === '--kill') {
519
+ console.error(`Error: '${arg}' is no longer a flag. Use 'ocmux ${arg.slice(2)}' instead.`);
520
+ } else {
521
+ console.error(`Error: unknown option '${arg}'`);
522
+ console.error(`Run '${SCRIPT} -h' for help.`);
523
+ }
524
+ process.exit(1);
525
+ } else {
526
+ positional.push(arg);
527
+ }
528
+ }
529
+
530
+ let subcommand = null;
531
+ let targetDir = null;
532
+
533
+ for (const arg of positional) {
534
+ if (dirIsForced) {
535
+ dirIsForced = false;
536
+ if (targetDir) {
537
+ console.error(`Error: unexpected argument '${arg}'`);
538
+ process.exit(1);
539
+ }
540
+ targetDir = resolveDir(arg);
541
+ } else if (arg === 'new' || arg === 'kill' || arg === 'list') {
542
+ if (subcommand) {
543
+ console.error(`Error: multiple subcommands specified: '${subcommand}' and '${arg}'`);
544
+ process.exit(1);
545
+ }
546
+ subcommand = arg;
547
+ } else {
548
+ if (targetDir) {
549
+ console.error(`Error: unexpected argument '${arg}'`);
550
+ process.exit(1);
551
+ }
552
+ targetDir = resolveDir(arg);
553
+ }
554
+ }
555
+
556
+ if (!targetDir) targetDir = process.cwd();
557
+
558
+ // Dispatch
559
+ if (subcommand === 'list') {
560
+ if (gitMode) {
561
+ console.error("Error: '--git' and '--GIT' are only valid with 'new'.");
562
+ process.exit(1);
563
+ }
564
+ if (targetDir !== process.cwd()) {
565
+ console.error("Error: 'list' does not accept a directory path.");
566
+ process.exit(1);
567
+ }
568
+ cmdList();
569
+ } else if (subcommand === 'kill') {
570
+ if (gitMode) {
571
+ console.error("Error: '--git' and '--GIT' are only valid with 'new'.");
572
+ process.exit(1);
573
+ }
574
+ const sf = findParentStatefile(targetDir);
575
+ if (!sf) {
576
+ console.error(`Error: no opencode server found in ${targetDir} or any of its parents.`);
577
+ console.error("Run 'ocmux new' to create a new server.");
578
+ process.exit(1);
579
+ }
580
+ cmdKill(sf);
581
+ } else if (subcommand === 'new') {
582
+ if (gitMode) {
583
+ const flagLabel = gitMode === 'root' ? '--GIT' : '--git';
584
+ targetDir = resolveGitDir(targetDir, gitMode === 'root', flagLabel);
585
+ }
586
+ cmdNew(targetDir, longMode);
587
+ } else {
588
+ if (gitMode) {
589
+ console.error("Error: '--git' and '--GIT' are only valid with 'new'.");
590
+ process.exit(1);
591
+ }
592
+ // Default mode
593
+ const sf = findParentStatefile(targetDir);
594
+ if (!sf) {
595
+ console.error(`Error: no opencode server found in ${targetDir} or any of its parents.`);
596
+ console.error("Run 'ocmux new' to create a new server.");
597
+ process.exit(1);
598
+ }
599
+ ensureSession();
600
+ cmdSwitch(sf, longMode);
601
+ }
602
+ }
603
+
604
+ main();
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "0.1.2",
3
+ "version": "0.3.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}