agentp 0.4.0 → 0.5.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 ADDED
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ const SESSION = 'Opencode';
8
+
9
+ function _tmux(args) {
10
+ const result = spawnSync('tmux', args, { encoding: 'utf8', maxBuffer: 1024 * 1024 });
11
+ if (result.error && result.error.code === 'ENOENT') {
12
+ return { status: -1, stdout: '', stderr: 'tmux not found' };
13
+ }
14
+ return result;
15
+ }
16
+
17
+ function readState(file) {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ function statefileFor(dir) {
26
+ return path.join(dir, '.ocmux.json');
27
+ }
28
+
29
+ function tuiPaneId(windowIndex) {
30
+ const r = _tmux(['list-panes', '-t', `${SESSION}:${windowIndex}`, '-F', '#{pane_id}\t#{pane_current_command}']);
31
+ if (r.status !== 0) return null;
32
+ for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
33
+ const [paneId, cmd] = line.split('\t');
34
+ if (cmd === 'node') return paneId;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ function windowByDir(dir) {
40
+ const r = _tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}\t#{window_name}']);
41
+ if (r.status !== 0) return null;
42
+ for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
43
+ const [idx, name] = line.split('\t');
44
+ if (name === dir) return parseInt(idx, 10);
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function windowNameByIndex(idx) {
50
+ const r = _tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}\t#{window_name}']);
51
+ if (r.status !== 0) return null;
52
+ for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
53
+ const [wi, name] = line.split('\t');
54
+ if (parseInt(wi, 10) === idx) return name;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function activeWindowIndex() {
60
+ const r = _tmux(['list-windows', '-t', SESSION, '-F', '#{window_index} #{window_active}']);
61
+ if (r.status !== 0) return null;
62
+ for (const line of r.stdout.trim().split('\n').filter(Boolean)) {
63
+ const [idx, active] = line.split(' ');
64
+ if (active === '1') return parseInt(idx, 10);
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function paneCount(windowIndex) {
70
+ const r = _tmux(['list-panes', '-t', `${SESSION}:${windowIndex}`, '-F', '#{pane_id}']);
71
+ if (r.status !== 0) return 0;
72
+ return r.stdout.trim().split('\n').filter(Boolean).length;
73
+ }
74
+
75
+ function listServers() {
76
+ const sessionR = _tmux(['has-session', '-t', SESSION]);
77
+ if (sessionR.status !== 0) return [];
78
+
79
+ const listR = _tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}\t#{window_name}']);
80
+ if (listR.status !== 0 || !listR.stdout.trim()) return [];
81
+
82
+ const rows = [];
83
+ for (const line of listR.stdout.trim().split('\n').filter(Boolean)) {
84
+ const [idx, dir] = line.split('\t');
85
+ const sf = statefileFor(dir);
86
+ if (!fs.existsSync(sf)) continue;
87
+ const state = readState(sf);
88
+ if (!state || !state.url) continue;
89
+ const tui = tuiPaneId(parseInt(idx, 10));
90
+ rows.push({ url: state.url, dir, index: parseInt(idx, 10), status: tui ? 'alive' : 'dead' });
91
+ }
92
+ return rows;
93
+ }
94
+
95
+ // Switch tmux focus to the given server window and ensure the TUI pane is alive and zoomed.
96
+ function activateServer(dir, index, url) {
97
+ const winIdx = index;
98
+
99
+ // Pin window name
100
+ _tmux(['set-window-option', '-t', `${SESSION}:${winIdx}`, 'automatic-rename', 'off']);
101
+
102
+ // Restart TUI pane if dead
103
+ const tuiPane = tuiPaneId(winIdx);
104
+ if (!tuiPane) {
105
+ const count = paneCount(winIdx);
106
+ if (count >= 2) {
107
+ const panesR = _tmux(['list-panes', '-t', `${SESSION}:${winIdx}`, '-F', '#{pane_id}']);
108
+ if (panesR.status === 0) {
109
+ const panes = panesR.stdout.trim().split('\n').filter(Boolean);
110
+ const lastPane = panes[panes.length - 1];
111
+ const respawnR = _tmux(['respawn-pane', '-k', '-t', lastPane, `opencode attach --continue '${url}'`]);
112
+ if (respawnR.status !== 0) {
113
+ _tmux(['send-keys', '-t', lastPane, `opencode attach --continue '${url}'`, 'Enter']);
114
+ }
115
+ }
116
+ } else {
117
+ _tmux(['split-window', '-v', '-t', `${SESSION}:${winIdx}`, '-c', dir,
118
+ `opencode attach --continue '${url}'`]);
119
+ }
120
+ }
121
+
122
+ // Select window
123
+ const activeWin = activeWindowIndex();
124
+ if (activeWin !== null && activeWin !== winIdx) {
125
+ _tmux(['select-window', '-t', `${SESSION}:${winIdx}`]);
126
+ }
127
+
128
+ // Zoom TUI pane
129
+ const zoomR = _tmux(['list-windows', '-t', SESSION, '-F', '#{window_index}|#{window_zoomed_flag}']);
130
+ if (zoomR.status === 0) {
131
+ let zoomed = false;
132
+ for (const line of zoomR.stdout.trim().split('\n').filter(Boolean)) {
133
+ const [wi, z] = line.split('|');
134
+ if (parseInt(wi, 10) === winIdx && z === '1') { zoomed = true; break; }
135
+ }
136
+ if (!zoomed) {
137
+ const tui = tuiPaneId(winIdx);
138
+ if (tui) _tmux(['resize-pane', '-Z', '-t', tui]);
139
+ }
140
+ }
141
+
142
+ return true;
143
+ }
144
+
145
+ module.exports = {
146
+ SESSION,
147
+ readState,
148
+ statefileFor,
149
+ tuiPaneId,
150
+ windowByDir,
151
+ windowNameByIndex,
152
+ activeWindowIndex,
153
+ paneCount,
154
+ listServers,
155
+ activateServer,
156
+ };
@@ -0,0 +1,270 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const { URL } = require('url');
5
+
6
+ function getAuthHeaders() {
7
+ const password = process.env.OPENCODE_SERVER_PASSWORD;
8
+ if (!password) return {};
9
+ const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode';
10
+ const encoded = Buffer.from(`${username}:${password}`).toString('base64');
11
+ return { 'Authorization': `Basic ${encoded}` };
12
+ }
13
+
14
+ function makeRequest(options, data) {
15
+ return new Promise((resolve, reject) => {
16
+ const req = http.request(options, (res) => {
17
+ let body = '';
18
+ res.on('data', (chunk) => { body += chunk; });
19
+ res.on('end', () => {
20
+ if (res.statusCode === 401) {
21
+ reject(new Error('OpenCode server authentication failed. Set OPENCODE_SERVER_PASSWORD to match the server password.'));
22
+ } else {
23
+ resolve({ status: res.statusCode, body });
24
+ }
25
+ });
26
+ });
27
+ req.on('error', reject);
28
+ if (data) req.write(data);
29
+ req.end();
30
+ });
31
+ }
32
+
33
+ function buildJsonRequest(url, method, body) {
34
+ const parsed = new URL(url);
35
+ const opts = {
36
+ hostname: parsed.hostname,
37
+ port: parsed.port,
38
+ path: parsed.pathname + (parsed.search || ''),
39
+ method,
40
+ headers: { ...getAuthHeaders() }
41
+ };
42
+ if (body != null) {
43
+ opts.headers['Content-Type'] = 'application/json';
44
+ opts.headers['Content-Length'] = Buffer.byteLength(body);
45
+ }
46
+ return opts;
47
+ }
48
+
49
+ async function clearPrompt(server) {
50
+ const url = `${server}/tui/clear-prompt`;
51
+ await makeRequest(buildJsonRequest(url, 'POST', '{}'), '{}');
52
+ }
53
+
54
+ async function appendPrompt(server, text) {
55
+ const url = `${server}/tui/append-prompt`;
56
+ const body = JSON.stringify({ text });
57
+ await makeRequest(buildJsonRequest(url, 'POST', body), body);
58
+ }
59
+
60
+ async function submitPrompt(server) {
61
+ const url = `${server}/tui/submit-prompt`;
62
+ await makeRequest(buildJsonRequest(url, 'POST', '{}'), '{}');
63
+ }
64
+
65
+ // Send a complete text string as a prompt (clear + append + submit).
66
+ async function sendText(server, text) {
67
+ await clearPrompt(server);
68
+ await appendPrompt(server, text);
69
+ await submitPrompt(server);
70
+ }
71
+
72
+ // Listen for the final answer from the OpenCode event stream.
73
+ // onText(chunk) is called for each text part received (optional; defaults to no-op).
74
+ // cancelRef — if provided, { current: null } is populated with the request so it can be aborted.
75
+ // Returns a Promise that resolves with the full collected response string.
76
+ function listenForFinalAnswer(server, onText, cancelRef) {
77
+ return new Promise((resolve, reject) => {
78
+ const url = `${server}/event`;
79
+ const parsed = new URL(url);
80
+ const write = typeof onText === 'function' ? onText : null;
81
+
82
+ const req = http.request({
83
+ hostname: parsed.hostname,
84
+ port: parsed.port,
85
+ path: parsed.pathname,
86
+ method: 'GET',
87
+ headers: { 'Accept': 'text/event-stream', ...getAuthHeaders() }
88
+ }, (res) => {
89
+ if (res.statusCode === 401) {
90
+ res.resume();
91
+ reject(new Error('OpenCode server authentication failed. Set OPENCODE_SERVER_PASSWORD to match the server password.'));
92
+ return;
93
+ }
94
+ let buffer = '';
95
+ const userMessageIDs = new Set();
96
+ let collected = '';
97
+
98
+ res.on('data', (chunk) => {
99
+ buffer += chunk.toString();
100
+
101
+ // Split by double newline (SSE event separator)
102
+ while (buffer.includes('\n\n')) {
103
+ const eventEnd = buffer.indexOf('\n\n');
104
+ const eventData = buffer.slice(0, eventEnd);
105
+ buffer = buffer.slice(eventEnd + 2);
106
+
107
+ // Extract data lines
108
+ const lines = eventData.split('\n');
109
+ let jsonStr = '';
110
+
111
+ for (const line of lines) {
112
+ if (line.startsWith('data: ')) {
113
+ jsonStr += line.slice(6);
114
+ }
115
+ }
116
+
117
+ if (!jsonStr.trim()) continue;
118
+
119
+ try {
120
+ const event = JSON.parse(jsonStr);
121
+
122
+ // Track user message IDs
123
+ if (event.type === 'message.updated' && event.properties?.info?.role === 'user') {
124
+ userMessageIDs.add(event.properties.info.id);
125
+ }
126
+
127
+ // Check for session.idle to detect completion
128
+ if (event.type === 'session.idle') {
129
+ cleanup();
130
+ req.destroy();
131
+ res.destroy();
132
+ resolve(collected);
133
+ return;
134
+ }
135
+
136
+ const part = event.properties?.part;
137
+ if (!part) continue;
138
+
139
+ // Filter out user input by checking messageID
140
+ if (part.messageID && userMessageIDs.has(part.messageID)) {
141
+ continue;
142
+ }
143
+
144
+ if (part.type === 'text' && part.text != null && part.text !== '') {
145
+ if (write) write(part.text);
146
+ collected += part.text;
147
+ }
148
+ } catch (e) {
149
+ // Malformed SSE event — skip and continue processing
150
+ }
151
+ }
152
+ });
153
+
154
+ res.on('end', () => {
155
+ cleanup();
156
+ resolve(collected);
157
+ });
158
+ });
159
+
160
+ const cleanup = () => {
161
+ if (cancelRef) cancelRef.current = null;
162
+ };
163
+
164
+ req.on('error', (err) => {
165
+ cleanup();
166
+ reject(err);
167
+ });
168
+
169
+ if (cancelRef) cancelRef.current = req;
170
+ req.end();
171
+ });
172
+ }
173
+
174
+ // List all sessions from the OpenCode server.
175
+ // Optionally filter by project directory.
176
+ async function listSessions(server, directory) {
177
+ let url = `${server}/session`;
178
+ if (directory) url += '?directory=' + encodeURIComponent(directory);
179
+ const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
180
+ if (status !== 200) throw new Error(`Failed to list sessions: ${status}`);
181
+ return JSON.parse(body);
182
+ }
183
+
184
+ // Send a message directly to a specific session via the session API.
185
+ // Returns the collected text from all text parts in the response.
186
+ // Optionally specify an agent to handle the message.
187
+ async function sendToSession(server, sessionId, text, agent) {
188
+ const url = `${server}/session/${encodeURIComponent(sessionId)}/message`;
189
+ const bodyObj = { parts: [{ type: 'text', text }] };
190
+ if (agent) bodyObj.agent = agent;
191
+ const body = JSON.stringify(bodyObj);
192
+ const { status, body: responseBody } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
193
+ if (status !== 200) throw new Error(`Failed to send to session: ${status}`);
194
+ const result = JSON.parse(responseBody);
195
+ const texts = (result.parts || [])
196
+ .filter(p => p.type === 'text')
197
+ .map(p => p.text)
198
+ .join('');
199
+ return texts;
200
+ }
201
+
202
+ // Create a new session on the OpenCode server. Optionally set a title.
203
+ async function createSession(server, title) {
204
+ const url = `${server}/session`;
205
+ const body = JSON.stringify(title != null ? { title } : {});
206
+ const { status, body: responseBody } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
207
+ if (status !== 200) throw new Error(`Failed to create session: ${status}`);
208
+ return JSON.parse(responseBody);
209
+ }
210
+
211
+ // Update a session's properties (e.g. title).
212
+ async function updateSession(server, sessionId, title) {
213
+ const url = `${server}/session/${encodeURIComponent(sessionId)}`;
214
+ const body = JSON.stringify({ title });
215
+ const { status, body: responseBody } = await makeRequest(buildJsonRequest(url, 'PATCH', body), body);
216
+ if (status !== 200) throw new Error(`Failed to update session: ${status}`);
217
+ return JSON.parse(responseBody);
218
+ }
219
+
220
+ // Tell the TUI to navigate to a specific session (if a TUI is attached).
221
+ // Tries multiple approaches since the endpoint varies by opencode version.
222
+ async function selectSession(server, sessionId) {
223
+ const encoded = encodeURIComponent(sessionId);
224
+ const attempts = [
225
+ { url: `${server}/session/${encoded}/select`, body: '{}' },
226
+ { url: `${server}/tui/select-session`, body: JSON.stringify({ sessionID: sessionId }) },
227
+ ];
228
+ for (const { url, body } of attempts) {
229
+ try {
230
+ await makeRequest(buildJsonRequest(url, 'POST', body), body);
231
+ return;
232
+ } catch {
233
+ // try next approach
234
+ }
235
+ }
236
+ }
237
+
238
+ // List all agents from the OpenCode server.
239
+ async function listAgents(server) {
240
+ const url = `${server}/agent`;
241
+ const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
242
+ if (status !== 200) throw new Error(`Failed to list agents: ${status}`);
243
+ return JSON.parse(body);
244
+ }
245
+
246
+ // List all providers/models from the OpenCode server.
247
+ async function listProviders(server) {
248
+ const url = `${server}/provider`;
249
+ const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'));
250
+ if (status !== 200) throw new Error(`Failed to list providers: ${status}`);
251
+ return JSON.parse(body);
252
+ }
253
+
254
+ module.exports = {
255
+ getAuthHeaders,
256
+ makeRequest,
257
+ buildJsonRequest,
258
+ clearPrompt,
259
+ appendPrompt,
260
+ submitPrompt,
261
+ sendText,
262
+ listenForFinalAnswer,
263
+ listSessions,
264
+ createSession,
265
+ updateSession,
266
+ selectSession,
267
+ sendToSession,
268
+ listAgents,
269
+ listProviders,
270
+ };
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Pipe prompts into a running OpenCode TUI session and stream the final answer",
5
5
  "bin": {
6
6
  "agentp": "bin/agentp",
7
- "ocmux": "bin/ocmux"
7
+ "ocmux": "bin/ocmux",
8
+ "tgagentp": "bin/tgagentp"
8
9
  },
9
10
  "keywords": [
10
11
  "ai",