agentp 1.13.0 → 2.0.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/CONTRIBUTING.md +80 -83
- package/README.md +135 -48
- package/bin/agentp +337 -75
- package/bin/ocmux +1075 -440
- package/docs/specification.md +12 -449
- package/docs/specification_v2.md +611 -0
- package/lib/ocmux.js +124 -173
- package/lib/opencode.js +563 -356
- package/lib/project-state.js +205 -0
- package/package.json +1 -1
package/lib/ocmux.js
CHANGED
|
@@ -1,12 +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
|
-
const URL_RE = /opencode server listening on (http:\/\/[^\s]*)/;
|
|
10
16
|
|
|
11
17
|
function _tmux(args) {
|
|
12
18
|
const result = child_process.spawnSync('tmux', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 });
|
|
@@ -51,15 +57,18 @@ function statefileFor(dir) {
|
|
|
51
57
|
return path.join(dir, '.ocmux.json');
|
|
52
58
|
}
|
|
53
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.
|
|
54
64
|
function tuiPaneId(windowIndex) {
|
|
55
65
|
const r = _tmux(['list-panes', '-t', `${SESSION}:${windowIndex}`, '-F', '#{pane_id}\t#{pane_index}']);
|
|
56
66
|
if (r.status !== 0) return null;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
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];
|
|
63
72
|
}
|
|
64
73
|
|
|
65
74
|
function windowByDir(dir) {
|
|
@@ -97,25 +106,15 @@ function activeWindowIndex() {
|
|
|
97
106
|
return null;
|
|
98
107
|
}
|
|
99
108
|
|
|
100
|
-
// Get the URL of the active tmux window (the one currently selected in the Opencode session).
|
|
101
|
-
function getActiveServer() {
|
|
102
|
-
const idx = activeWindowIndex();
|
|
103
|
-
if (idx == null) return null;
|
|
104
|
-
const dir = windowNameByIndex(idx);
|
|
105
|
-
if (!dir) return null;
|
|
106
|
-
const sf = statefileFor(dir);
|
|
107
|
-
if (!fs.existsSync(sf)) return null;
|
|
108
|
-
const state = readState(sf);
|
|
109
|
-
return state && state.url ? state.url : null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
109
|
function paneCount(windowIndex) {
|
|
113
110
|
const r = _tmux(['list-panes', '-t', `${SESSION}:${windowIndex}`, '-F', '#{pane_id}']);
|
|
114
111
|
if (r.status !== 0) return 0;
|
|
115
112
|
return r.stdout.trim().split('\n').filter(Boolean).length;
|
|
116
113
|
}
|
|
117
114
|
|
|
118
|
-
|
|
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() {
|
|
119
118
|
const sessionR = _tmux(['has-session', '-t', SESSION]);
|
|
120
119
|
if (sessionR.status !== 0) return [];
|
|
121
120
|
|
|
@@ -128,155 +127,81 @@ function listServers() {
|
|
|
128
127
|
const sf = statefileFor(dir);
|
|
129
128
|
if (!fs.existsSync(sf)) continue;
|
|
130
129
|
const state = readState(sf);
|
|
131
|
-
if (!state
|
|
132
|
-
const
|
|
133
|
-
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
|
+
});
|
|
134
139
|
}
|
|
135
140
|
return rows;
|
|
136
141
|
}
|
|
137
142
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
dir,
|
|
148
|
-
url: state.url,
|
|
149
|
-
status: winIdx != null ? 'alive' : 'dead',
|
|
150
|
-
windowIndex: winIdx,
|
|
151
|
-
hasTui: tui != null,
|
|
152
|
-
};
|
|
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;
|
|
153
151
|
}
|
|
154
152
|
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const alive = new Set(listServers().map(s => s.dir));
|
|
160
|
-
const dead = [];
|
|
161
|
-
for (const dir of knownDirs) {
|
|
162
|
-
if (alive.has(dir)) continue;
|
|
163
|
-
const status = serverStatus(dir);
|
|
164
|
-
if (status) dead.push(status);
|
|
165
|
-
}
|
|
166
|
-
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`;
|
|
167
157
|
}
|
|
168
158
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
159
|
+
function zoomPane(paneId) {
|
|
160
|
+
if (paneId) _tmux(['resize-pane', '-Z', '-t', paneId]);
|
|
161
|
+
}
|
|
172
162
|
|
|
173
|
-
|
|
174
|
-
|
|
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);
|
|
175
168
|
|
|
176
|
-
|
|
177
|
-
const tuiPane = tuiPaneId(winIdx);
|
|
169
|
+
let tuiPane = tuiPaneId(winIdx);
|
|
178
170
|
if (!tuiPane) {
|
|
171
|
+
const cmd = tuiAttachCommand(server, session);
|
|
179
172
|
const count = paneCount(winIdx);
|
|
180
|
-
if (count >=
|
|
173
|
+
if (count >= 1) {
|
|
181
174
|
const panesR = _tmux(['list-panes', '-t', `${SESSION}:${winIdx}`, '-F', '#{pane_id}']);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const respawnR = _tmux(['respawn-pane', '-k', '-t', lastPane,
|
|
186
|
-
if (respawnR.status !== 0)
|
|
187
|
-
_tmux(['send-keys', '-t', lastPane, `opencode attach --continue '${url}'`, 'Enter']);
|
|
188
|
-
}
|
|
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']);
|
|
189
180
|
}
|
|
190
181
|
} else {
|
|
191
|
-
_tmux(['
|
|
192
|
-
`opencode attach --continue '${url}'`]);
|
|
182
|
+
_tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`, cmd, 'Enter']);
|
|
193
183
|
}
|
|
194
184
|
}
|
|
195
185
|
|
|
196
|
-
// Select window
|
|
197
186
|
const activeWin = activeWindowIndex();
|
|
198
187
|
if (activeWin !== null && activeWin !== winIdx) {
|
|
199
188
|
_tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
|
|
200
189
|
}
|
|
201
190
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
let zoomed = false;
|
|
206
|
-
for (const line of zoomR.stdout.trim().split('\n').filter(Boolean)) {
|
|
207
|
-
const [wi, z] = line.split('|');
|
|
208
|
-
if (parseInt(wi, 10) === winIdx && z === '1') { zoomed = true; break; }
|
|
209
|
-
}
|
|
210
|
-
if (!zoomed) {
|
|
211
|
-
const tui = tuiPaneId(winIdx);
|
|
212
|
-
if (tui) _tmux(['resize-pane', '-Z', '-t', tui]);
|
|
213
|
-
}
|
|
191
|
+
if (!isWindowZoomed(winIdx)) {
|
|
192
|
+
tuiPane = tuiPaneId(winIdx);
|
|
193
|
+
zoomPane(tuiPane);
|
|
214
194
|
}
|
|
215
195
|
|
|
216
196
|
return true;
|
|
217
197
|
}
|
|
218
198
|
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
// Returns { url, dir } on success, throws on error.
|
|
223
|
-
function resurrectServer(dir, printLogs) {
|
|
224
|
-
try { dir = fs.realpathSync(dir); } catch {}
|
|
225
|
-
const sf = path.join(dir, '.ocmux.json');
|
|
226
|
-
const state = readState(sf);
|
|
227
|
-
|
|
228
|
-
// Kill old tmux window if it exists
|
|
229
|
-
if (state && state.window_index != null) {
|
|
230
|
-
let winIdx = state.window_index;
|
|
231
|
-
const sessionR = _tmux(['has-session', '-t', SESSION]);
|
|
232
|
-
if (sessionR.status === 0) {
|
|
233
|
-
const actualName = windowNameByIndex(winIdx);
|
|
234
|
-
if (actualName !== dir) {
|
|
235
|
-
const found = windowByDir(dir);
|
|
236
|
-
if (found == null) {
|
|
237
|
-
// No window exists for this directory — old server is already dead, skip kill
|
|
238
|
-
winIdx = null;
|
|
239
|
-
} else {
|
|
240
|
-
winIdx = found;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
if (winIdx != null) {
|
|
244
|
-
_tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
|
|
245
|
-
_tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`, 'C-c']);
|
|
246
|
-
sleep(0.3);
|
|
247
|
-
const killR = _tmux(['kill-window', '-t', `${SESSION}:${winIdx}`]);
|
|
248
|
-
if (killR.status !== 0) {
|
|
249
|
-
throw new Error(`Failed to kill old tmux window ${winIdx}: ${killR.stderr || 'unknown error'}`);
|
|
250
|
-
}
|
|
251
|
-
// Wait for window to actually disappear (up to 2s)
|
|
252
|
-
for (let i = 0; i < 10; i++) {
|
|
253
|
-
if (windowByDir(dir) == null) break;
|
|
254
|
-
sleep(0.2);
|
|
255
|
-
}
|
|
256
|
-
if (windowByDir(dir) != null) {
|
|
257
|
-
throw new Error(`Old tmux window for ${dir} still exists after kill`);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Remove old state file so we can create a fresh one
|
|
264
|
-
try { fs.unlinkSync(sf); } catch {}
|
|
265
|
-
|
|
266
|
-
// Start fresh server (same logic as ocmux serve)
|
|
267
|
-
return startServer(dir, printLogs);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Start a new opencode server in the given directory.
|
|
271
|
-
// Creates a tmux window, starts opencode serve, waits for the URL,
|
|
272
|
-
// and writes .ocmux.json. Returns { url, dir } on success.
|
|
273
|
-
function startServer(dir, printLogs) {
|
|
274
|
-
// 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) {
|
|
275
202
|
try { dir = fs.realpathSync(dir); } catch {}
|
|
276
203
|
ensureSession();
|
|
277
|
-
const lf = logfileFor(dir);
|
|
278
204
|
|
|
279
|
-
// Create a new tmux window and capture its index directly from the -P output
|
|
280
205
|
const newWinR = _tmux(['new-window', '-d', '-P', '-F', '#{window_index}', '-t', SESSION, '-n', dir, '-c', dir]);
|
|
281
206
|
if (newWinR.status !== 0) throw new Error(`failed to create tmux window for ${dir}`);
|
|
282
207
|
let winIdx = parseInt(newWinR.stdout.trim(), 10);
|
|
@@ -287,46 +212,71 @@ function startServer(dir, printLogs) {
|
|
|
287
212
|
|
|
288
213
|
pinWindowName(winIdx);
|
|
289
214
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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);
|
|
295
220
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const log = (() => { try { return fs.readFileSync(lf, 'utf8'); } catch { return ''; } })();
|
|
300
|
-
const m = log.match(URL_RE);
|
|
301
|
-
if (m) { url = m[1]; break; }
|
|
221
|
+
const activeWin = activeWindowIndex();
|
|
222
|
+
if (activeWin !== null && activeWin !== winIdx) {
|
|
223
|
+
_tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
|
|
302
224
|
}
|
|
303
225
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
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');
|
|
308
231
|
|
|
309
|
-
|
|
310
|
-
|
|
232
|
+
return { url, dir, index: winIdx };
|
|
233
|
+
}
|
|
311
234
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
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
|
+
}
|
|
318
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;
|
|
319
263
|
const activeWin = activeWindowIndex();
|
|
320
|
-
if (activeWin !== null && activeWin !==
|
|
321
|
-
_tmux(['select-window', '-t', `${SESSION}:${
|
|
264
|
+
if (activeWin !== null && activeWin !== idx) {
|
|
265
|
+
_tmux(['select-window', '-t', `${SESSION}:${idx}`]);
|
|
322
266
|
}
|
|
267
|
+
const pane = tuiPaneId(idx);
|
|
268
|
+
if (pane && !isWindowZoomed(idx)) zoomPane(pane);
|
|
269
|
+
return idx;
|
|
270
|
+
}
|
|
323
271
|
|
|
324
|
-
|
|
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'}`);
|
|
325
276
|
}
|
|
326
277
|
|
|
327
278
|
module.exports = {
|
|
328
279
|
SESSION,
|
|
329
|
-
URL_RE,
|
|
330
280
|
readState,
|
|
331
281
|
statefileFor,
|
|
332
282
|
tuiPaneId,
|
|
@@ -334,16 +284,17 @@ module.exports = {
|
|
|
334
284
|
windowNameByIndex,
|
|
335
285
|
activeWindowIndex,
|
|
336
286
|
paneCount,
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
287
|
+
listProjects,
|
|
288
|
+
activateProject,
|
|
289
|
+
relaunchTui,
|
|
290
|
+
focusWindowByDir,
|
|
291
|
+
closeProjectWindow,
|
|
292
|
+
createProjectWindow,
|
|
340
293
|
hashDir,
|
|
341
294
|
logfileFor,
|
|
342
295
|
sleep,
|
|
343
296
|
ensureSession,
|
|
344
297
|
pinWindowName,
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
listDeadServers,
|
|
349
|
-
};
|
|
298
|
+
tuiAttachCommand,
|
|
299
|
+
isWindowZoomed,
|
|
300
|
+
};
|