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 +10 -2
- package/bin/ocmux +4 -2
- package/bin/tgagentp +188 -11
- package/lib/ocmux.js +4 -3
- package/lib/opencode.js +154 -0
- package/package.json +1 -1
- package/status_report.md +85 -0
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:**
|
|
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#{
|
|
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,
|
|
34
|
-
|
|
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
package/status_report.md
ADDED
|
@@ -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
|