agentp 1.14.0 → 2.0.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.
- package/CONTRIBUTING.md +80 -83
- package/README.md +165 -51
- package/bin/agentp +525 -84
- package/bin/ocmux +1376 -580
- package/docs/specification.md +12 -454
- package/docs/specification_v2.md +652 -0
- package/lib/ocmux.js +123 -197
- package/lib/opencode.js +363 -657
- package/lib/project-state.js +213 -0
- package/package.json +1 -1
package/lib/ocmux.js
CHANGED
|
@@ -1,35 +1,18 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// tmux helpers for the agentp v2 model ("sessions live in projects").
|
|
4
|
+
//
|
|
5
|
+
// A project owns a tmux window (TUI only — pane 0) plus a `.ocmux.json` state
|
|
6
|
+
// file recording the target session. The OpenCode **server is user-managed**
|
|
7
|
+
// (started with `opencode serve`); this module never starts or stops it. All
|
|
8
|
+
// tmux interactions must go through `_tmux()`.
|
|
9
|
+
|
|
3
10
|
const fs = require('fs');
|
|
4
11
|
const path = require('path');
|
|
5
12
|
const crypto = require('crypto');
|
|
6
13
|
const child_process = require('child_process');
|
|
7
14
|
|
|
8
15
|
const SESSION = 'Opencode';
|
|
9
|
-
// OpenCode v2 prints `server listening on http://...`; older versions printed
|
|
10
|
-
// `opencode server listening on http://...`. Accept both.
|
|
11
|
-
const URL_RE = /(?:opencode )?server listening on (http:\/\/[^\s]*)/;
|
|
12
|
-
|
|
13
|
-
// Detect the OpenCode major version: v2 runs the TUI with `opencode --server`,
|
|
14
|
-
// older versions used the `attach` subcommand. Returns null when undetectable.
|
|
15
|
-
function detectOcMajor() {
|
|
16
|
-
const r = child_process.spawnSync('opencode', ['--version'], { encoding: 'utf8' });
|
|
17
|
-
const out = (r.stdout || '') + (r.stderr || '');
|
|
18
|
-
const m = out.match(/v?(\d+)\.(\d+)\./);
|
|
19
|
-
return m ? parseInt(m[1], 10) : null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Command to attach the TUI to a running server. OpenCode v2 dropped the
|
|
23
|
-
// `attach` subcommand in favor of `opencode --server <url>`; older versions
|
|
24
|
-
// still use `opencode attach --continue <url>`. Falls back to the legacy form
|
|
25
|
-
// when the version cannot be detected (keeps old installations working).
|
|
26
|
-
function tuiAttachCommand(url) {
|
|
27
|
-
const major = detectOcMajor();
|
|
28
|
-
if (major !== null && major >= 2) {
|
|
29
|
-
return `opencode --server '${url}' --continue`;
|
|
30
|
-
}
|
|
31
|
-
return `opencode attach --continue '${url}'`;
|
|
32
|
-
}
|
|
33
16
|
|
|
34
17
|
function _tmux(args) {
|
|
35
18
|
const result = child_process.spawnSync('tmux', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 });
|
|
@@ -74,15 +57,18 @@ function statefileFor(dir) {
|
|
|
74
57
|
return path.join(dir, '.ocmux.json');
|
|
75
58
|
}
|
|
76
59
|
|
|
60
|
+
// Return the pane ID running the TUI in `windowIndex`, or null when the window
|
|
61
|
+
// has no TUI. v2 layout: the TUI is the only pane (pane 0). Legacy layout
|
|
62
|
+
// (transition only): pane 0 is a server and the TUI is a non-zero pane; the
|
|
63
|
+
// last pane is used as the fallback.
|
|
77
64
|
function tuiPaneId(windowIndex) {
|
|
78
65
|
const r = _tmux(['list-panes', '-t', `${SESSION}:${windowIndex}`, '-F', '#{pane_id}\t#{pane_index}']);
|
|
79
66
|
if (r.status !== 0) return null;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return null;
|
|
67
|
+
const panes = r.stdout.trim().split('\n').filter(Boolean);
|
|
68
|
+
if (panes.length === 0) return null;
|
|
69
|
+
if (panes.length === 1) return panes[0].split('\t')[0];
|
|
70
|
+
// Legacy layout with a server pane — assume the last pane is the TUI.
|
|
71
|
+
return panes[panes.length - 1].split('\t')[0];
|
|
86
72
|
}
|
|
87
73
|
|
|
88
74
|
function windowByDir(dir) {
|
|
@@ -120,25 +106,15 @@ function activeWindowIndex() {
|
|
|
120
106
|
return null;
|
|
121
107
|
}
|
|
122
108
|
|
|
123
|
-
// Get the URL of the active tmux window (the one currently selected in the Opencode session).
|
|
124
|
-
function getActiveServer() {
|
|
125
|
-
const idx = activeWindowIndex();
|
|
126
|
-
if (idx == null) return null;
|
|
127
|
-
const dir = windowNameByIndex(idx);
|
|
128
|
-
if (!dir) return null;
|
|
129
|
-
const sf = statefileFor(dir);
|
|
130
|
-
if (!fs.existsSync(sf)) return null;
|
|
131
|
-
const state = readState(sf);
|
|
132
|
-
return state && state.url ? state.url : null;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
109
|
function paneCount(windowIndex) {
|
|
136
110
|
const r = _tmux(['list-panes', '-t', `${SESSION}:${windowIndex}`, '-F', '#{pane_id}']);
|
|
137
111
|
if (r.status !== 0) return 0;
|
|
138
112
|
return r.stdout.trim().split('\n').filter(Boolean).length;
|
|
139
113
|
}
|
|
140
114
|
|
|
141
|
-
|
|
115
|
+
// List project windows: every tmux window whose name is a directory holding a
|
|
116
|
+
// `.ocmux.json`. Status derives from TUI pane liveness.
|
|
117
|
+
function listProjects() {
|
|
142
118
|
const sessionR = _tmux(['has-session', '-t', SESSION]);
|
|
143
119
|
if (sessionR.status !== 0) return [];
|
|
144
120
|
|
|
@@ -151,155 +127,81 @@ function listServers() {
|
|
|
151
127
|
const sf = statefileFor(dir);
|
|
152
128
|
if (!fs.existsSync(sf)) continue;
|
|
153
129
|
const state = readState(sf);
|
|
154
|
-
if (!state
|
|
155
|
-
const
|
|
156
|
-
rows.push({
|
|
130
|
+
if (!state) continue;
|
|
131
|
+
const winIdx = parseInt(idx, 10);
|
|
132
|
+
rows.push({
|
|
133
|
+
dir,
|
|
134
|
+
index: winIdx,
|
|
135
|
+
status: tuiPaneId(winIdx) != null ? 'alive' : 'dead',
|
|
136
|
+
session: state.session || null,
|
|
137
|
+
server: state.server || state.url || null,
|
|
138
|
+
});
|
|
157
139
|
}
|
|
158
140
|
return rows;
|
|
159
141
|
}
|
|
160
142
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return {
|
|
170
|
-
dir,
|
|
171
|
-
url: state.url,
|
|
172
|
-
status: winIdx != null ? 'alive' : 'dead',
|
|
173
|
-
windowIndex: winIdx,
|
|
174
|
-
hasTui: tui != null,
|
|
175
|
-
};
|
|
143
|
+
function isWindowZoomed(windowIndex) {
|
|
144
|
+
const r = _tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}|#{window_zoomed_flag}']);
|
|
145
|
+
if (r.status !== 0) return false;
|
|
146
|
+
for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
|
|
147
|
+
const [wi, z] = line.split('|');
|
|
148
|
+
if (parseInt(wi, 10) === windowIndex && z === '1') return true;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
176
151
|
}
|
|
177
152
|
|
|
178
|
-
//
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const alive = new Set(listServers().map(s => s.dir));
|
|
183
|
-
const dead = [];
|
|
184
|
-
for (const dir of knownDirs) {
|
|
185
|
-
if (alive.has(dir)) continue;
|
|
186
|
-
const status = serverStatus(dir);
|
|
187
|
-
if (status) dead.push(status);
|
|
188
|
-
}
|
|
189
|
-
return dead;
|
|
153
|
+
// v2-only TUI launch command. `sessionId` may be null to continue the last one.
|
|
154
|
+
function tuiAttachCommand(url, sessionId) {
|
|
155
|
+
if (sessionId) return `opencode --server '${url}' --session '${sessionId}'`;
|
|
156
|
+
return `opencode --server '${url}' --continue`;
|
|
190
157
|
}
|
|
191
158
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
159
|
+
function zoomPane(paneId) {
|
|
160
|
+
if (paneId) _tmux(['resize-pane', '-Z', '-t', paneId]);
|
|
161
|
+
}
|
|
195
162
|
|
|
196
|
-
|
|
197
|
-
|
|
163
|
+
// Focus a project window, restarting its TUI when dead (or opening it for the
|
|
164
|
+
// first time). Reads nothing from disk — the caller supplies server/session.
|
|
165
|
+
function activateProject(dir, index, server, session) {
|
|
166
|
+
const winIdx = index;
|
|
167
|
+
pinWindowName(winIdx);
|
|
198
168
|
|
|
199
|
-
|
|
200
|
-
const tuiPane = tuiPaneId(winIdx);
|
|
169
|
+
let tuiPane = tuiPaneId(winIdx);
|
|
201
170
|
if (!tuiPane) {
|
|
171
|
+
const cmd = tuiAttachCommand(server, session);
|
|
202
172
|
const count = paneCount(winIdx);
|
|
203
|
-
if (count >=
|
|
173
|
+
if (count >= 1) {
|
|
204
174
|
const panesR = _tmux(['list-panes', '-t', `${SESSION}:${winIdx}`, '-F', '#{pane_id}']);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const respawnR = _tmux(['respawn-pane', '-k', '-t', lastPane,
|
|
209
|
-
if (respawnR.status !== 0)
|
|
210
|
-
_tmux(['send-keys', '-t', lastPane, tuiAttachCommand(url), 'Enter']);
|
|
211
|
-
}
|
|
175
|
+
const panes = panesR.status === 0 ? panesR.stdout.trim().split('\n').filter(Boolean) : [];
|
|
176
|
+
const lastPane = panes[panes.length - 1];
|
|
177
|
+
if (lastPane) {
|
|
178
|
+
const respawnR = _tmux(['respawn-pane', '-k', '-t', lastPane, cmd]);
|
|
179
|
+
if (respawnR.status !== 0) _tmux(['send-keys', '-t', lastPane, cmd, 'Enter']);
|
|
212
180
|
}
|
|
213
181
|
} else {
|
|
214
|
-
_tmux(['
|
|
215
|
-
tuiAttachCommand(url)]);
|
|
182
|
+
_tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`, cmd, 'Enter']);
|
|
216
183
|
}
|
|
217
184
|
}
|
|
218
185
|
|
|
219
|
-
// Select window
|
|
220
186
|
const activeWin = activeWindowIndex();
|
|
221
187
|
if (activeWin !== null && activeWin !== winIdx) {
|
|
222
188
|
_tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
|
|
223
189
|
}
|
|
224
190
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
let zoomed = false;
|
|
229
|
-
for (const line of zoomR.stdout.trim().split('\n').filter(Boolean)) {
|
|
230
|
-
const [wi, z] = line.split('|');
|
|
231
|
-
if (parseInt(wi, 10) === winIdx && z === '1') { zoomed = true; break; }
|
|
232
|
-
}
|
|
233
|
-
if (!zoomed) {
|
|
234
|
-
const tui = tuiPaneId(winIdx);
|
|
235
|
-
if (tui) _tmux(['resize-pane', '-Z', '-t', tui]);
|
|
236
|
-
}
|
|
191
|
+
if (!isWindowZoomed(winIdx)) {
|
|
192
|
+
tuiPane = tuiPaneId(winIdx);
|
|
193
|
+
zoomPane(tuiPane);
|
|
237
194
|
}
|
|
238
195
|
|
|
239
196
|
return true;
|
|
240
197
|
}
|
|
241
198
|
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
|
|
245
|
-
// Returns { url, dir } on success, throws on error.
|
|
246
|
-
function resurrectServer(dir, printLogs) {
|
|
247
|
-
try { dir = fs.realpathSync(dir); } catch {}
|
|
248
|
-
const sf = path.join(dir, '.ocmux.json');
|
|
249
|
-
const state = readState(sf);
|
|
250
|
-
|
|
251
|
-
// Kill old tmux window if it exists
|
|
252
|
-
if (state && state.window_index != null) {
|
|
253
|
-
let winIdx = state.window_index;
|
|
254
|
-
const sessionR = _tmux(['has-session', '-t', SESSION]);
|
|
255
|
-
if (sessionR.status === 0) {
|
|
256
|
-
const actualName = windowNameByIndex(winIdx);
|
|
257
|
-
if (actualName !== dir) {
|
|
258
|
-
const found = windowByDir(dir);
|
|
259
|
-
if (found == null) {
|
|
260
|
-
// No window exists for this directory — old server is already dead, skip kill
|
|
261
|
-
winIdx = null;
|
|
262
|
-
} else {
|
|
263
|
-
winIdx = found;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
if (winIdx != null) {
|
|
267
|
-
_tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
|
|
268
|
-
_tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`, 'C-c']);
|
|
269
|
-
sleep(0.3);
|
|
270
|
-
const killR = _tmux(['kill-window', '-t', `${SESSION}:${winIdx}`]);
|
|
271
|
-
if (killR.status !== 0) {
|
|
272
|
-
throw new Error(`Failed to kill old tmux window ${winIdx}: ${killR.stderr || 'unknown error'}`);
|
|
273
|
-
}
|
|
274
|
-
// Wait for window to actually disappear (up to 2s)
|
|
275
|
-
for (let i = 0; i < 10; i++) {
|
|
276
|
-
if (windowByDir(dir) == null) break;
|
|
277
|
-
sleep(0.2);
|
|
278
|
-
}
|
|
279
|
-
if (windowByDir(dir) != null) {
|
|
280
|
-
throw new Error(`Old tmux window for ${dir} still exists after kill`);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Remove old state file so we can create a fresh one
|
|
287
|
-
try { fs.unlinkSync(sf); } catch {}
|
|
288
|
-
|
|
289
|
-
// Start fresh server (same logic as ocmux serve)
|
|
290
|
-
return startServer(dir, printLogs);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Start a new opencode server in the given directory.
|
|
294
|
-
// Creates a tmux window, starts opencode serve, waits for the URL,
|
|
295
|
-
// and writes .ocmux.json. Returns { url, dir } on success.
|
|
296
|
-
function startServer(dir, printLogs) {
|
|
297
|
-
// Normalize to real path so tmux window names are consistent regardless of symlinks
|
|
199
|
+
// Create a project TUI window (v2 layout: TUI is pane 0, no server pane).
|
|
200
|
+
// Writes the v2 state file. The server must already be reachable.
|
|
201
|
+
function createProjectWindow(dir, url, session) {
|
|
298
202
|
try { dir = fs.realpathSync(dir); } catch {}
|
|
299
203
|
ensureSession();
|
|
300
|
-
const lf = logfileFor(dir);
|
|
301
204
|
|
|
302
|
-
// Create a new tmux window and capture its index directly from the -P output
|
|
303
205
|
const newWinR = _tmux(['new-window', '-d', '-P', '-F', '#{window_index}', '-t', SESSION, '-n', dir, '-c', dir]);
|
|
304
206
|
if (newWinR.status !== 0) throw new Error(`failed to create tmux window for ${dir}`);
|
|
305
207
|
let winIdx = parseInt(newWinR.stdout.trim(), 10);
|
|
@@ -310,46 +212,71 @@ function startServer(dir, printLogs) {
|
|
|
310
212
|
|
|
311
213
|
pinWindowName(winIdx);
|
|
312
214
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
215
|
+
const cmd = tuiAttachCommand(url, session);
|
|
216
|
+
_tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`, cmd, 'Enter']);
|
|
217
|
+
sleep(0.5);
|
|
218
|
+
const tuiPane = tuiPaneId(winIdx);
|
|
219
|
+
zoomPane(tuiPane);
|
|
318
220
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const log = (() => { try { return fs.readFileSync(lf, 'utf8'); } catch { return ''; } })();
|
|
323
|
-
const m = log.match(URL_RE);
|
|
324
|
-
if (m) { url = m[1]; break; }
|
|
221
|
+
const activeWin = activeWindowIndex();
|
|
222
|
+
if (activeWin !== null && activeWin !== winIdx) {
|
|
223
|
+
_tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
|
|
325
224
|
}
|
|
326
225
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}
|
|
226
|
+
const stateFile = path.join(dir, '.ocmux.json');
|
|
227
|
+
let existing = {};
|
|
228
|
+
try { existing = JSON.parse(fs.readFileSync(stateFile, 'utf8')); } catch {}
|
|
229
|
+
const state = { ...existing, version: 2, directory: dir, session: session || existing.session || null, server: url };
|
|
230
|
+
fs.writeFileSync(stateFile, JSON.stringify(state) + '\n');
|
|
331
231
|
|
|
332
|
-
|
|
333
|
-
|
|
232
|
+
return { url, dir, index: winIdx };
|
|
233
|
+
}
|
|
334
234
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
if (tuiPane)
|
|
235
|
+
// Point an existing project window's TUI at `session` by relaunching the TUI
|
|
236
|
+
// process (OpenCode v2 exposes no HTTP TUI-steering endpoint).
|
|
237
|
+
function relaunchTui(windowIndex, dir, server, session) {
|
|
238
|
+
const cmd = tuiAttachCommand(server, session);
|
|
239
|
+
const tuiPane = tuiPaneId(windowIndex);
|
|
240
|
+
if (tuiPane) {
|
|
241
|
+
const respawnR = _tmux(['respawn-pane', '-k', '-t', tuiPane, cmd]);
|
|
242
|
+
if (respawnR.status !== 0) _tmux(['send-keys', '-t', tuiPane, cmd, 'Enter']);
|
|
243
|
+
} else {
|
|
244
|
+
_tmux(['send-keys', '-t', `${SESSION}:${windowIndex}.0`, cmd, 'Enter']);
|
|
245
|
+
}
|
|
246
|
+
sleep(0.4);
|
|
247
|
+
// Keep the project's own window active (switching sessions must not leave
|
|
248
|
+
// another project's window selected).
|
|
249
|
+
const active = activeWindowIndex();
|
|
250
|
+
if (active !== null && active !== windowIndex) {
|
|
251
|
+
_tmux(['select-window', '-t', `${SESSION}:${windowIndex}`]);
|
|
252
|
+
}
|
|
253
|
+
const pane = tuiPaneId(windowIndex);
|
|
254
|
+
if (pane && !isWindowZoomed(windowIndex)) zoomPane(pane);
|
|
255
|
+
}
|
|
341
256
|
|
|
257
|
+
// Focus the tmux window belonging to a project directory (best-effort). Used by
|
|
258
|
+
// agentp so the target project's TUI window is the one shown when a prompt runs.
|
|
259
|
+
// Returns the window index, or null when the directory has no window.
|
|
260
|
+
function focusWindowByDir(dir) {
|
|
261
|
+
const idx = windowByDir(dir);
|
|
262
|
+
if (idx == null) return null;
|
|
342
263
|
const activeWin = activeWindowIndex();
|
|
343
|
-
if (activeWin !== null && activeWin !==
|
|
344
|
-
_tmux(['select-window', '-t', `${SESSION}:${
|
|
264
|
+
if (activeWin !== null && activeWin !== idx) {
|
|
265
|
+
_tmux(['select-window', '-t', `${SESSION}:${idx}`]);
|
|
345
266
|
}
|
|
267
|
+
const pane = tuiPaneId(idx);
|
|
268
|
+
if (pane && !isWindowZoomed(idx)) zoomPane(pane);
|
|
269
|
+
return idx;
|
|
270
|
+
}
|
|
346
271
|
|
|
347
|
-
|
|
272
|
+
// Close a project's tmux window (no server to stop in the v2 model).
|
|
273
|
+
function closeProjectWindow(windowIndex) {
|
|
274
|
+
const r = _tmux(['kill-window', '-t', `${SESSION}:${windowIndex}`]);
|
|
275
|
+
if (r.status !== 0) throw new Error(`failed to kill window ${windowIndex}: ${r.stderr || 'unknown error'}`);
|
|
348
276
|
}
|
|
349
277
|
|
|
350
278
|
module.exports = {
|
|
351
279
|
SESSION,
|
|
352
|
-
URL_RE,
|
|
353
280
|
readState,
|
|
354
281
|
statefileFor,
|
|
355
282
|
tuiPaneId,
|
|
@@ -357,18 +284,17 @@ module.exports = {
|
|
|
357
284
|
windowNameByIndex,
|
|
358
285
|
activeWindowIndex,
|
|
359
286
|
paneCount,
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
287
|
+
listProjects,
|
|
288
|
+
activateProject,
|
|
289
|
+
relaunchTui,
|
|
290
|
+
focusWindowByDir,
|
|
291
|
+
closeProjectWindow,
|
|
292
|
+
createProjectWindow,
|
|
363
293
|
hashDir,
|
|
364
294
|
logfileFor,
|
|
365
295
|
sleep,
|
|
366
296
|
ensureSession,
|
|
367
297
|
pinWindowName,
|
|
368
|
-
resurrectServer,
|
|
369
|
-
startServer,
|
|
370
|
-
serverStatus,
|
|
371
|
-
listDeadServers,
|
|
372
|
-
detectOcMajor,
|
|
373
298
|
tuiAttachCommand,
|
|
374
|
-
|
|
299
|
+
isWindowZoomed,
|
|
300
|
+
};
|