agentp 2.0.0 → 2.1.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 +7 -5
- package/README.md +129 -27
- package/bin/agentp +224 -48
- package/bin/ocmux +965 -290
- package/bin/tgagentp +121 -130
- package/docs/specification.md +2 -1
- package/docs/specification_v2.md +188 -583
- package/lib/ocmux.js +26 -289
- package/lib/opencode.js +186 -43
- package/lib/project-state.js +8 -0
- package/lib/tui-cmd.js +12 -5
- package/lib/tui-registry.js +313 -0
- package/package.json +1 -1
package/lib/ocmux.js
CHANGED
|
@@ -1,300 +1,37 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
13
|
-
const child_process = require('child_process');
|
|
8
|
+
const registry = require('./tui-registry');
|
|
14
9
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
|
|
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;
|
|
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;
|
|
197
16
|
}
|
|
198
17
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
18
|
+
function switchTui(directory, server, session, executable) {
|
|
19
|
+
const target = registry.resolve(directory);
|
|
20
|
+
if (!target) return { ok: false, reason: 'none' };
|
|
21
|
+
const result = registry.respawn(target.instance, {
|
|
22
|
+
executable: executable || path.resolve(__dirname, '..', 'bin', 'ocmux'),
|
|
23
|
+
directory,
|
|
24
|
+
server,
|
|
25
|
+
session,
|
|
26
|
+
});
|
|
27
|
+
if (!result.ok) {
|
|
28
|
+
registry.pruneDead();
|
|
29
|
+
return { ok: false, reason: 'failed', kind: target.kind, error: result.error };
|
|
211
30
|
}
|
|
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'}`);
|
|
31
|
+
return { ok: true, kind: target.kind, instance: target.instance };
|
|
276
32
|
}
|
|
277
33
|
|
|
278
34
|
module.exports = {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
};
|
|
35
|
+
tuiArgs,
|
|
36
|
+
switchTui,
|
|
37
|
+
};
|
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',
|
|
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
|
|
253
|
-
//
|
|
254
|
-
//
|
|
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 (
|
|
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
|
-
|
|
326
|
-
|
|
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
|
|
329
|
-
|
|
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
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
+
};
|
package/lib/project-state.js
CHANGED
|
@@ -60,6 +60,9 @@ function normalizeState(raw, statefile) {
|
|
|
60
60
|
const annotations = state.annotations && typeof state.annotations === 'object' && !Array.isArray(state.annotations)
|
|
61
61
|
? state.annotations
|
|
62
62
|
: {};
|
|
63
|
+
const broadcast = Array.isArray(state.broadcast)
|
|
64
|
+
? state.broadcast.filter(x => typeof x === 'string' && x)
|
|
65
|
+
: null;
|
|
63
66
|
return {
|
|
64
67
|
file: statefile,
|
|
65
68
|
version: typeof state.version === 'number' ? state.version : 1,
|
|
@@ -68,6 +71,7 @@ function normalizeState(raw, statefile) {
|
|
|
68
71
|
server,
|
|
69
72
|
status,
|
|
70
73
|
annotations,
|
|
74
|
+
broadcast,
|
|
71
75
|
};
|
|
72
76
|
}
|
|
73
77
|
|
|
@@ -109,6 +113,10 @@ function writeProjectState(statefile, patch) {
|
|
|
109
113
|
if (patch.session !== undefined) next.session = patch.session;
|
|
110
114
|
if (patch.server !== undefined) next.server = patch.server;
|
|
111
115
|
if (patch.status !== undefined) next.status = patch.status;
|
|
116
|
+
if (patch.broadcast !== undefined) {
|
|
117
|
+
if (Array.isArray(patch.broadcast) && patch.broadcast.length >= 2) next.broadcast = [...patch.broadcast];
|
|
118
|
+
else delete next.broadcast;
|
|
119
|
+
}
|
|
112
120
|
const tmp = statefile + '.tmp';
|
|
113
121
|
fs.writeFileSync(tmp, JSON.stringify(next) + '\n');
|
|
114
122
|
fs.renameSync(tmp, statefile);
|