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 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` keeps the list
352
- and switches normally; `ESC`/`q` cancels back to the session list and returns
353
- the TUI to the stored session; deselecting down to a single session selects
354
- that remaining session. New selections open in the TUI for inspection.
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
- if (Array.isArray(ticket.sessionIds) && ticket.sessionIds.length > 0) fields.sessionIds = ticket.sessionIds;
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
- // Print a deferred ticket (with elapsed) and exit — used for the interrupt path.
227
- function printTicketAndExit(ticket, onlineTicket) {
228
- const elapsed = ticket && ticket.ctime
229
- ? Math.max(0, Math.floor((Date.now() - Date.parse(ticket.ctime)) / 1000))
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(ticket, elapsed, onlineTicket) + '\n');
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
- const elapsed = ticket.ctime
974
- ? Math.max(0, Math.floor((Date.now() - Date.parse(ticket.ctime)) / 1000))
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(ticket, elapsed, onlineTicket) + '\n');
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
- process.stdout.write(formatTicket(newTicket, undefined, onlineTicket) + '\n');
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. TUI routing is
1175
- // owned by explicit ocmux session switches, not by prompt submission.
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 };