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/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
- for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
81
- const [paneId, idx] = line.split('\t');
82
- // Server is always pane 0, TUI is pane 1+
83
- if (idx !== '0') return paneId;
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
- function listServers() {
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 || !state.url) continue;
155
- const tui = tuiPaneId(parseInt(idx, 10));
156
- rows.push({ url: state.url, dir, index: parseInt(idx, 10), status: tui ? 'alive' : 'dead' });
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
- // Check the state of a server in a given directory.
162
- // Returns null if no .ocmux.json exists, or an object with status.
163
- function serverStatus(dir) {
164
- const sf = statefileFor(dir);
165
- const state = readState(sf);
166
- if (!state || !state.url) return null;
167
- const winIdx = windowByDir(dir);
168
- const tui = winIdx != null ? tuiPaneId(winIdx) : null;
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
- // List dead servers by scanning known directories for orphaned .ocmux.json files.
179
- // dirs: array of directories to check (e.g. from connections file).
180
- // Returns entries with status 'dead' that are not already in listServers().
181
- function listDeadServers(knownDirs) {
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
- // Switch tmux focus to the given server window and ensure the TUI pane is alive and zoomed.
193
- function activateServer(dir, index, url) {
194
- const winIdx = index;
159
+ function zoomPane(paneId) {
160
+ if (paneId) _tmux(['resize-pane', '-Z', '-t', paneId]);
161
+ }
195
162
 
196
- // Pin window name
197
- _tmux(['set-window-option', '-t', `${SESSION}:${winIdx}`, 'automatic-rename', 'off']);
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
- // Restart TUI pane if dead
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 >= 2) {
173
+ if (count >= 1) {
204
174
  const panesR = _tmux(['list-panes', '-t', `${SESSION}:${winIdx}`, '-F', '#{pane_id}']);
205
- if (panesR.status === 0) {
206
- const panes = panesR.stdout.trim().split('\n').filter(Boolean);
207
- const lastPane = panes[panes.length - 1];
208
- const respawnR = _tmux(['respawn-pane', '-k', '-t', lastPane, tuiAttachCommand(url)]);
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(['split-window', '-v', '-t', `${SESSION}:${winIdx}`, '-c', dir,
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
- // Zoom TUI pane
226
- const zoomR = _tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}|#{window_zoomed_flag}']);
227
- if (zoomR.status === 0) {
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
- // Recover a dead/crashed server: kill old window, remove state file,
243
- // create fresh server + TUI in the same directory.
244
- // Delegates to startServer for the actual server creation.
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
- try { fs.truncateSync(lf); } catch {}
314
-
315
- const serveFlags = printLogs ? '--print-logs' : '';
316
- _tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`,
317
- 'opencode serve' + (serveFlags ? ' ' + serveFlags : '') + ` 2>&1 | tee '${lf}'`, 'Enter']);
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
- let url = null;
320
- for (let i = 0; i < 50; i++) {
321
- sleep(0.2);
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
- if (!url) {
328
- _tmux(['kill-window', '-t', `${SESSION}:${winIdx}`]);
329
- throw new Error('opencode server did not start within 10 seconds');
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
- const newState = { url, logfile: lf, window_index: winIdx };
333
- fs.writeFileSync(path.join(dir, '.ocmux.json'), JSON.stringify(newState) + '\n');
232
+ return { url, dir, index: winIdx };
233
+ }
334
234
 
335
- const splitResult = _tmux(['split-window', '-v', '-P', '-F', '#{pane_id}', '-t', `${SESSION}:${winIdx}`, '-c', dir]);
336
- const tuiPane = splitResult.stdout.trim();
337
- sleep(0.2);
338
- _tmux(['send-keys', '-t', tuiPane, tuiAttachCommand(url), 'Enter']);
339
- sleep(0.5);
340
- if (tuiPane) _tmux(['resize-pane', '-Z', '-t', 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 !== winIdx) {
344
- _tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
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
- return { url, dir };
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
- listServers,
361
- activateServer,
362
- getActiveServer,
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
+ };