agentp 2.1.1 → 2.1.3
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/README.md +45 -5
- package/bin/agentp +122 -16
- package/bin/ocmux +717 -36
- package/docs/specification_v2.md +85 -5
- package/lib/opencode.js +95 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -114,6 +114,7 @@ Arguments:
|
|
|
114
114
|
|
|
115
115
|
- `url`: OpenCode server URL or port number (defaults to `4096`). Examples: `4096`, `http://localhost:4096`, `http://192.168.1.50:4096`
|
|
116
116
|
- Omit it and agentp resolves the project itself: the nearest `.ocmux.json` (upward from the working directory) supplies the server URL, the project directory, and the target session id. `agentp $(ocmux)` still works as an override, but is no longer needed.
|
|
117
|
+
- When a registered TUI pane exists for the project, `agentp` routes it to the session the prompt went to (normal and `--defer`), so the answer streams in view; re-submitting a deferred ticket does the same for the ticket's session. Best-effort and silent when no pane is registered — broadcast prompts are left untouched (no single session to show).
|
|
117
118
|
|
|
118
119
|
## Examples
|
|
119
120
|
|
|
@@ -218,6 +219,8 @@ The ticket is `agentp_ticket` followed by a JSON object with these fields:
|
|
|
218
219
|
- `server` — OpenCode server URL used by the deferred job.
|
|
219
220
|
- `sessionId` — OpenCode session ID used by a normal deferred job.
|
|
220
221
|
- `sessionIds` — array of OpenCode session IDs used by a **broadcast** deferred job (replaces `sessionId`).
|
|
222
|
+
- `sessionName` — the session's current title, display-only: routing always keys on `sessionId`. Refreshed on every ticket print, so a session renamed while a job is running shows its up-to-date name.
|
|
223
|
+
- `sessionNames` — map of session ID → current title for **broadcast** tickets (display-only, refreshed on every print).
|
|
221
224
|
- `elapsed` — seconds since `ctime`, included only when the ticket is re-printed (not on first print).
|
|
222
225
|
- `defer` — the timeout requested at submission, included only when it was > 0.
|
|
223
226
|
- `cancelled` — always present (defaults to `false`). Set it to `true` and pipe the ticket back to cancel the running job (see below).
|
|
@@ -348,10 +351,15 @@ the project found upward from `<directory>` (default: `$PWD`):
|
|
|
348
351
|
it, and `Backspace` on an already-empty search also exits it. The same `/`
|
|
349
352
|
search works in the model/agent pickers and the project switcher.
|
|
350
353
|
- `Enter` switches (menu stays open). `Space` over a different session enters
|
|
351
|
-
**broadcast mode**: Space selects/deselects sessions; `Enter`
|
|
352
|
-
and
|
|
353
|
-
the TUI to the stored session; deselecting down to a single
|
|
354
|
-
that remaining session.
|
|
354
|
+
**broadcast mode**: Space selects/deselects sessions; `Enter` switches to the
|
|
355
|
+
cursor session and ends broadcast; `ESC`/`q` cancels back to the session list
|
|
356
|
+
and returns the TUI to the stored session; deselecting down to a single
|
|
357
|
+
session selects that remaining session. Ending broadcast (`Enter`/`ESC`/`q`,
|
|
358
|
+
or any later session switch) clears the stored selection. Reopening `ocmux`
|
|
359
|
+
while a valid selection is stored starts directly in broadcast mode; a stale
|
|
360
|
+
or invalid stored list (fewer than two sessions, or ids that no longer exist)
|
|
361
|
+
is cleared first, so the picker always shows the real state. New selections
|
|
362
|
+
open in the TUI for inspection.
|
|
355
363
|
Broadcast mode also has `h` help, `d` delete, `D` (Shift+d) to delete every
|
|
356
364
|
selected session at once (confirmation in the status bar), and `m` to change
|
|
357
365
|
the model for every selected session. The `Broadcast to sessions: …` line
|
|
@@ -368,6 +376,33 @@ the project found upward from `<directory>` (default: `$PWD`):
|
|
|
368
376
|
state is obvious at a glance. Deleting the current session adopts the session
|
|
369
377
|
under the cursor as the new current (recorded, refreshed in the TUI, and
|
|
370
378
|
highlighted)
|
|
379
|
+
- **pending-answer awareness**: while the picker is open, ocmux re-checks the
|
|
380
|
+
project every 2 seconds and marks sessions that are waiting on the agent.
|
|
381
|
+
Two independent marker columns: ❓ = pending agent **question** (a form),
|
|
382
|
+
🔒 = pending **permission** request (each always reserves its slot so rows
|
|
383
|
+
stay aligned). A 🔔 leads the **status bar** when any listed session is
|
|
384
|
+
waiting, and the cursor session's marker columns are pinned to the **top-right
|
|
385
|
+
corner of the info panel**. The same markers appear in the project switcher,
|
|
386
|
+
per session and — when folded — aggregated over the project.
|
|
387
|
+
- **live activity**: sessions with a running turn show an **animated spinner**
|
|
388
|
+
next to their row (and `BUSY` in the info panel). While the picker is open
|
|
389
|
+
ocmux re-checks activity every second, so the spinner appears when a session
|
|
390
|
+
starts a turn and disappears when it finishes — no re-opening needed.
|
|
391
|
+
- `A` answers the pending **question(s)** of the selected session (only when it
|
|
392
|
+
shows a ❓ marker). Answer mode
|
|
393
|
+
lists the form's fields and options: `Space`/`Enter` selects (`(•)`), extra
|
|
394
|
+
rows toggle on multi-select fields, and "Type your own answer" / free-text
|
|
395
|
+
rows open the status-bar input. Fields gated by `when` conditions appear and
|
|
396
|
+
disappear live as you answer their gate. The final **Submit answers** row
|
|
397
|
+
sends the whole form in one request; the server's message shows in the status
|
|
398
|
+
bar on rejection (400), an already-answered form (409) reports and returns to
|
|
399
|
+
the list, and multi-form sessions advance form to form. `h` toggles the
|
|
400
|
+
answer-mode help, `q`/`ESC` cancels without answering.
|
|
401
|
+
- `P` answers the pending **permission request(s)** of the selected session
|
|
402
|
+
(only when it shows a 🔒 marker). Each request is shown with its action,
|
|
403
|
+
resources and optional message; `o`/`1` allow **once**, `a` allow **always**
|
|
404
|
+
(remembered), `r` **reject** — each posts the decision and advances to the
|
|
405
|
+
next pending request (`h` help, `q`/`ESC` leaves them pending).
|
|
371
406
|
- `m` opens the model list **sorted by provider** with the cursor already on the
|
|
372
407
|
session's **current model** — also in broadcast mode, where it starts from the
|
|
373
408
|
cursor session's model, so a bulk change begins where you are
|
|
@@ -394,7 +429,12 @@ prepends a session's reminder to every prompt sent to it.
|
|
|
394
429
|
Broadcast selections are stored in `.ocmux.json` as `broadcast`. `agentp`
|
|
395
430
|
sends a prompt to all selected sessions (waiting for busy sessions to go idle),
|
|
396
431
|
and prints a labeled answer section for each. Errors/incomplete targets are
|
|
397
|
-
reported individually with session name, id, error, and timestamp.
|
|
432
|
+
reported individually with session name, id, error, and timestamp. Leaving
|
|
433
|
+
broadcast mode (`Enter`/`ESC`/`q`) or picking a session in the normal switcher
|
|
434
|
+
removes the stored `broadcast`, so a stale selection never keeps broadcasting.
|
|
435
|
+
Opening `ocmux` reads it back: a valid list opens the picker directly in
|
|
436
|
+
broadcast mode, while a stale or invalid list is cleared and the picker shows
|
|
437
|
+
the (real) normal state.
|
|
398
438
|
|
|
399
439
|
Subcommands:
|
|
400
440
|
|
package/bin/agentp
CHANGED
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
getActiveSessions,
|
|
22
22
|
} = require('../lib/opencode');
|
|
23
23
|
const projectState = require('../lib/project-state');
|
|
24
|
+
const ocmuxLib = require('../lib/ocmux');
|
|
24
25
|
|
|
25
26
|
const TGAGENTP_PORT_FILE = '/tmp/tgagentp-port';
|
|
26
27
|
|
|
@@ -177,9 +178,21 @@ function parseDeferredTicketInput(text) {
|
|
|
177
178
|
cancelled: data.cancelled === true,
|
|
178
179
|
followupText,
|
|
179
180
|
};
|
|
181
|
+
// Display-only name(s): carried through for fallback when a refresh fails,
|
|
182
|
+
// never used for routing (which keys on sessionId/sessionIds).
|
|
183
|
+
if (typeof data.sessionName === 'string' && data.sessionName) {
|
|
184
|
+
ticket.sessionName = data.sessionName;
|
|
185
|
+
}
|
|
180
186
|
if (Array.isArray(data.sessionIds)) {
|
|
181
187
|
ticket.sessionIds = data.sessionIds.filter(x => typeof x === 'string' && x);
|
|
182
188
|
}
|
|
189
|
+
if (data.sessionNames && typeof data.sessionNames === 'object' && !Array.isArray(data.sessionNames)) {
|
|
190
|
+
const names = {};
|
|
191
|
+
for (const [k, v] of Object.entries(data.sessionNames)) {
|
|
192
|
+
if (typeof v === 'string' && v) names[k] = v;
|
|
193
|
+
}
|
|
194
|
+
if (Object.keys(names).length > 0) ticket.sessionNames = names;
|
|
195
|
+
}
|
|
183
196
|
return ticket;
|
|
184
197
|
}
|
|
185
198
|
|
|
@@ -197,7 +210,15 @@ function formatTicket(ticket, elapsed, compact) {
|
|
|
197
210
|
const fields = { ctime: ticket.ctime, path: ticket.path };
|
|
198
211
|
if (ticket.server) fields.server = ticket.server;
|
|
199
212
|
if (ticket.sessionId) fields.sessionId = ticket.sessionId;
|
|
200
|
-
|
|
213
|
+
// Session name(s) are display-only convenience (ids are hard to read for a
|
|
214
|
+
// human); the actual routing always keys on `sessionId` / `sessionIds`.
|
|
215
|
+
if (ticket.sessionName) fields.sessionName = ticket.sessionName;
|
|
216
|
+
if (Array.isArray(ticket.sessionIds) && ticket.sessionIds.length > 0) {
|
|
217
|
+
fields.sessionIds = ticket.sessionIds;
|
|
218
|
+
if (ticket.sessionNames && Object.keys(ticket.sessionNames).length > 0) {
|
|
219
|
+
fields.sessionNames = ticket.sessionNames;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
201
222
|
if (elapsed !== undefined) fields.elapsed = elapsed;
|
|
202
223
|
if (ticket.defer > 0) fields.defer = ticket.defer;
|
|
203
224
|
// Always present so it can be flipped to true and piped back to cancel.
|
|
@@ -223,15 +244,76 @@ async function cancelTicket(ticket) {
|
|
|
223
244
|
process.exit(0);
|
|
224
245
|
}
|
|
225
246
|
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
247
|
+
// Re-fetch the display name(s) of a ticket's target session(s) right before
|
|
248
|
+
// printing, so a session renamed since the ticket was created shows its
|
|
249
|
+
// current title. Display-only: routing always keys on `sessionId`/`sessionIds`.
|
|
250
|
+
// Failures keep the last known name (carried in the parsed ticket) rather than
|
|
251
|
+
// dropping it.
|
|
252
|
+
async function refreshTicketSessionNames(ticket) {
|
|
253
|
+
if (!ticket || !ticket.server) return ticket;
|
|
254
|
+
const out = { ...ticket };
|
|
255
|
+
if (out.sessionId) {
|
|
256
|
+
try {
|
|
257
|
+
const s = await getSession(out.server, out.sessionId);
|
|
258
|
+
if (s && (s.title || s.id)) out.sessionName = s.title || s.id;
|
|
259
|
+
} catch { /* keep the last known name */ }
|
|
260
|
+
}
|
|
261
|
+
if (Array.isArray(out.sessionIds) && out.sessionIds.length > 0) {
|
|
262
|
+
const names = {};
|
|
263
|
+
if (out.sessionNames && typeof out.sessionNames === 'object') {
|
|
264
|
+
for (const [k, v] of Object.entries(out.sessionNames)) if (v) names[k] = v;
|
|
265
|
+
}
|
|
266
|
+
for (const id of out.sessionIds) {
|
|
267
|
+
try {
|
|
268
|
+
const s = await getSession(out.server, id);
|
|
269
|
+
if (s && (s.title || s.id)) names[String(id)] = s.title || s.id;
|
|
270
|
+
} catch { /* keep the last known name for this id */ }
|
|
271
|
+
}
|
|
272
|
+
const kept = {};
|
|
273
|
+
for (const id of out.sessionIds) {
|
|
274
|
+
const k = String(id);
|
|
275
|
+
if (names[k]) kept[k] = names[k];
|
|
276
|
+
}
|
|
277
|
+
if (Object.keys(kept).length > 0) out.sessionNames = kept;
|
|
278
|
+
else delete out.sessionNames;
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Print a deferred ticket (with refreshed session name and elapsed) and exit —
|
|
284
|
+
// used for the interrupt path.
|
|
285
|
+
async function printTicketAndExit(ticket, onlineTicket) {
|
|
286
|
+
const fresh = await refreshTicketSessionNames(ticket);
|
|
287
|
+
const elapsed = fresh && fresh.ctime
|
|
288
|
+
? Math.max(0, Math.floor((Date.now() - Date.parse(fresh.ctime)) / 1000))
|
|
230
289
|
: 0;
|
|
231
|
-
process.stdout.write(formatTicket(
|
|
290
|
+
process.stdout.write(formatTicket(fresh, elapsed, onlineTicket) + '\n');
|
|
232
291
|
process.exit(0);
|
|
233
292
|
}
|
|
234
293
|
|
|
294
|
+
// Route a registered TUI (if any) to the session a ticket refers to, so
|
|
295
|
+
// re-sending a ticket brings that session into view while it works. Tickets
|
|
296
|
+
// carry no project directory — it is derived from the session's own location.
|
|
297
|
+
// Best-effort: a missing directory, server hiccup or absent registered pane is
|
|
298
|
+
// silently ignored, and broadcast tickets are skipped (no single session to
|
|
299
|
+
// show).
|
|
300
|
+
async function routeTuiToTicketSession(ticket) {
|
|
301
|
+
if (!ticket || !ticket.server || !ticket.sessionId) return;
|
|
302
|
+
let directory = null;
|
|
303
|
+
try {
|
|
304
|
+
const s = await getSession(ticket.server, ticket.sessionId);
|
|
305
|
+
directory = s && s.location && s.location.directory ? s.location.directory : null;
|
|
306
|
+
} catch {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (!directory) return;
|
|
310
|
+
try {
|
|
311
|
+
ocmuxLib.switchTui(directory, ticket.server, ticket.sessionId);
|
|
312
|
+
} catch {
|
|
313
|
+
// TUI routing is best-effort; never fail the retrieval because of it.
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
235
317
|
async function resolveTargetSession(serverBase, sessionName, newSession, opts = {}) {
|
|
236
318
|
// opts: { directory, preferredSession } — v2 project/session resolution from
|
|
237
319
|
// the nearest .ocmux.json. `directory` scopes the listing to the project;
|
|
@@ -912,6 +994,9 @@ async function main() {
|
|
|
912
994
|
// ignored; the ticket's own "defer" timeout is used instead (default 0).
|
|
913
995
|
const deferredPath = ticket.path;
|
|
914
996
|
|
|
997
|
+
// Bring the ticket's session into view in any registered TUI (best-effort).
|
|
998
|
+
await routeTuiToTicketSession(ticket);
|
|
999
|
+
|
|
915
1000
|
if (ticket.cancelled) {
|
|
916
1001
|
await cancelTicket(ticket);
|
|
917
1002
|
}
|
|
@@ -970,10 +1055,12 @@ async function main() {
|
|
|
970
1055
|
}
|
|
971
1056
|
|
|
972
1057
|
// Answer not available yet — re-print the ticket with updated elapsed
|
|
973
|
-
|
|
974
|
-
|
|
1058
|
+
// and a freshly resolved session name (it may have been renamed).
|
|
1059
|
+
const freshTicket = await refreshTicketSessionNames(ticket);
|
|
1060
|
+
const elapsed = freshTicket.ctime
|
|
1061
|
+
? Math.max(0, Math.floor((Date.now() - Date.parse(freshTicket.ctime)) / 1000))
|
|
975
1062
|
: 0;
|
|
976
|
-
process.stdout.write(formatTicket(
|
|
1063
|
+
process.stdout.write(formatTicket(freshTicket, elapsed, onlineTicket) + '\n');
|
|
977
1064
|
process.exit(0);
|
|
978
1065
|
}
|
|
979
1066
|
|
|
@@ -996,6 +1083,13 @@ async function main() {
|
|
|
996
1083
|
}
|
|
997
1084
|
}
|
|
998
1085
|
|
|
1086
|
+
// Route the project's registered TUI (if any) to the target session so the
|
|
1087
|
+
// prompt streams in view while the deferred child works. Best-effort and
|
|
1088
|
+
// skipped for broadcasts (no single session to show).
|
|
1089
|
+
try {
|
|
1090
|
+
if (projectDir && targetSessionId) ocmuxLib.switchTui(projectDir, serverBase, targetSessionId);
|
|
1091
|
+
} catch {}
|
|
1092
|
+
|
|
999
1093
|
const newTicket = spawnDeferredChild({
|
|
1000
1094
|
promptText,
|
|
1001
1095
|
serverBase,
|
|
@@ -1026,8 +1120,10 @@ async function main() {
|
|
|
1026
1120
|
}
|
|
1027
1121
|
}
|
|
1028
1122
|
|
|
1029
|
-
// Output the deferred ticket
|
|
1030
|
-
|
|
1123
|
+
// Output the deferred ticket with the session name resolved for display
|
|
1124
|
+
// (ids are hard to remember; the name is fetch-and-forget, never routed on).
|
|
1125
|
+
const freshTicket = await refreshTicketSessionNames(newTicket);
|
|
1126
|
+
process.stdout.write(formatTicket(freshTicket, undefined, onlineTicket) + '\n');
|
|
1031
1127
|
process.exit(0);
|
|
1032
1128
|
}
|
|
1033
1129
|
|
|
@@ -1041,6 +1137,9 @@ async function main() {
|
|
|
1041
1137
|
// re-print the ticket only if the user interrupts with Ctrl+C.
|
|
1042
1138
|
const deferredPath = ticket.path;
|
|
1043
1139
|
|
|
1140
|
+
// Bring the ticket's session into view in any registered TUI (best-effort).
|
|
1141
|
+
await routeTuiToTicketSession(ticket);
|
|
1142
|
+
|
|
1044
1143
|
if (ticket.cancelled) {
|
|
1045
1144
|
await cancelTicket(ticket);
|
|
1046
1145
|
}
|
|
@@ -1075,7 +1174,7 @@ async function main() {
|
|
|
1075
1174
|
}
|
|
1076
1175
|
|
|
1077
1176
|
const wait = await waitForDeferredResultForever(deferredPath);
|
|
1078
|
-
if (wait.interrupted) printTicketAndExit(ticket, onlineTicket);
|
|
1177
|
+
if (wait.interrupted) await printTicketAndExit(ticket, onlineTicket);
|
|
1079
1178
|
outputReadyResult(deferredPath, wait.result, qaMode, unusedFollowup, await ticketHeader(ticket, qaMode));
|
|
1080
1179
|
}
|
|
1081
1180
|
|
|
@@ -1096,6 +1195,13 @@ async function main() {
|
|
|
1096
1195
|
}
|
|
1097
1196
|
}
|
|
1098
1197
|
|
|
1198
|
+
// Route the project's registered TUI (if any) to the target session so the
|
|
1199
|
+
// answer streams in view. Best-effort and skipped for broadcasts (no single
|
|
1200
|
+
// session to show).
|
|
1201
|
+
try {
|
|
1202
|
+
if (projectDir && targetSessionId) ocmuxLib.switchTui(projectDir, serverBase, targetSessionId);
|
|
1203
|
+
} catch {}
|
|
1204
|
+
|
|
1099
1205
|
const newTicket = spawnDeferredChild({
|
|
1100
1206
|
promptText,
|
|
1101
1207
|
serverBase,
|
|
@@ -1110,7 +1216,7 @@ async function main() {
|
|
|
1110
1216
|
});
|
|
1111
1217
|
|
|
1112
1218
|
const wait = await waitForDeferredResultForever(newTicket.path);
|
|
1113
|
-
if (wait.interrupted) printTicketAndExit(newTicket, onlineTicket);
|
|
1219
|
+
if (wait.interrupted) await printTicketAndExit(newTicket, onlineTicket);
|
|
1114
1220
|
|
|
1115
1221
|
// Answer ready — output it and clean up.
|
|
1116
1222
|
try {
|
|
@@ -1171,8 +1277,8 @@ async function main() {
|
|
|
1171
1277
|
preferredSession,
|
|
1172
1278
|
});
|
|
1173
1279
|
|
|
1174
|
-
// Prepend the session's annotation (if any) to the prompt.
|
|
1175
|
-
//
|
|
1280
|
+
// Prepend the session's annotation (if any) to the prompt. (The parent
|
|
1281
|
+
// already routed the project's TUI to this session before spawning us.)
|
|
1176
1282
|
let prompt = actualPromptText;
|
|
1177
1283
|
try {
|
|
1178
1284
|
if (ctx && ctx.statefile) {
|
|
@@ -1281,4 +1387,4 @@ if (require.main === module) {
|
|
|
1281
1387
|
});
|
|
1282
1388
|
}
|
|
1283
1389
|
|
|
1284
|
-
module.exports = { main, notifyAgentpGateway, readAllStdin, parseDeferredTicket, parseDeferredTicketInput, formatTicket, waitForDeferredResult, waitForDeferredResultForever, runBroadcastSend, buildBroadcastSection, formatBroadcastResults, isBroadcastActive };
|
|
1390
|
+
module.exports = { main, notifyAgentpGateway, readAllStdin, parseDeferredTicket, parseDeferredTicketInput, formatTicket, refreshTicketSessionNames, waitForDeferredResult, waitForDeferredResultForever, runBroadcastSend, buildBroadcastSection, formatBroadcastResults, isBroadcastActive };
|