agentp 2.0.1 → 2.1.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,300 +1,45 @@
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()`.
3
+ // TUI routing helpers. OpenCode owns the shared server and sessions; ocmux only
4
+ // routes a selected session to an explicitly registered tmux pane when one is
5
+ // available. There is no managed tmux session or per-project window layout.
9
6
 
10
- const fs = require('fs');
11
7
  const path = require('path');
12
- const crypto = require('crypto');
13
- const child_process = require('child_process');
14
-
15
- const SESSION = 'Opencode';
16
-
17
- function _tmux(args) {
18
- const result = child_process.spawnSync('tmux', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 });
19
- if (result.error && result.error.code === 'ENOENT') {
20
- return { status: -1, stdout: '', stderr: 'tmux not found' };
21
- }
22
- return result;
23
- }
24
-
25
- function hashDir(dir) {
26
- return crypto.createHash('md5').update(dir).digest('hex').slice(0, 12);
27
- }
28
-
29
- function logfileFor(dir) {
30
- return `/tmp/opencode-serve-${hashDir(dir)}.log`;
31
- }
32
-
33
- function sleep(seconds) {
34
- child_process.execSync(`sleep ${seconds}`, { stdio: 'ignore' });
35
- }
36
-
37
- function ensureSession() {
38
- const r = _tmux(['has-session', '-t', SESSION]);
39
- if (r.status !== 0) {
40
- _tmux(['new-session', '-d', '-s', SESSION]);
41
- }
42
- }
43
-
44
- function pinWindowName(windowIndex) {
45
- _tmux(['set-window-option', '-t', `${SESSION}:${windowIndex}`, 'automatic-rename', 'off']);
46
- }
47
-
48
- function readState(file) {
49
- try {
50
- return JSON.parse(fs.readFileSync(file, 'utf8'));
51
- } catch {
52
- return null;
53
- }
54
- }
55
-
56
- function statefileFor(dir) {
57
- return path.join(dir, '.ocmux.json');
58
- }
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.
64
- function tuiPaneId(windowIndex) {
65
- const r = _tmux(['list-panes', '-t', `${SESSION}:${windowIndex}`, '-F', '#{pane_id}\t#{pane_index}']);
66
- if (r.status !== 0) 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];
72
- }
73
-
74
- function windowByDir(dir) {
75
- let realDir;
76
- try { realDir = fs.realpathSync(dir); } catch { realDir = dir; }
77
- const r = _tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}\t#{window_name}']);
78
- if (r.status !== 0) return null;
79
- for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
80
- const [idx, name] = line.split('\t');
81
- if (name === dir) return parseInt(idx, 10);
82
- try {
83
- if (fs.realpathSync(name) === realDir) return parseInt(idx, 10);
84
- } catch {}
85
- }
86
- return null;
87
- }
88
-
89
- function windowNameByIndex(idx) {
90
- const r = _tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}\t#{window_name}']);
91
- if (r.status !== 0) return null;
92
- for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
93
- const [wi, name] = line.split('\t');
94
- if (parseInt(wi, 10) === idx) return name;
95
- }
96
- return null;
97
- }
98
-
99
- function activeWindowIndex() {
100
- const r = _tmux(['list-windows', '-t', SESSION, '-F', '#{window_index} #{window_active}']);
101
- if (r.status !== 0) return null;
102
- for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
103
- const [idx, active] = line.split(' ');
104
- if (active === '1') return parseInt(idx, 10);
105
- }
106
- return null;
107
- }
108
-
109
- function paneCount(windowIndex) {
110
- const r = _tmux(['list-panes', '-t', `${SESSION}:${windowIndex}`, '-F', '#{pane_id}']);
111
- if (r.status !== 0) return 0;
112
- return r.stdout.trim().split('\n').filter(Boolean).length;
113
- }
114
-
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() {
118
- const sessionR = _tmux(['has-session', '-t', SESSION]);
119
- if (sessionR.status !== 0) return [];
120
-
121
- const listR = _tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}\t#{window_name}']);
122
- if (listR.status !== 0 || !listR.stdout.trim()) return [];
123
-
124
- const rows = [];
125
- for (const line of listR.stdout.trim().split('\n').filter(Boolean)) {
126
- const [idx, dir] = line.split('\t');
127
- const sf = statefileFor(dir);
128
- if (!fs.existsSync(sf)) continue;
129
- const state = readState(sf);
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
- });
139
- }
140
- return rows;
141
- }
142
-
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;
151
- }
152
-
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`;
157
- }
158
-
159
- function zoomPane(paneId) {
160
- if (paneId) _tmux(['resize-pane', '-Z', '-t', paneId]);
161
- }
162
-
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);
168
-
169
- let tuiPane = tuiPaneId(winIdx);
170
- if (!tuiPane) {
171
- const cmd = tuiAttachCommand(server, session);
172
- const count = paneCount(winIdx);
173
- if (count >= 1) {
174
- const panesR = _tmux(['list-panes', '-t', `${SESSION}:${winIdx}`, '-F', '#{pane_id}']);
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']);
180
- }
181
- } else {
182
- _tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`, cmd, 'Enter']);
183
- }
184
- }
185
-
186
- const activeWin = activeWindowIndex();
187
- if (activeWin !== null && activeWin !== winIdx) {
188
- _tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
189
- }
190
-
191
- if (!isWindowZoomed(winIdx)) {
192
- tuiPane = tuiPaneId(winIdx);
193
- zoomPane(tuiPane);
194
- }
195
-
196
- return true;
197
- }
198
-
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) {
202
- try { dir = fs.realpathSync(dir); } catch {}
203
- ensureSession();
204
-
205
- const newWinR = _tmux(['new-window', '-d', '-P', '-F', '#{window_index}', '-t', SESSION, '-n', dir, '-c', dir]);
206
- if (newWinR.status !== 0) throw new Error(`failed to create tmux window for ${dir}`);
207
- let winIdx = parseInt(newWinR.stdout.trim(), 10);
208
- if (isNaN(winIdx)) {
209
- winIdx = windowByDir(dir);
210
- if (winIdx == null) throw new Error('could not determine new window index');
211
- }
212
-
213
- pinWindowName(winIdx);
214
-
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);
220
-
221
- const activeWin = activeWindowIndex();
222
- if (activeWin !== null && activeWin !== winIdx) {
223
- _tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
224
- }
225
-
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');
231
-
232
- return { url, dir, index: winIdx };
233
- }
234
-
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
- }
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;
263
- const activeWin = activeWindowIndex();
264
- if (activeWin !== null && activeWin !== idx) {
265
- _tmux(['select-window', '-t', `${SESSION}:${idx}`]);
266
- }
267
- const pane = tuiPaneId(idx);
268
- if (pane && !isWindowZoomed(idx)) zoomPane(pane);
269
- return idx;
270
- }
271
-
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'}`);
8
+ const registry = require('./tui-registry');
9
+
10
+ function tuiArgs(server, session, directory) {
11
+ const args = ['--server', server];
12
+ if (session) args.push('--session', session);
13
+ else args.push('--continue');
14
+ if (directory) args.push(directory);
15
+ return args;
16
+ }
17
+
18
+ function switchTui(directory, server, session, executable) {
19
+ const target = registry.resolve(directory);
20
+ if (!target) return { ok: false, reason: 'none' };
21
+ // Skip the respawn when the routed pane already shows this project/session,
22
+ // so merely opening ocmux does not restart a TUI that is already correct.
23
+ const instance = target.instance;
24
+ if (instance.activeDirectory
25
+ && path.resolve(instance.activeDirectory) === path.resolve(directory)
26
+ && String(instance.activeSession || '') === String(session || '')) {
27
+ return { ok: true, kind: target.kind, instance, unchanged: true };
28
+ }
29
+ const result = registry.respawn(instance, {
30
+ executable: executable || path.resolve(__dirname, '..', 'bin', 'ocmux'),
31
+ directory,
32
+ server,
33
+ session,
34
+ });
35
+ if (!result.ok) {
36
+ registry.pruneDead();
37
+ return { ok: false, reason: 'failed', kind: target.kind, error: result.error };
38
+ }
39
+ return { ok: true, kind: target.kind, instance };
276
40
  }
277
41
 
278
42
  module.exports = {
279
- SESSION,
280
- readState,
281
- statefileFor,
282
- tuiPaneId,
283
- windowByDir,
284
- windowNameByIndex,
285
- activeWindowIndex,
286
- paneCount,
287
- listProjects,
288
- activateProject,
289
- relaunchTui,
290
- focusWindowByDir,
291
- closeProjectWindow,
292
- createProjectWindow,
293
- hashDir,
294
- logfileFor,
295
- sleep,
296
- ensureSession,
297
- pinWindowName,
298
- tuiAttachCommand,
299
- isWindowZoomed,
300
- };
43
+ tuiArgs,
44
+ switchTui,
45
+ };
package/lib/opencode.js CHANGED
@@ -46,10 +46,14 @@ function parseBody(body) {
46
46
 
47
47
  function makeRequest(options, data, cancelRef, timeoutMs) {
48
48
  return new Promise((resolve, reject) => {
49
+ const cleanup = (req) => {
50
+ if (cancelRef && cancelRef.current === req) cancelRef.current = null;
51
+ };
49
52
  const req = http.request(options, (res) => {
50
53
  let body = '';
51
54
  res.on('data', (chunk) => { body += chunk; });
52
55
  res.on('end', () => {
56
+ cleanup(req);
53
57
  if (res.statusCode === 401) {
54
58
  reject(new Error('OpenCode server authentication failed. Set OPENCODE_SERVER_PASSWORD to match the server password.'));
55
59
  } else {
@@ -60,10 +64,14 @@ function makeRequest(options, data, cancelRef, timeoutMs) {
60
64
  if (timeoutMs) {
61
65
  req.setTimeout(timeoutMs, () => {
62
66
  req.destroy();
67
+ cleanup(req);
63
68
  reject(new Error('Request timed out'));
64
69
  });
65
70
  }
66
- req.on('error', reject);
71
+ req.on('error', (err) => {
72
+ cleanup(req);
73
+ reject(err);
74
+ });
67
75
  if (cancelRef) cancelRef.current = req;
68
76
  if (data) req.write(data);
69
77
  req.end();
@@ -159,6 +167,8 @@ function listenV2(server, sessionId, callbacks, cancelRef, logFn, opts) {
159
167
  if (callbacks.onConnected) callbacks.onConnected();
160
168
  let buffer = '';
161
169
  let collected = '';
170
+ let currentTextStart = null;
171
+ let currentTextHadDelta = false;
162
172
  let resolved = false;
163
173
  let completionTimer = null;
164
174
 
@@ -248,12 +258,12 @@ function listenV2(server, sessionId, callbacks, cancelRef, logFn, opts) {
248
258
  }
249
259
  if (!data || typeof data !== 'object') continue;
250
260
 
261
+ if (sessionId && data.sessionID && String(data.sessionID) !== String(sessionId)) continue;
262
+
251
263
  // Interrupted → resolve immediately with what we have. Every other
252
- // event counts as activity — including sub-agent events from child
253
- // sessions flowing on the same stream — and defers completion: we
254
- // only finish after a silent grace window following the last
255
- // terminal signal (a long-running tool or sub-agent would otherwise
256
- // truncate the answer).
264
+ // in-scope event counts as activity and defers completion: we only
265
+ // finish after a silent grace window following the last terminal
266
+ // signal (a long-running tool would otherwise truncate the answer).
257
267
  if (ev.type === 'session.execution.interrupted') {
258
268
  sseLog(` [SSE] session.execution.interrupted for session ${sessionId || 'any'}\n`);
259
269
  finishResolve(collected);
@@ -261,8 +271,6 @@ function listenV2(server, sessionId, callbacks, cancelRef, logFn, opts) {
261
271
  }
262
272
  cancelCompletion();
263
273
 
264
- if (sessionId && data.sessionID && String(data.sessionID) !== String(sessionId)) continue;
265
-
266
274
  if (ev.type === 'session.text.started') {
267
275
  // v2 emits one text segment per step; separate them so the answer
268
276
  // doesn't run adjacent segments together (as the TUI shows them).
@@ -271,13 +279,35 @@ function listenV2(server, sessionId, callbacks, cancelRef, logFn, opts) {
271
279
  if (callbacks.onText) callbacks.onText(sep);
272
280
  collected += sep;
273
281
  }
282
+ currentTextStart = collected.length;
283
+ currentTextHadDelta = false;
274
284
  } else if (ev.type === 'session.text.delta') {
275
285
  if (data.delta != null && data.delta !== '') {
286
+ if (currentTextStart === null) {
287
+ currentTextStart = collected.length;
288
+ currentTextHadDelta = false;
289
+ }
276
290
  if (callbacks.onText) callbacks.onText(data.delta);
277
291
  collected += data.delta;
292
+ currentTextHadDelta = true;
278
293
  }
279
294
  } else if (ev.type === 'session.text.ended') {
280
- if (collected === '' && data.text != null) collected = data.text;
295
+ if (data.text != null) {
296
+ if (currentTextStart === null) {
297
+ if (collected !== '' && !collected.endsWith('\n\n')) {
298
+ const sep = '\n\n';
299
+ if (callbacks.onText) callbacks.onText(sep);
300
+ collected += sep;
301
+ }
302
+ currentTextStart = collected.length;
303
+ currentTextHadDelta = false;
304
+ }
305
+ const replacement = String(data.text);
306
+ collected = collected.slice(0, currentTextStart) + replacement;
307
+ if (!currentTextHadDelta && callbacks.onText && replacement) callbacks.onText(replacement);
308
+ currentTextStart = null;
309
+ currentTextHadDelta = false;
310
+ }
281
311
  } else if (ev.type === 'session.reasoning.delta') {
282
312
  if (data.delta != null && data.delta !== '' && callbacks.onThinking) callbacks.onThinking(data.delta);
283
313
  } else if (ev.type === 'session.execution.started' || ev.type === 'session.retry.scheduled') {
@@ -322,16 +352,129 @@ async function getActiveSessions(server) {
322
352
  // List all sessions from the OpenCode server, optionally filtered by project
323
353
  // directory (exact match on the session's location.directory).
324
354
  async function listSessions(server, directory) {
325
- let url = `${await apiBase(server)}/session`;
326
- if (directory) url += '?directory=' + encodeURIComponent(directory);
355
+ const base = `${await apiBase(server)}/session`;
356
+ let cursor = null;
357
+ const seen = new Set();
358
+ const sessions = [];
359
+ do {
360
+ const params = new URLSearchParams();
361
+ params.set('limit', '200');
362
+ if (directory) params.set('directory', directory);
363
+ if (cursor) params.set('cursor', cursor);
364
+ const { status, body } = await makeRequest(buildJsonRequest(`${base}?${params}`, 'GET'));
365
+ if (status !== 200) throw new Error(`Failed to list sessions: ${status}`);
366
+ const parsed = JSON.parse(body);
367
+ const page = parsed && typeof parsed === 'object' && Array.isArray(parsed.data)
368
+ ? parsed.data
369
+ : (Array.isArray(parsed) ? parsed : []);
370
+ sessions.push(...page);
371
+ const next = parsed && parsed.cursor && parsed.cursor.next;
372
+ cursor = typeof next === 'string' && next && !seen.has(next) ? next : null;
373
+ if (cursor) seen.add(cursor);
374
+ } while (cursor);
375
+ return sessions;
376
+ }
377
+
378
+ // List projects known to the connected OpenCode data store.
379
+ async function listProjects(server) {
380
+ const url = `${await apiBase(server)}/project`;
327
381
  const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
328
- if (status !== 200) throw new Error(`Failed to list sessions: ${status}`);
329
- return parseBody(body);
382
+ if (status !== 200) throw new Error(`Failed to list projects: ${status}`);
383
+ const result = parseBody(body);
384
+ return Array.isArray(result) ? result : [];
330
385
  }
331
386
 
332
- // Send a message directly to a specific session. Attaches to the event stream
333
- // FIRST (so no events are missed), then posts a steered prompt, and resolves
334
- // with the collected text when execution completes.
387
+ // Wait for OpenCode's foreground execution drain to become idle. The v2 wait
388
+ // endpoint follows successor executions admitted during cleanup, unlike a
389
+ // client-side silence timer. Returns false only when an older v2 server does
390
+ // not implement the experimental route.
391
+ async function waitForSessionIdle(server, sessionId, cancelRef) {
392
+ const encoded = encodeURIComponent(sessionId);
393
+ const base = await apiBase(server);
394
+ const { status } = await makeRequest(
395
+ buildJsonRequest(`${base}/experimental/session/${encoded}/wait`, 'POST'),
396
+ null,
397
+ cancelRef,
398
+ );
399
+ if (status === 204) return true;
400
+ if (status === 404 || status === 503) return false;
401
+ throw new Error(`Failed to wait for session: ${status}`);
402
+ }
403
+
404
+ // Read message pages newest-first until the submitted prompt is found. The
405
+ // returned array contains only messages newer than that prompt, in timeline
406
+ // order. This avoids loading an entire long session while still respecting the
407
+ // API's pagination contract.
408
+ async function messagesAfterPrompt(server, sessionId, promptId, cancelRef) {
409
+ const encoded = encodeURIComponent(sessionId);
410
+ const base = await apiBase(server);
411
+ let cursor = null;
412
+ const seen = new Set();
413
+ const newer = [];
414
+ do {
415
+ const params = new URLSearchParams({ limit: '200', order: 'desc' });
416
+ if (cursor) {
417
+ params.delete('order');
418
+ params.set('cursor', cursor);
419
+ }
420
+ const url = `${base}/session/${encoded}/message?${params}`;
421
+ const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'), null, cancelRef);
422
+ if (status !== 200) throw new Error(`Failed to read session messages: ${status}`);
423
+ const parsed = JSON.parse(body);
424
+ const page = parsed && typeof parsed === 'object' && Array.isArray(parsed.data)
425
+ ? parsed.data
426
+ : (Array.isArray(parsed) ? parsed : []);
427
+ for (const message of page) {
428
+ if (String(message.id) === String(promptId)) return newer.reverse();
429
+ newer.push(message);
430
+ }
431
+ const next = parsed && parsed.cursor && parsed.cursor.next;
432
+ cursor = typeof next === 'string' && next && !seen.has(next) ? next : null;
433
+ if (cursor) seen.add(cursor);
434
+ } while (cursor);
435
+ throw new Error(`Submitted prompt ${promptId} was not found in session messages`);
436
+ }
437
+
438
+ function answerFromTurn(messages) {
439
+ const idle = messages.findIndex(message => message && message.type === 'idle');
440
+ const turn = idle === -1 ? messages : messages.slice(0, idle);
441
+ const text = [];
442
+ for (const message of turn) {
443
+ if (!message || message.type !== 'assistant' || !Array.isArray(message.content)) continue;
444
+ for (const part of message.content) {
445
+ if (part && part.type === 'text' && part.text != null && part.text !== '') text.push(String(part.text));
446
+ }
447
+ }
448
+ return text.join('\n\n');
449
+ }
450
+
451
+ async function readTurnAnswer(server, sessionId, promptId, cancelRef) {
452
+ return answerFromTurn(await messagesAfterPrompt(server, sessionId, promptId, cancelRef));
453
+ }
454
+
455
+ // Compatibility fallback for v2 servers predating the wait route. Do not use a
456
+ // deadline that can turn a valid long response into a successful partial one:
457
+ // wait until the submitted prompt is projected and a newer idle transition is
458
+ // visible while the process reports no active drain.
459
+ async function pollForSessionIdle(server, sessionId, promptId, admittedAt, cancelRef) {
460
+ for (;;) {
461
+ let messages = null;
462
+ try { messages = await messagesAfterPrompt(server, sessionId, promptId, cancelRef); } catch {}
463
+ const hasIdle = messages && messages.some(message => message && message.type === 'idle');
464
+ const active = await getActiveSessions(server);
465
+ let idle = 0;
466
+ try {
467
+ const session = await getSession(server, sessionId);
468
+ idle = session && session.time && session.time.idle || 0;
469
+ } catch {}
470
+ if (hasIdle && !active.has(String(sessionId)) && (!admittedAt || idle >= admittedAt)) return;
471
+ await new Promise(resolve => setTimeout(resolve, 500));
472
+ }
473
+ }
474
+
475
+ // Send a message directly to a specific session. Prompt admission gives us a
476
+ // durable message ID; wait for OpenCode to become idle, then reconstruct the
477
+ // answer from projected messages so missed/late SSE deltas cannot truncate it.
335
478
  async function sendToSession(server, sessionId, text, agent, cancelRef) {
336
479
  const encoded = encodeURIComponent(sessionId);
337
480
  const base = await apiBase(server);
@@ -340,32 +483,27 @@ async function sendToSession(server, sessionId, text, agent, cancelRef) {
340
483
  const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/agent`, 'POST', body), body);
341
484
  if (status !== 200 && status !== 204) throw new Error(`Failed to set agent: ${status}`);
342
485
  }
343
- let sendAt = Date.now();
344
- let verifyTries = 0;
345
- return listenV2(server, sessionId, {}, cancelRef, null, {
346
- start: async () => {
347
- const body = JSON.stringify({ text, delivery: 'steer' });
348
- const { status } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/prompt`, 'POST', body), body, cancelRef);
349
- if (status !== 200) throw new Error(`Failed to send to session: ${status}`);
350
- sendAt = Date.now();
351
- },
352
- // After a quiescence candidate, only treat the turn as done when the
353
- // session's idle marker is newer than our send — otherwise the terminal
354
- // signal probably belonged to an older queued execution (which would
355
- // produce an empty/partial answer). Give up after a few tryouts so a
356
- // stalled session can't hang the caller forever.
357
- verify: async () => {
358
- verifyTries++;
359
- if (verifyTries > 5) return true;
360
- try {
361
- const s = await getSession(server, sessionId);
362
- const idle = s && s.time && s.time.idle;
363
- return !!(idle && idle >= sendAt - 1000);
364
- } catch {
365
- return true; // cannot verify → trust the quiescence window
366
- }
367
- },
368
- });
486
+ const body = JSON.stringify({ text, delivery: 'steer' });
487
+ const { status, body: responseBody } = await makeRequest(
488
+ buildJsonRequest(`${base}/session/${encoded}/prompt`, 'POST', body),
489
+ body,
490
+ cancelRef,
491
+ );
492
+ if (status !== 200) throw new Error(`Failed to send to session: ${status}`);
493
+ let admitted;
494
+ try { admitted = parseBody(responseBody); } catch {}
495
+ const promptId = admitted && admitted.id;
496
+ if (!promptId) throw new Error('OpenCode did not return the submitted prompt ID');
497
+
498
+ const supported = await waitForSessionIdle(server, sessionId, cancelRef);
499
+ if (!supported) {
500
+ // Confirm the session still exists before interpreting a 404 as an older
501
+ // server without the experimental route.
502
+ await getSession(server, sessionId);
503
+ const admittedAt = admitted.time && admitted.time.created;
504
+ await pollForSessionIdle(server, sessionId, promptId, admittedAt, cancelRef);
505
+ }
506
+ return readTurnAnswer(server, sessionId, promptId, cancelRef);
369
507
  }
370
508
 
371
509
  // Create a new session. Optionally set a title and pin it to a project
@@ -740,7 +878,12 @@ module.exports = {
740
878
  listenForFinalAnswer,
741
879
  respondToQuestion,
742
880
  listSessions,
881
+ listProjects,
743
882
  getActiveSessions,
883
+ waitForSessionIdle,
884
+ messagesAfterPrompt,
885
+ answerFromTurn,
886
+ readTurnAnswer,
744
887
  getSession,
745
888
  createSession,
746
889
  updateSession,
@@ -768,4 +911,4 @@ module.exports = {
768
911
  _setApiVersion,
769
912
  _resetApiCache,
770
913
  _setCompletionGraceMs,
771
- };
914
+ };