agentp 0.5.0 → 0.7.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/AGENTS.md CHANGED
@@ -42,7 +42,7 @@ Shared ocmux session discovery; used by both `bin/ocmux` and `bin/tgagentp`:
42
42
 
43
43
  - `readState(file)` — parses `.ocmux.json`, returns `null` on error
44
44
  - `statefileFor(dir)` — `path.join(dir, '.ocmux.json')`
45
- - `tuiPaneId(windowIndex)` — returns pane ID if TUI process (`node`) is running, `null` if dead
45
+ - `tuiPaneId(windowIndex)` — returns pane ID of the TUI pane (pane index != 0), `null` if dead
46
46
  - `windowByDir(dir)` — returns window index by name match
47
47
  - `windowNameByIndex(idx)` — reverse: name from index
48
48
  - `activeWindowIndex()` — returns index of currently selected tmux window
@@ -95,7 +95,12 @@ These endpoints are consumed but not documented elsewhere in the repo:
95
95
 
96
96
  ### Pending
97
97
 
98
- *(none)*
98
+ - TUI navigation on session switch (open code API limitation — likely not possible)
99
+ - `--print-logs` flag for `ocmux serve`
100
+
101
+ ### In Progress
102
+
103
+ - Event-driven permission handling via Telegram: `POST /session/:id/prompt_async` + `GET /event` SSE listener for `permission.updated` events + `/allow`, `/reject`, `/always` commands — implemented, pending user testing
99
104
 
100
105
  ### Done
101
106
 
@@ -108,7 +113,10 @@ These endpoints are consumed but not documented elsewhere in the repo:
108
113
  - Fix: /sessions now shows the active session even without agent set (exception in filter)
109
114
  - Fix: /sessions switch and /sessions new call selectSession (TUI navigation doesn't work — see With Issues)
110
115
  - Fix: active session highlighted with ▶ bullet in session list
116
+ - Fix: discover TUI session on startup and server switch via `discoverActiveSession()`
111
117
  - Message splitting (>4096 chars, breaks at newlines)
118
+ - Fix: tuiPaneId detects 'bun' (opencode 1.15.x) in addition to 'node'
119
+ - Fix: URL polling in ocmux doNew takes last match, not first (more robust with bun startup)
112
120
  - lib/ocmux.js shared session discovery, refactor ocmux + tgagentp
113
121
  - /cancel with true server-side SSE abort via cancelRef
114
122
  - /shutdown (--dev mode) with Telegram offset acknowledgment
package/bin/ocmux CHANGED
@@ -346,13 +346,15 @@ function doNew(targetDir, longMode) {
346
346
  tmux(['send-keys', '-t', `${SESSION}:${winIdx}.0`,
347
347
  `opencode serve --port 0 2>&1 | tee '${lf}'`, 'Enter']);
348
348
 
349
- // Wait up to 10s for the URL in the logfile
349
+ // Wait up to 10s for the URL in the logfile.
350
+ // Don't break on first match — keep polling so url ends up holding the
351
+ // final URL in case the server prints multiple URLs during startup.
350
352
  let url = null;
351
353
  for (let i = 0; i < 50; i++) {
352
354
  sleep(0.2);
353
355
  const log = (() => { try { return fs.readFileSync(lf, 'utf8'); } catch { return ''; } })();
354
356
  const m = log.match(URL_RE);
355
- if (m) { url = m[1]; break; }
357
+ if (m) url = m[1];
356
358
  }
357
359
 
358
360
  if (!url) {
package/bin/tgagentp CHANGED
@@ -4,7 +4,19 @@ const https = require('https');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { version } = require('../package.json');
7
- const { sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, listAgents, listProviders } = require('../lib/opencode.js');
7
+ const { sendText, listenForFinalAnswer, listSessions, createSession, updateSession, selectSession, sendToSession, sendToSessionAsync, respondToPermission, listenForSessionEvents, listAgents, listProviders } = require('../lib/opencode.js');
8
+
9
+ // Monkey-patch stderr to prefix all output with timestamps
10
+ {
11
+ const origWrite = process.stderr.write.bind(process.stderr);
12
+ process.stderr.write = function (chunk, encoding, cb) {
13
+ const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
14
+ const str = typeof chunk === 'string' ? chunk : chunk.toString(encoding || 'utf8');
15
+ const lines = str.split('\n');
16
+ const prefixed = lines.map(l => l ? `[${ts}] ${l}` : l).join('\n');
17
+ return origWrite(prefixed, encoding, cb);
18
+ };
19
+ }
8
20
 
9
21
  const STARTUP_CHAT_FILE = '/tmp/tgagentp-startup-chat';
10
22
  const { listServers, activateServer } = require('../lib/ocmux.js');
@@ -224,8 +236,18 @@ async function cmdHelp(token, chatId, args) {
224
236
  'with status, context limit, and input/output costs.');
225
237
  return;
226
238
  }
239
+ if (topic === 'allow' || topic === 'reject' || topic === 'always') {
240
+ await sendLongMessage(token, chatId,
241
+ '`/allow` — approve a permission request once\n' +
242
+ '`/reject` — deny a permission request\n' +
243
+ '`/always` — approve and remember for the session\n\n' +
244
+ 'These commands respond to OpenCode permission prompts forwarded by tgagentp.\n' +
245
+ 'When OpenCode needs approval (e.g. to run a shell command), tgagentp sends a\n' +
246
+ '🔐 notification to the chat. Use one of these commands to answer.');
247
+ return;
248
+ }
227
249
  await sendLongMessage(token, chatId,
228
- 'Unknown topic. Try `/help servers`, `/help sessions`, `/help session`, `/help agents`, `/help models`, or just `/help`.');
250
+ 'Unknown topic. Try `/help servers`, `/help sessions`, `/help session`, `/help agents`, `/help models`, `/help allow`, or just `/help`.');
229
251
  return;
230
252
  }
231
253
  await sendLongMessage(token, chatId,
@@ -243,6 +265,7 @@ async function cmdHelp(token, chatId, args) {
243
265
  '`/agents` — list available agents\n' +
244
266
  '`/agents switch <name>` — switch active agent\n' +
245
267
  '`/models` — list providers and models\n' +
268
+ '`/allow` / `/reject` / `/always` — respond to permission requests\n' +
246
269
  '`/shutdown` — stop tgagentp (only with `--dev` flag)\n\n' +
247
270
  'Everything else is forwarded to the active server as `[Telegram] <text>`.');
248
271
  }
@@ -279,7 +302,7 @@ async function cmdServers(token, chatId, args, setServer) {
279
302
  return;
280
303
  }
281
304
  const target = matches[0];
282
- const pending = setServer(target.url);
305
+ const pending = await setServer(target.url);
283
306
  activateServer(target.dir, target.index, target.url);
284
307
  let msg = `Switched to server: ${target.url} → ${target.dir}`;
285
308
  if (pending && pending.trim()) {
@@ -297,7 +320,11 @@ async function cmdServers(token, chatId, args, setServer) {
297
320
  await sendLongMessage(token, chatId, 'No servers running.');
298
321
  return;
299
322
  }
300
- const lines = servers.map(s => ` ${s.url} → ${s.dir} (${s.status})`);
323
+ const activeUrl = normalizeUrl(serverBase);
324
+ const lines = servers.map(s => {
325
+ const bullet = normalizeUrl(s.url) === activeUrl ? '▶ ' : ' · ';
326
+ return bullet + `${s.url} → ${s.dir} (${s.status})`;
327
+ });
301
328
  await sendLongMessage(token, chatId, 'Active servers:\n' + lines.join('\n'));
302
329
  }
303
330
 
@@ -324,6 +351,21 @@ function sortedSessions(sessions, limit) {
324
351
  .slice(0, limit || 50);
325
352
  }
326
353
 
354
+ // Find the most recently updated session and return its ID and name.
355
+ // Returns null if no sessions exist or the server is unreachable.
356
+ async function discoverActiveSession(server) {
357
+ try {
358
+ const sessions = await listSessions(server);
359
+ if (!sessions || sessions.length === 0) return null;
360
+ const sorted = sortedSessions(sessions, 1);
361
+ if (sorted.length === 0) return null;
362
+ const s = sorted[0];
363
+ return { id: s.id, name: sessionName(s) };
364
+ } catch {
365
+ return null;
366
+ }
367
+ }
368
+
327
369
  async function cmdSessions(token, chatId, args, setServer, setActiveSession, server) {
328
370
  if (args.length > 0) {
329
371
  const sub = args[0].toLowerCase();
@@ -590,6 +632,56 @@ async function cmdModels(token, chatId, args, setServer, setActiveSession, serve
590
632
  }
591
633
  }
592
634
 
635
+ const PERM_RESPONSE_HELP = ' No pending permission. Use `/status` to check the current state.';
636
+
637
+ async function cmdAllow(token, chatId, args, setServer, setActiveSession, server) {
638
+ const st = getState(server);
639
+ if (!st.pendingPermission) {
640
+ await sendLongMessage(token, chatId, '/allow' + PERM_RESPONSE_HELP);
641
+ return;
642
+ }
643
+ const p = st.pendingPermission;
644
+ try {
645
+ await respondToPermission(p.server, p.sessionId, p.permissionId, 'once');
646
+ await sendLongMessage(token, chatId, '✅ Permission approved (once).');
647
+ process.stderr.write(` → /allow: ${p.type} ${p.pattern || ''}\n`);
648
+ } catch (err) {
649
+ await sendLongMessage(token, chatId, `Error: ${err.message}`);
650
+ }
651
+ }
652
+
653
+ async function cmdReject(token, chatId, args, setServer, setActiveSession, server) {
654
+ const st = getState(server);
655
+ if (!st.pendingPermission) {
656
+ await sendLongMessage(token, chatId, '/reject' + PERM_RESPONSE_HELP);
657
+ return;
658
+ }
659
+ const p = st.pendingPermission;
660
+ try {
661
+ await respondToPermission(p.server, p.sessionId, p.permissionId, 'reject');
662
+ await sendLongMessage(token, chatId, '⛔ Permission denied.');
663
+ process.stderr.write(` → /reject: ${p.type} ${p.pattern || ''}\n`);
664
+ } catch (err) {
665
+ await sendLongMessage(token, chatId, `Error: ${err.message}`);
666
+ }
667
+ }
668
+
669
+ async function cmdAlways(token, chatId, args, setServer, setActiveSession, server) {
670
+ const st = getState(server);
671
+ if (!st.pendingPermission) {
672
+ await sendLongMessage(token, chatId, '/always' + PERM_RESPONSE_HELP);
673
+ return;
674
+ }
675
+ const p = st.pendingPermission;
676
+ try {
677
+ await respondToPermission(p.server, p.sessionId, p.permissionId, 'always');
678
+ await sendLongMessage(token, chatId, '✅ Permission approved (always).');
679
+ process.stderr.write(` → /always: ${p.type} ${p.pattern || ''}\n`);
680
+ } catch (err) {
681
+ await sendLongMessage(token, chatId, `Error: ${err.message}`);
682
+ }
683
+ }
684
+
593
685
  const COMMANDS = {
594
686
  'help': cmdHelp,
595
687
  'servers': cmdServers,
@@ -597,6 +689,9 @@ const COMMANDS = {
597
689
  'session': cmdSessions,
598
690
  'agents': cmdAgents,
599
691
  'models': cmdModels,
692
+ 'allow': cmdAllow,
693
+ 'reject': cmdReject,
694
+ 'always': cmdAlways,
600
695
  };
601
696
 
602
697
  // --------------------------------------------------------------------------
@@ -605,7 +700,13 @@ const COMMANDS = {
605
700
 
606
701
  const serverState = {};
607
702
  function getState(url) {
608
- return serverState[url] = serverState[url] || { busy: false, pendingResponse: null, activeSessionId: null, activeAgentName: null, taskId: 0 };
703
+ return serverState[url] = serverState[url] || { busy: false, pendingResponse: null, activeSessionId: null, activeSessionName: null, activeAgentName: null, taskId: 0, pendingPermission: null };
704
+ }
705
+
706
+ function sessionLabel(st) {
707
+ if (st.activeSessionName) return '`' + st.activeSessionName + '`';
708
+ if (st.activeSessionId) return '`' + st.activeSessionId + '`';
709
+ return '*none (TUI+SSE)*';
609
710
  }
610
711
 
611
712
  // --------------------------------------------------------------------------
@@ -683,14 +784,44 @@ async function main() {
683
784
 
684
785
  let lockedChatId = null;
685
786
 
686
- process.stderr.write('Waiting for Telegram messages... (Ctrl+C to stop)\n');
687
-
688
787
  getState(serverBase);
689
788
 
690
- const setServer = (url) => {
789
+ process.stderr.write('Discovering server state...\n');
790
+ const [discovered, agents] = await Promise.all([
791
+ discoverActiveSession(serverBase),
792
+ listAgents(serverBase).catch(() => []),
793
+ ]);
794
+ if (discovered) {
795
+ getState(serverBase).activeSessionId = discovered.id;
796
+ getState(serverBase).activeSessionName = discovered.name;
797
+ process.stderr.write(` → session: ${discovered.name || discovered.id}\n`);
798
+ }
799
+ const defaultAgent = agents.find(a => a.mode === 'primary') || agents[0];
800
+ if (defaultAgent) {
801
+ getState(serverBase).activeAgentName = defaultAgent.name;
802
+ process.stderr.write(` → agent: ${defaultAgent.name}\n`);
803
+ }
804
+
805
+ process.stderr.write('Waiting for Telegram messages... (Ctrl+C to stop)\n');
806
+
807
+ const setServer = async (url) => {
691
808
  serverBase = url;
692
809
  getState(url);
693
810
  process.stderr.write(`Switched to server: ${url}\n`);
811
+ const [discovered, agents] = await Promise.all([
812
+ discoverActiveSession(url),
813
+ listAgents(url).catch(() => []),
814
+ ]);
815
+ if (discovered) {
816
+ getState(url).activeSessionId = discovered.id;
817
+ getState(url).activeSessionName = discovered.name;
818
+ process.stderr.write(` → session: ${discovered.name || discovered.id}\n`);
819
+ }
820
+ const defaultAgent = agents.find(a => a.mode === 'primary') || agents[0];
821
+ if (defaultAgent) {
822
+ getState(url).activeAgentName = defaultAgent.name;
823
+ process.stderr.write(` → agent: ${defaultAgent.name}\n`);
824
+ }
694
825
  const p = getState(url).pendingResponse;
695
826
  if (p) {
696
827
  getState(url).pendingResponse = null;
@@ -712,15 +843,57 @@ async function main() {
712
843
  try {
713
844
  let answer;
714
845
  if (useSession) {
715
- answer = await sendToSession(url, sessionId, `[Telegram] ${text}`, st.activeAgentName);
846
+ // Event-driven flow: start SSE listener first, then send message async
847
+ const notifiedPerms = new Set();
848
+ const ssePromise = listenForSessionEvents(url, sessionId, {
849
+ onPermission: (perm) => {
850
+ const st2 = getState(url);
851
+ const permDump = JSON.stringify(perm);
852
+ process.stderr.write(` → permission_asked callback: ${permDump}\n`);
853
+ st2.pendingPermission = { server: url, sessionId, permissionId: perm.id, type: perm.permission || 'unspecified', pattern: perm.patterns || perm.pattern, title: perm.title };
854
+ if (!notifiedPerms.has(perm.id)) {
855
+ notifiedPerms.add(perm.id);
856
+ const toolStr = perm.permission || 'unspecified';
857
+ const patternArr = perm.patterns || (perm.pattern ? [perm.pattern] : []);
858
+ const patternStr = patternArr.join(', ');
859
+ const msg = '🔐 **Permission needed**\n'
860
+ + '**Tool:** `' + toolStr + '`\n'
861
+ + (patternStr ? '**Target:** `' + patternStr + '`\n' : '')
862
+ + (perm.title ? '**Detail:** ' + perm.title + '\n' : '')
863
+ + 'Use `/allow` to approve once, `/always` to always allow, or `/reject` to deny.';
864
+ process.stderr.write(` → sending permission notification to chat ${chatId}\n`);
865
+ sendLongMessage(token, chatId, msg)
866
+ .then(() => process.stderr.write(` → permission notification sent OK\n`))
867
+ .catch((err) => process.stderr.write(` → permission notification failed: ${err.message}\n`));
868
+ } else {
869
+ process.stderr.write(` → permission ${perm.id} already notified, updating pendingPermission only\n`);
870
+ }
871
+ },
872
+ onPermissionReplied: () => {
873
+ process.stderr.write(` → permission_replied: clearing pendingPermission\n`);
874
+ getState(url).pendingPermission = null;
875
+ },
876
+ }, cancelRef);
877
+ // Send message (SSE is already listening — no race)
878
+ try {
879
+ await sendToSessionAsync(url, sessionId, `[Telegram] ${text}`, st.activeAgentName);
880
+ } catch (err) {
881
+ // Abort SSE listener on send failure to avoid resource leak
882
+ if (cancelRef.current) { cancelRef.current.destroy(); cancelRef.current = null; }
883
+ throw err;
884
+ }
885
+ // Wait for the SSE listener to collect the full answer
886
+ answer = await ssePromise;
716
887
  } else {
717
888
  await sendText(url, `[Telegram] ${text}`);
718
889
  answer = await listenForFinalAnswer(url, null, cancelRef);
719
890
  }
720
891
  if (isCancelled()) {
721
892
  process.stderr.write(` → cancelled, discarding response from ${url}\n`);
893
+ st.pendingPermission = null;
722
894
  return;
723
895
  }
896
+ st.pendingPermission = null;
724
897
  if (url === serverBase) {
725
898
  if (answer && answer.trim()) {
726
899
  await sendLongMessage(token, chatId, answer.trim());
@@ -732,6 +905,7 @@ async function main() {
732
905
  } catch (err) {
733
906
  if (isCancelled()) {
734
907
  process.stderr.write(` → cancelled, ignoring error from ${url}: ${err.message}\n`);
908
+ st.pendingPermission = null;
735
909
  return;
736
910
  }
737
911
  process.stderr.write(`Error processing message (${url}): ${err.message}\n`);
@@ -739,6 +913,7 @@ async function main() {
739
913
  try { await sendLongMessage(token, chatId, `Error: ${err.message}`); } catch (_) {}
740
914
  }
741
915
  } finally {
916
+ st.pendingPermission = null;
742
917
  if (st._cancelRef === cancelRef) st._cancelRef = null;
743
918
  if (!isCancelled()) {
744
919
  getState(url).busy = false;
@@ -771,7 +946,7 @@ async function main() {
771
946
  if (dir) lines.push(' • **Path:** `' + dir + '`');
772
947
  lines.push(' • **URL:** `' + serverBase + '`');
773
948
  lines.push(' • **Status:** ' + busyInfo);
774
- lines.push('**Session:** ' + (st.activeSessionId ? '`' + st.activeSessionId + '`' : '*none (TUI+SSE)*'));
949
+ lines.push('**Session:** ' + sessionLabel(st));
775
950
  lines.push('**Agent:** ' + (st.activeAgentName ? '`' + st.activeAgentName + '`' : '*default*'));
776
951
  await sendLongMessage(token, chatId, lines.join('\n'));
777
952
  };
@@ -794,12 +969,14 @@ async function main() {
794
969
  // Startup greeting to last-known chat
795
970
  const savedChatId = (() => { try { return fs.readFileSync(STARTUP_CHAT_FILE, 'utf8').trim(); } catch { return null; } })();
796
971
  if (savedChatId) {
972
+ const st = getState(serverBase);
797
973
  const dir = serverDir(serverBase);
798
974
  const lines = ['✅ Bot started.', '**Server:**'];
799
975
  if (dir) lines.push(' • **Path:** `' + dir + '`');
800
976
  lines.push(' • **URL:** `' + serverBase + '`');
801
977
  lines.push(' • **Status:** ✅ idle');
802
- lines.push('**Session:** *none (TUI+SSE)*');
978
+ lines.push('**Session:** ' + sessionLabel(st));
979
+ lines.push('**Agent:** ' + (st.activeAgentName ? '`' + st.activeAgentName + '`' : '*default*'));
803
980
  try { await sendLongMessage(token, savedChatId, lines.join('\n')); } catch (_) {}
804
981
  try { fs.unlinkSync(STARTUP_CHAT_FILE); } catch (_) {}
805
982
  }
package/lib/ocmux.js CHANGED
@@ -27,11 +27,12 @@ function statefileFor(dir) {
27
27
  }
28
28
 
29
29
  function tuiPaneId(windowIndex) {
30
- const r = _tmux(['list-panes', '-t', `${SESSION}:${windowIndex}`, '-F', '#{pane_id}\t#{pane_current_command}']);
30
+ const r = _tmux(['list-panes', '-t', `${SESSION}:${windowIndex}`, '-F', '#{pane_id}\t#{pane_index}']);
31
31
  if (r.status !== 0) return null;
32
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;
33
+ const [paneId, idx] = line.split('\t');
34
+ // Server is always pane 0, TUI is pane 1+
35
+ if (idx !== '0') return paneId;
35
36
  }
36
37
  return null;
37
38
  }
package/lib/opencode.js CHANGED
@@ -251,6 +251,157 @@ async function listProviders(server) {
251
251
  return JSON.parse(body);
252
252
  }
253
253
 
254
+ // Send a message asynchronously (non-blocking). Returns 204 No Content.
255
+ async function sendToSessionAsync(server, sessionId, text, agent) {
256
+ const url = `${server}/session/${encodeURIComponent(sessionId)}/prompt_async`;
257
+ const bodyObj = { parts: [{ type: 'text', text }] };
258
+ if (agent) bodyObj.agent = agent;
259
+ const body = JSON.stringify(bodyObj);
260
+ const { status } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
261
+ if (status !== 204) throw new Error(`Failed to send async message: ${status}`);
262
+ }
263
+
264
+ // Respond to a permission request.
265
+ async function respondToPermission(server, sessionId, permissionId, response, remember) {
266
+ const url = `${server}/session/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(permissionId)}`;
267
+ const bodyObj = { response };
268
+ if (remember) bodyObj.remember = true;
269
+ const body = JSON.stringify(bodyObj);
270
+ const { status } = await makeRequest(buildJsonRequest(url, 'POST', body), body);
271
+ if (status !== 200) throw new Error(`Failed to respond to permission: ${status}`);
272
+ }
273
+
274
+ // Listen for session events via SSE, collecting assistant text parts and detecting
275
+ // permission requests. Resolves with the full collected text when the session goes idle.
276
+ // callbacks: { onText(chunk), onPermission(permission) }
277
+ // cancelRef — allows aborting the SSE stream via req.destroy()
278
+ function listenForSessionEvents(server, sessionId, callbacks, cancelRef) {
279
+ process.stderr.write(` [SSE] connecting to ${server}/event for session ${sessionId}\n`);
280
+ return new Promise((resolve, reject) => {
281
+ const url = `${server}/event`;
282
+ const parsed = new URL(url);
283
+
284
+ const req = http.request({
285
+ hostname: parsed.hostname,
286
+ port: parsed.port,
287
+ path: parsed.pathname,
288
+ method: 'GET',
289
+ headers: { 'Accept': 'text/event-stream', ...getAuthHeaders() }
290
+ }, (res) => {
291
+ if (res.statusCode === 401) {
292
+ res.resume();
293
+ reject(new Error('OpenCode server authentication failed. Set OPENCODE_SERVER_PASSWORD to match the server password.'));
294
+ return;
295
+ }
296
+ process.stderr.write(` [SSE] connected for session ${sessionId}\n`);
297
+ let buffer = '';
298
+ const userMessageIDs = new Set();
299
+ let collected = '';
300
+
301
+ res.on('data', (chunk) => {
302
+ buffer += chunk.toString();
303
+ while (buffer.includes('\n\n')) {
304
+ const eventEnd = buffer.indexOf('\n\n');
305
+ const eventData = buffer.slice(0, eventEnd);
306
+ buffer = buffer.slice(eventEnd + 2);
307
+
308
+ const lines = eventData.split('\n');
309
+ let jsonStr = '';
310
+ for (const line of lines) {
311
+ if (line.startsWith('data: ')) jsonStr += line.slice(6);
312
+ }
313
+ if (!jsonStr.trim()) continue;
314
+
315
+ try {
316
+ const event = JSON.parse(jsonStr);
317
+ const props = event.properties || {};
318
+
319
+ if (event.type === 'session.status') {
320
+ process.stderr.write(` [SSE] session.status: type=${props.status?.type} status=${JSON.stringify(props.status)}\n`);
321
+ } else {
322
+ process.stderr.write(` [SSE] event type=${event.type} sessionID=${props.sessionID} waitSession=${sessionId}\n`);
323
+ }
324
+
325
+ // Filter by sessionID when the event carries one
326
+ if (props.sessionID && props.sessionID !== sessionId) continue;
327
+
328
+ if (event.type === 'message.updated' && props.info?.role === 'user') {
329
+ userMessageIDs.add(props.info.id);
330
+ continue;
331
+ }
332
+
333
+ if (event.type === 'permission.asked') {
334
+ const permStr = JSON.stringify(props);
335
+ process.stderr.write(` [SSE] PERMISSION ASKED: ${permStr}\n`);
336
+ if (callbacks.onPermission) {
337
+ process.stderr.write(` [SSE] calling onPermission callback\n`);
338
+ callbacks.onPermission(props);
339
+ } else {
340
+ process.stderr.write(` [SSE] WARNING: no onPermission callback registered\n`);
341
+ }
342
+ continue;
343
+ }
344
+
345
+ if (event.type === 'permission.replied') {
346
+ process.stderr.write(` [SSE] PERMISSION REPLIED: type=${props.type}\n`);
347
+ if (callbacks.onPermissionReplied) {
348
+ callbacks.onPermissionReplied(props);
349
+ }
350
+ continue;
351
+ }
352
+
353
+ // Completion signals
354
+ if (event.type === 'session.idle') {
355
+ cleanup();
356
+ req.destroy();
357
+ res.destroy();
358
+ resolve(collected);
359
+ return;
360
+ }
361
+ if (event.type === 'session.status' && props.status?.type === 'idle') {
362
+ cleanup();
363
+ req.destroy();
364
+ res.destroy();
365
+ resolve(collected);
366
+ return;
367
+ }
368
+
369
+ const part = props.part;
370
+ if (!part) continue;
371
+
372
+ // Skip echoed user input
373
+ if (part.messageID && userMessageIDs.has(part.messageID)) continue;
374
+
375
+ if (part.type === 'text' && part.text != null && part.text !== '') {
376
+ if (callbacks.onText) callbacks.onText(part.text);
377
+ collected += part.text;
378
+ }
379
+ } catch (e) {
380
+ // Malformed SSE event — skip
381
+ }
382
+ }
383
+ });
384
+
385
+ res.on('end', () => {
386
+ cleanup();
387
+ resolve(collected);
388
+ });
389
+ });
390
+
391
+ const cleanup = () => {
392
+ if (cancelRef) cancelRef.current = null;
393
+ };
394
+
395
+ req.on('error', (err) => {
396
+ cleanup();
397
+ reject(err);
398
+ });
399
+
400
+ if (cancelRef) cancelRef.current = req;
401
+ req.end();
402
+ });
403
+ }
404
+
254
405
  module.exports = {
255
406
  getAuthHeaders,
256
407
  makeRequest,
@@ -265,6 +416,9 @@ module.exports = {
265
416
  updateSession,
266
417
  selectSession,
267
418
  sendToSession,
419
+ sendToSessionAsync,
420
+ respondToPermission,
421
+ listenForSessionEvents,
268
422
  listAgents,
269
423
  listProviders,
270
424
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "0.5.0",
3
+ "version": "0.7.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",
@@ -0,0 +1,85 @@
1
+ # Project Status Report — June 2026
2
+
3
+ ## Environment
4
+
5
+ - **Node.js:** v22.12.0
6
+ - **opencode:** v1.15.13 (compiled ELF binary, bun-based)
7
+ - **Package:** agentp v0.6.0 (just published)
8
+ - **Branch structure (local):**
9
+ - `main` — v0.6.0, published, contains all ocmux fixes
10
+ - `pr-2` — worktree at `wt/pr-2/`, same as main + minor divergence
11
+ - `feature/tgagentp-session-init` — branch with WIP session discovery work (based on old v0.5.0, needs rebase onto main)
12
+
13
+ ## What's Fixed (v0.6.0)
14
+
15
+ - **tuiPaneId** rewritten to use pane index (server=0, TUI=1+) instead of process name — works with any runtime (node, bun, compiled binary)
16
+ - **URL polling** in `doNew()` takes the last match instead of the first — more robust with bun startup
17
+ - **agentp freeze** confirmed resolved — the 401/event stream issue was caused by stale opencode 0.14.39 session, not a code bug
18
+
19
+ ## Known Issues (unsolved)
20
+
21
+ ### 1. Session initialization — startup / server switch
22
+ - **Branch:** `feature/tgagentp-session-init` (1 commit, needs rebase)
23
+ - **Problem:** On fresh tgagentp start or `/servers switch`, `activeSessionId` is `null`. The user must manually do `/sessions switch` or `/sessions new` before session-aware messaging works.
24
+ - **WIP fix:** `discoverActiveSession()` helper — calls `listSessions`, sorts by `time.updated`, picks most recent. Called at startup (async `.then()`) and in `setServer()` (awaited). But startup greeting may fire before discovery completes, showing "Session: *none (TUI+SSE)*".
25
+ - **Next step:** Rebase branch onto main, tune timing (await discovery before greeting, or delay greeting).
26
+
27
+ ### 2. /status shows session ID instead of name
28
+ - `/status` displays raw `ses_xxxxx` ID. Would be user-friendly to show the session title/slug (available from `listSessions`).
29
+ - **Fix approach:** Either fetch session name on `/status` (adds latency), or cache discovered session names.
30
+
31
+ ### 3. /status shows "Agent: *default*" instead of actual agent name
32
+ - `activeAgentName` is never initialized on startup. The TUI has an active agent but tgagentp doesn't discover it.
33
+ - **Fix:** Could extend `discoverActiveSession()` to also fetch the session's agent, or query `GET /agent` for the default agent on startup.
34
+
35
+ ### 4. /servers list — no active server marker
36
+ - Unlike `/sessions` (which marks active session with ▶), `/servers` doesn't highlight the currently selected server.
37
+ - **Fix:** Simple — compare each server URL against `serverBase` and prepend a bullet.
38
+
39
+ ### 5. TUI navigation on session switch
40
+ - `/sessions switch` and `/sessions new` call `selectSession()` which tries `POST /session/:id/select` and `POST /tui/select-session`. Neither navigates the TUI pane. Message routing works correctly in background.
41
+ - **Status:** Known limitation — documented in AGENTS.md "With Issues." The underlying opencode API may lack the needed TUI navigation endpoints. Revisit after opencode 1.15.x API audit.
42
+
43
+ ### 6. AGENTS.md docs need updating
44
+ - `tuiPaneId` doc still says "returns pane ID if TUI process (`node`) is running" — now uses pane index, not process name.
45
+ - Session discovery feature (once merged) needs documenting.
46
+
47
+ ### 7. --print-logs request (low priority)
48
+ - User requested a `--print-logs` option for the server startup command in ocmux to aid debugging.
49
+ - The log file is at `/tmp/opencode-serve-<hashDir(dir)>.log` and is already captured via `tee`. Could add a flag to tail it.
50
+
51
+ ## Session discovery (feature branch details)
52
+
53
+ Branch `feature/tgagentp-session-init` contains 1 commit (`6393a8f`):
54
+
55
+ ```
56
+ WIP: session discovery, initialization improvements, known issues
57
+ - Added discoverActiveSession() helper
58
+ - setServer() made async
59
+ - Startup greeting uses st.activeSessionId
60
+
61
+ Known issues (at time of writing, with opencode v1.14.39):
62
+ 1. Startup greeting shows "Session: *none*" due to async race
63
+ 2. /status shows session ID not name
64
+ 3. /status shows "Agent: *default*"
65
+ 4. /servers list no active marker
66
+ 5. /servers switch doesn't switch tmux window (now partially fixed in v0.6.0)
67
+ 6. /sessions switch doesn't navigate TUI
68
+ 7. ocmux misbehaved with opencode >= 1.15.x (now fixed in v0.6.0)
69
+ ```
70
+
71
+ **Files changed:** `bin/tgagentp`, `AGENTS.md` (+34/-5 lines)
72
+
73
+ The branch needs to be rebased onto main (v0.6.0) before further work.
74
+
75
+ ## Next Steps
76
+
77
+ 1. Rebase `feature/tgagentp-session-init` onto main
78
+ 2. Continue on that branch:
79
+ a. Fix startup greeting race (await discovery before sending)
80
+ b. Add session name resolution for /status
81
+ c. Add agent discovery for /status
82
+ d. Add active server marker in /servers
83
+ e. Revisit TUI navigation after opencode API audit
84
+ 3. Merge back into main when ready
85
+ 4. Clean up: remove `pr-2` worktree and branch