agentp 0.8.0 → 0.10.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.
@@ -0,0 +1,121 @@
1
+ # Agentp: Turn OpenCode Into a Headless AI Engine for Your Editor, Terminal, and Telegram
2
+
3
+ OpenCode is great, but its TUI locks you in. You type in one window, watch it stream, and that's it. What if you could:
4
+
5
+ - Pipe a prompt straight from Vim and replace your selection with the answer?
6
+ - Queue messages from Telegram while it's busy and get replies threaded?
7
+ - Keep a permanent tmux server per project and switch between them?
8
+ - Pull the last N answers from session history without sending a new prompt?
9
+ - Record your Telegram conversation and inject it as context into your next `agentp` call?
10
+
11
+ That's what **agentp** — a set of three zero-dependency Node.js CLI tools — does.
12
+
13
+ ---
14
+
15
+ ## The Three Tools
16
+
17
+ ### `agentp` — The Pipe
18
+
19
+ Stdin in, answer out. That's the core loop.
20
+
21
+ ```bash
22
+ printf "Summarize this file" | agentp
23
+ cat prompt.txt | agentp 4096
24
+ ```
25
+
26
+ From Vim/Neovim:
27
+
28
+ ```vim
29
+ :'<,'>!agentp --qa
30
+ ```
31
+
32
+ This replaces your visual selection with the answer. `--qa` preserves the prompt + answer with labels, so you keep the full context.
33
+
34
+ ### `ocmux` — The Project Manager
35
+
36
+ Each project gets its own tmux window with a dedicated `opencode serve` + TUI pane. Auto-restarts dead panes, pins window names, stores state in `.ocmux.json` (discovered upward like `.git`).
37
+
38
+ ```bash
39
+ ocmux serve ~/projects/myapp # create
40
+ ocmux list # list all
41
+ ocmux ~/projects/myapp # switch
42
+ ocmux kill ~/projects/myapp # remove
43
+ ```
44
+
45
+ ### `tgagentp` — The Telegram Bridge
46
+
47
+ A Telegram bot that routes messages to your OpenCode servers. Multi-server aware, slash-commands for everything.
48
+
49
+ **Slash commands:**
50
+
51
+ | Command | What it does |
52
+ |---|---|
53
+ | `/servers` | List/switch between ocmux projects |
54
+ | `/sessions` | List/switch/create/rename sessions |
55
+ | `/agents` | List/switch active agent |
56
+ | `/models` | List providers and models |
57
+ | `/queue <msg>` | Queue message when busy — auto-sent after current task finishes (replies chain!) |
58
+ | `/record` | Record conversation for `agentp` context injection |
59
+ | `/think` | Toggle real-time thinking message forwarding |
60
+ | `/cancel` | Abort the running prompt |
61
+
62
+ ---
63
+
64
+ ## The Agentp Gateway
65
+
66
+ The killer integration: `agentp` and `tgagentp` talk to each other through a tiny HTTP gateway.
67
+
68
+ - **`agentp --tg`** forwards the answer to your Telegram chat after every pipe.
69
+ - **`agentp --qa`** auto-detects tgagentp and sends the full QA pair (rulers + prompt + answer).
70
+ - **`/record`** buffers the Telegram conversation. On the next `agentp` call, the gateway returns the buffer, and `--qa` prepends it to stdout with rulers — so OpenCode sees the full Telegram thread as context.
71
+ - **`agentp --flush`** clears the buffer without prepending.
72
+ - **`agentp --getLast 5`** retrieves the last 5 assistant answers from session history.
73
+
74
+ ```vim
75
+ :'<,'>!agentp --qa --tg " answer in editor + Telegram
76
+ :'<,'>!agentp --qa --flush " flush recorded buffer
77
+ ```
78
+
79
+ All of this with **zero npm dependencies** — just Node.js 18+ stdlib.
80
+
81
+ ---
82
+
83
+ ## Why This Setup Works
84
+
85
+ 1. **Session API, not TUI hooks** — messages go through `POST /session/:id/message`, which returns synchronously. No fragile TUI scraping.
86
+ 2. **Per-project tmux isolation** — each project gets its own `opencode serve` process. Switch with a single command.
87
+ 3. **Graceful degradation** — `--tg` in auto mode silently skips Telegram if tgagentp isn't running. Explicit `--tg` errors pre-send, warns post-send.
88
+ 4. **10s HTTP timeout on every request** — nothing ever hangs.
89
+ 5. **Async processing** — tgagentp never blocks the polling loop. Commands stay responsive even while a prompt runs.
90
+
91
+ ---
92
+
93
+ ## Quick Start
94
+
95
+ ```bash
96
+ npm install -g agentp
97
+
98
+ # Start a project server
99
+ ocmux serve ~/projects/myapp
100
+
101
+ # Pipe a prompt
102
+ printf "Explain this codebase" | agentp
103
+
104
+ # With Telegram (set up a bot with @BotFather first)
105
+ export TELEGRAM_BOT_TOKEN="your-token"
106
+ tgagentp
107
+ ```
108
+
109
+ If your OpenCode server is password-protected, set `OPENCODE_SERVER_PASSWORD`.
110
+
111
+ ---
112
+
113
+ ## Links
114
+
115
+ - **GitHub:** [github.com/bitifet/agentp](https://github.com/bitifet/agentp)
116
+ - **npm:** [npmjs.com/package/agentp](https://www.npmjs.com/package/agentp)
117
+ - **OpenCode:** [github.com/anthropics/claude-code](https://github.com/anthropics/claude-code)
118
+
119
+ ---
120
+
121
+ *Feedback? Issues? PRs welcome.*
package/lib/opencode.js CHANGED
@@ -11,7 +11,7 @@ function getAuthHeaders() {
11
11
  return { 'Authorization': `Basic ${encoded}` };
12
12
  }
13
13
 
14
- function makeRequest(options, data) {
14
+ function makeRequest(options, data, cancelRef, timeoutMs) {
15
15
  return new Promise((resolve, reject) => {
16
16
  const req = http.request(options, (res) => {
17
17
  let body = '';
@@ -24,7 +24,14 @@ function makeRequest(options, data) {
24
24
  }
25
25
  });
26
26
  });
27
+ if (timeoutMs) {
28
+ req.setTimeout(timeoutMs, () => {
29
+ req.destroy();
30
+ reject(new Error('Request timed out'));
31
+ });
32
+ }
27
33
  req.on('error', reject);
34
+ if (cancelRef) cancelRef.current = req;
28
35
  if (data) req.write(data);
29
36
  req.end();
30
37
  });
@@ -181,15 +188,16 @@ async function listSessions(server, directory) {
181
188
  return JSON.parse(body);
182
189
  }
183
190
 
184
- // Send a message directly to a specific session via the session API.
191
+ // Send a message directly to a specific session via the synchronous session API.
185
192
  // Returns the collected text from all text parts in the response.
186
193
  // Optionally specify an agent to handle the message.
187
- async function sendToSession(server, sessionId, text, agent) {
194
+ // Optional cancelRef allows aborting the HTTP request (for /cancel support).
195
+ async function sendToSession(server, sessionId, text, agent, cancelRef) {
188
196
  const url = `${server}/session/${encodeURIComponent(sessionId)}/message`;
189
197
  const bodyObj = { parts: [{ type: 'text', text }] };
190
198
  if (agent) bodyObj.agent = agent;
191
199
  const body = JSON.stringify(bodyObj);
192
- const { status, body: responseBody } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
200
+ const { status, body: responseBody } = await makeRequest(buildJsonRequest(url, 'POST', body), body, cancelRef);
193
201
  if (status !== 200) throw new Error(`Failed to send to session: ${status}`);
194
202
  const result = JSON.parse(responseBody);
195
203
  const texts = (result.parts || [])
@@ -209,9 +217,12 @@ async function createSession(server, title) {
209
217
  }
210
218
 
211
219
  // Update a session's properties (e.g. title).
212
- async function updateSession(server, sessionId, title) {
220
+ async function updateSession(server, sessionId, title, agent) {
221
+ const bodyObj = {};
222
+ if (title != null) bodyObj.title = title;
223
+ if (agent != null) bodyObj.agent = agent;
213
224
  const url = `${server}/session/${encodeURIComponent(sessionId)}`;
214
- const body = JSON.stringify({ title });
225
+ const body = JSON.stringify(bodyObj);
215
226
  const { status, body: responseBody } = await makeRequest(buildJsonRequest(url, 'PATCH', body), body);
216
227
  if (status !== 200) throw new Error(`Failed to update session: ${status}`);
217
228
  return JSON.parse(responseBody);
@@ -238,6 +249,27 @@ async function selectSession(server, sessionId) {
238
249
  }
239
250
  }
240
251
 
252
+ // Get a single session by ID, including its message history.
253
+ // Tries GET /session/:id first; falls back to filtering the sessions list.
254
+ async function getSession(server, sessionId) {
255
+ // Try dedicated endpoint first (newer opencode versions)
256
+ try {
257
+ const url = `${server}/session/${encodeURIComponent(sessionId)}`;
258
+ const { status, body } = await makeRequest(buildJsonRequest(url, 'GET'), null, null, 10000);
259
+ if (status === 200) {
260
+ const parsed = JSON.parse(body);
261
+ if (parsed) return parsed;
262
+ }
263
+ } catch {
264
+ // Fall through
265
+ }
266
+ // Fallback: get all sessions and find the one we need
267
+ const sessions = await listSessions(server);
268
+ const match = sessions.find(s => String(s.id) === String(sessionId));
269
+ if (match) return match;
270
+ throw new Error(`Session ${sessionId} not found`);
271
+ }
272
+
241
273
  // List all agents from the OpenCode server.
242
274
  async function listAgents(server) {
243
275
  const url = `${server}/agent`;
@@ -276,7 +308,7 @@ async function respondToPermission(server, sessionId, permissionId, response, re
276
308
 
277
309
  // Listen for session events via SSE, collecting assistant text parts and detecting
278
310
  // permission requests. Resolves with the full collected text when the session goes idle.
279
- // callbacks: { onText(chunk), onPermission(permission) }
311
+ // callbacks: { onText(chunk), onPermission(permission), onThinking(chunk), onConnected() }
280
312
  // cancelRef — allows aborting the SSE stream via req.destroy()
281
313
  function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
282
314
  process.stderr.write(` [SSE] connecting to ${server}/event for session ${sessionId}\n`);
@@ -297,9 +329,22 @@ function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
297
329
  return;
298
330
  }
299
331
  process.stderr.write(` [SSE] connected for session ${sessionId}\n`);
332
+ if (callbacks.onConnected) callbacks.onConnected();
300
333
  let buffer = '';
301
334
  const userMessageIDs = new Set();
302
335
  let collected = '';
336
+ let resolved = false;
337
+
338
+ // Safety timeout: resolve with whatever we have after 90s
339
+ const safetyTimer = setTimeout(() => {
340
+ if (resolved) return;
341
+ resolved = true;
342
+ process.stderr.write(` [SSE] safety timeout (90s) for session ${sessionId}, collected ${collected.length} chars\n`);
343
+ cleanup();
344
+ req.destroy();
345
+ res.destroy();
346
+ resolve(collected);
347
+ }, 90000);
303
348
 
304
349
  res.on('data', (chunk) => {
305
350
  buffer += chunk.toString();
@@ -325,8 +370,8 @@ function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
325
370
  process.stderr.write(` [SSE] event type=${event.type} sessionID=${props.sessionID} waitSession=${sessionId}\n`);
326
371
  }
327
372
 
328
- // Filter by sessionID when the event carries one
329
- if (props.sessionID && props.sessionID !== sessionId) continue;
373
+ // Filter by sessionID when the event carries one (string-compare to handle type mismatches)
374
+ if (props.sessionID && String(props.sessionID) !== String(sessionId)) continue;
330
375
 
331
376
  if (event.type === 'message.updated' && props.info?.role === 'user') {
332
377
  userMessageIDs.add(props.info.id);
@@ -355,6 +400,9 @@ function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
355
400
 
356
401
  // Completion signals
357
402
  if (event.type === 'session.idle') {
403
+ clearTimeout(safetyTimer);
404
+ if (resolved) return;
405
+ resolved = true;
358
406
  cleanup();
359
407
  req.destroy();
360
408
  res.destroy();
@@ -362,6 +410,9 @@ function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
362
410
  return;
363
411
  }
364
412
  if (event.type === 'session.status' && props.status?.type === 'idle') {
413
+ clearTimeout(safetyTimer);
414
+ if (resolved) return;
415
+ resolved = true;
365
416
  cleanup();
366
417
  req.destroy();
367
418
  res.destroy();
@@ -390,9 +441,21 @@ function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
390
441
  });
391
442
 
392
443
  res.on('end', () => {
444
+ clearTimeout(safetyTimer);
445
+ if (resolved) return;
446
+ resolved = true;
393
447
  cleanup();
394
448
  resolve(collected);
395
449
  });
450
+
451
+ res.on('error', (err) => {
452
+ clearTimeout(safetyTimer);
453
+ if (resolved) return;
454
+ resolved = true;
455
+ cleanup();
456
+ process.stderr.write(` [SSE] response stream error for session ${sessionId}: ${err.message}\n`);
457
+ resolve(collected); // resolve with what we have
458
+ });
396
459
  });
397
460
 
398
461
  const cleanup = () => {
@@ -419,6 +482,7 @@ module.exports = {
419
482
  sendText,
420
483
  listenForFinalAnswer,
421
484
  listSessions,
485
+ getSession,
422
486
  createSession,
423
487
  updateSession,
424
488
  selectSession,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",