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/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
- for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
58
- const [paneId, idx] = line.split('\t');
59
- // Server is always pane 0, TUI is pane 1+
60
- if (idx !== '0') return paneId;
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
- 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() {
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 || !state.url) continue;
132
- const tui = tuiPaneId(parseInt(idx, 10));
133
- 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
+ });
134
139
  }
135
140
  return rows;
136
141
  }
137
142
 
138
- // Check the state of a server in a given directory.
139
- // Returns null if no .ocmux.json exists, or an object with status.
140
- function serverStatus(dir) {
141
- const sf = statefileFor(dir);
142
- const state = readState(sf);
143
- if (!state || !state.url) return null;
144
- const winIdx = windowByDir(dir);
145
- const tui = winIdx != null ? tuiPaneId(winIdx) : null;
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
- // List dead servers by scanning known directories for orphaned .ocmux.json files.
156
- // dirs: array of directories to check (e.g. from connections file).
157
- // Returns entries with status 'dead' that are not already in listServers().
158
- function listDeadServers(knownDirs) {
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
- // Switch tmux focus to the given server window and ensure the TUI pane is alive and zoomed.
170
- function activateServer(dir, index, url) {
171
- const winIdx = index;
159
+ function zoomPane(paneId) {
160
+ if (paneId) _tmux(['resize-pane', '-Z', '-t', paneId]);
161
+ }
172
162
 
173
- // Pin window name
174
- _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);
175
168
 
176
- // Restart TUI pane if dead
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 >= 2) {
173
+ if (count >= 1) {
181
174
  const panesR = _tmux(['list-panes', '-t', `${SESSION}:${winIdx}`, '-F', '#{pane_id}']);
182
- if (panesR.status === 0) {
183
- const panes = panesR.stdout.trim().split('\n').filter(Boolean);
184
- const lastPane = panes[panes.length - 1];
185
- const respawnR = _tmux(['respawn-pane', '-k', '-t', lastPane, `opencode attach --continue '${url}'`]);
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(['split-window', '-v', '-t', `${SESSION}:${winIdx}`, '-c', dir,
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
- // Zoom TUI pane
203
- const zoomR = _tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}|#{window_zoomed_flag}']);
204
- if (zoomR.status === 0) {
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
- // Recover a dead/crashed server: kill old window, remove state file,
220
- // create fresh server + TUI in the same directory.
221
- // Delegates to startServer for the actual server creation.
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
- try { fs.truncateSync(lf); } catch {}
291
-
292
- const serveFlags = printLogs ? '--print-logs' : '';
293
- _tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`,
294
- '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);
295
220
 
296
- let url = null;
297
- for (let i = 0; i < 50; i++) {
298
- sleep(0.2);
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
- if (!url) {
305
- _tmux(['kill-window', '-t', `${SESSION}:${winIdx}`]);
306
- throw new Error('opencode server did not start within 10 seconds');
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
- const newState = { url, logfile: lf, window_index: winIdx };
310
- fs.writeFileSync(path.join(dir, '.ocmux.json'), JSON.stringify(newState) + '\n');
232
+ return { url, dir, index: winIdx };
233
+ }
311
234
 
312
- const splitResult = _tmux(['split-window', '-v', '-P', '-F', '#{pane_id}', '-t', `${SESSION}:${winIdx}`, '-c', dir]);
313
- const tuiPane = splitResult.stdout.trim();
314
- sleep(0.2);
315
- _tmux(['send-keys', '-t', tuiPane, `opencode attach --continue '${url}'`, 'Enter']);
316
- sleep(0.5);
317
- 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
+ }
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 !== winIdx) {
321
- _tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
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
- 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'}`);
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
- listServers,
338
- activateServer,
339
- getActiveServer,
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
- resurrectServer,
346
- startServer,
347
- serverStatus,
348
- listDeadServers,
349
- };
298
+ tuiAttachCommand,
299
+ isWindowZoomed,
300
+ };