agentp 2.1.2 → 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 +7 -0
- package/bin/agentp +122 -16
- package/bin/ocmux +53 -6
- package/docs/specification_v2.md +22 -4
- 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).
|
|
@@ -381,6 +384,10 @@ the project found upward from `<directory>` (default: `$PWD`):
|
|
|
381
384
|
waiting, and the cursor session's marker columns are pinned to the **top-right
|
|
382
385
|
corner of the info panel**. The same markers appear in the project switcher,
|
|
383
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.
|
|
384
391
|
- `A` answers the pending **question(s)** of the selected session (only when it
|
|
385
392
|
shows a ❓ marker). Answer mode
|
|
386
393
|
lists the form's fields and options: `Space`/`Enter` selects (`(•)`), extra
|
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 };
|
package/bin/ocmux
CHANGED
|
@@ -224,6 +224,8 @@ const GLYPH_PERMS = '🔒'; // pending permission request
|
|
|
224
224
|
const GLYPH_BELL = '🔔'; // status bar: at least one marker in the list
|
|
225
225
|
const CELL_NONE = ' '; // placeholder reserving one absent marker column
|
|
226
226
|
const PENDING_POLL_MS = 2000; // how often the open picker re-checks pending state
|
|
227
|
+
const RUNNING_POLL_MS = 1000; // how often the open picker re-checks which sessions are running
|
|
228
|
+
const SPIN_INTERVAL_MS = 120; // spinner frame rate while any listed session runs
|
|
227
229
|
|
|
228
230
|
// The two marker columns for a session/project: ❓ and/or 🔒, each column fixed
|
|
229
231
|
// at 2 columns wide so rows stay aligned whether or not a marker is present.
|
|
@@ -577,7 +579,11 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
577
579
|
// permission reply. When provided the picker polls it every PENDING_POLL_MS and
|
|
578
580
|
// renders the ❓/🔒 marker columns; `A` answers the cursor session's questions
|
|
579
581
|
// and `P` its pending permission requests (allow once/always, reject).
|
|
580
|
-
|
|
582
|
+
// `runningIds` is the caller's snapshot of actively-running session ids (Set);
|
|
583
|
+
// `loadRunning` (optional async () => Set) refreshes it every RUNNING_POLL_MS
|
|
584
|
+
// so every row that is running shows the spinner and the BUSY/IDLE status
|
|
585
|
+
// tracks the session's real activity.
|
|
586
|
+
function sessionMenu(sessions, currentSession, annotations, server, { onPick, onRename, onDelete, onReminder, runningIds, modelContext, onBroadcastChange, onInspectSession, dir, notices = [], loadPending, loadRunning, broadcast = null } = {}) {
|
|
581
587
|
return new Promise((resolve) => {
|
|
582
588
|
if (!process.stdin.isTTY) return resolve(null);
|
|
583
589
|
|
|
@@ -633,6 +639,13 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
633
639
|
let pendingPerms = new Set();
|
|
634
640
|
let pendingBusy = false;
|
|
635
641
|
let pendingTimer = null;
|
|
642
|
+
// Which listed sessions are actively running. The caller passes a snapshot
|
|
643
|
+
// taken at open; `loadRunning` (optional) refreshes it while the picker is
|
|
644
|
+
// open, so the spinner and BUSY/IDLE status keep up with sessions that start
|
|
645
|
+
// or finish a turn at any time.
|
|
646
|
+
let runningSet = runningIds instanceof Set ? runningIds : new Set();
|
|
647
|
+
let runningBusy = false;
|
|
648
|
+
let runningTimer = null;
|
|
636
649
|
// Answer mode ('A') state: the pending forms of the cursor session being
|
|
637
650
|
// answered one at a time, the answers chosen so far and the flat item list.
|
|
638
651
|
let answer = null;
|
|
@@ -679,6 +692,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
679
692
|
process.stderr.write('\x1b[?1049l');
|
|
680
693
|
if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
|
|
681
694
|
if (pendingTimer) { clearInterval(pendingTimer); pendingTimer = null; }
|
|
695
|
+
if (runningTimer) { clearInterval(runningTimer); runningTimer = null; }
|
|
682
696
|
process.stdout.removeListener('resize', draw);
|
|
683
697
|
try { process.stdin.setRawMode(false); } catch {}
|
|
684
698
|
process.stdin.pause();
|
|
@@ -789,7 +803,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
789
803
|
const selF = selected && pendingForms.has(String(selected.id));
|
|
790
804
|
const selP = selected && pendingPerms.has(String(selected.id));
|
|
791
805
|
const selPending = (selF || selP) ? pendingCells(selF, selP) : '';
|
|
792
|
-
infoLines = sessionInfoLines(selected,
|
|
806
|
+
infoLines = sessionInfoLines(selected, runningSet, modelContext, cols, selIsCurrent, selPending);
|
|
793
807
|
}
|
|
794
808
|
// 'd'/n/r/R prompts take over the status bar (previously an easy-to-miss
|
|
795
809
|
// line at the bottom of the list) with their essential keys on the right.
|
|
@@ -839,7 +853,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
839
853
|
const timeStr = showTime ? padLeft(fmtClock(s.time && s.time.viewed), TIME_W) + ' ' : '';
|
|
840
854
|
const mark = String(s.id) === String(current) ? ' *' : '';
|
|
841
855
|
const hasNote = annotations && annotations[s.id] ? ' ◈' : '';
|
|
842
|
-
const spin = (
|
|
856
|
+
const spin = (runningSet.has(s.id)) ? SPINNER[spinIdx % SPINNER.length] + ' ' : ' ';
|
|
843
857
|
const text = `${sel}${spin}${cellsOf(s.id)}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
|
|
844
858
|
// Brown for the session currently selected in the TUI window.
|
|
845
859
|
return String(s.id) === String(current) ? `${BROWN}${text}\x1b[0m` : text;
|
|
@@ -927,6 +941,33 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
927
941
|
}).catch(() => { pendingBusy = false; });
|
|
928
942
|
};
|
|
929
943
|
|
|
944
|
+
// ── Live activity polling (spinner + BUSY/IDLE status) ──────────
|
|
945
|
+
// Re-read `loadRunning` every RUNNING_POLL_MS while the picker is open so a
|
|
946
|
+
// session that starts or finishes a turn after the menu opened shows it
|
|
947
|
+
// without re-opening. Errors degrade gracefully: the previous set stays.
|
|
948
|
+
const refreshRunning = () => {
|
|
949
|
+
if (!loadRunning || runningBusy) return Promise.resolve();
|
|
950
|
+
runningBusy = true;
|
|
951
|
+
return Promise.resolve(loadRunning()).then((ids) => {
|
|
952
|
+
runningSet = ids instanceof Set ? ids : new Set(ids || []);
|
|
953
|
+
runningBusy = false;
|
|
954
|
+
syncSpin();
|
|
955
|
+
}).catch(() => { runningBusy = false; });
|
|
956
|
+
};
|
|
957
|
+
// Keep the spinner animation in sync with the running set: animate while at
|
|
958
|
+
// least one listed session is active, stop when none is, and paint a fresh
|
|
959
|
+
// frame so a poll update is visible immediately.
|
|
960
|
+
const syncSpin = (redraw = true) => {
|
|
961
|
+
const anyRunning = rows.some((s) => runningSet.has(s.id));
|
|
962
|
+
if (anyRunning && !spinTimer) {
|
|
963
|
+
spinTimer = setInterval(() => { spinIdx++; draw(); }, SPIN_INTERVAL_MS);
|
|
964
|
+
} else if (!anyRunning && spinTimer) {
|
|
965
|
+
clearInterval(spinTimer);
|
|
966
|
+
spinTimer = null;
|
|
967
|
+
}
|
|
968
|
+
if (redraw && !done) draw();
|
|
969
|
+
};
|
|
970
|
+
|
|
930
971
|
// ── Answer mode (A): answer the agent's pending form(s) ─────────
|
|
931
972
|
// Field applicability honours `hidden` and `when` conditions over the
|
|
932
973
|
// answers chosen SO FAR (live re-validation: groups appear/disappear as you
|
|
@@ -1765,9 +1806,12 @@ const toggleBroadcast = (idx) => {
|
|
|
1765
1806
|
process.stdin.setRawMode(true);
|
|
1766
1807
|
process.stdin.resume();
|
|
1767
1808
|
process.stdout.on('resize', draw);
|
|
1768
|
-
// Animate the spinner while any listed session is running.
|
|
1769
|
-
|
|
1770
|
-
|
|
1809
|
+
// Animate the spinner while any listed session is running. `loadRunning`
|
|
1810
|
+
// (when provided) polls for activity so the spinner follows sessions that
|
|
1811
|
+
// start or finish a turn after the picker opened.
|
|
1812
|
+
syncSpin(false);
|
|
1813
|
+
if (loadRunning) {
|
|
1814
|
+
runningTimer = setInterval(() => { refreshRunning(); }, RUNNING_POLL_MS);
|
|
1771
1815
|
}
|
|
1772
1816
|
// Poll for pending agent questions / permission replies while the picker is
|
|
1773
1817
|
// open (the ❓/🔒 marker columns and the 'A' entry refresh live).
|
|
@@ -2179,6 +2223,9 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
|
2179
2223
|
forms: new Set((forms || []).map(f => f.sessionID).filter(Boolean)),
|
|
2180
2224
|
perms: new Set((perms || []).map(x => x.sessionID).filter(Boolean)),
|
|
2181
2225
|
})),
|
|
2226
|
+
// Which sessions are actively running, re-polled while the picker is
|
|
2227
|
+
// open so the spinner and BUSY/IDLE status stay accurate.
|
|
2228
|
+
loadRunning: () => getActiveSessions(server),
|
|
2182
2229
|
onBroadcastChange: (ids) => projectState.writeProjectState(sf, { broadcast: ids }),
|
|
2183
2230
|
onInspectSession: (id) => refreshTui(dir, server, id),
|
|
2184
2231
|
onPick: async (row) => {
|
package/docs/specification_v2.md
CHANGED
|
@@ -83,7 +83,8 @@ There is no `--global` alias.
|
|
|
83
83
|
|
|
84
84
|
## 4. TUI routing
|
|
85
85
|
|
|
86
|
-
When `ocmux` selects or inspects a session
|
|
86
|
+
When `ocmux` selects or inspects a session — or `agentp` sends a prompt or
|
|
87
|
+
re-submits a deferred ticket (section 8) — the display is resolved in order:
|
|
87
88
|
|
|
88
89
|
1. live TUI dedicated to the target directory;
|
|
89
90
|
2. the one live shared TUI;
|
|
@@ -197,6 +198,15 @@ The broadcast info line (`Broadcast to sessions: …`) never wraps: it holds up
|
|
|
197
198
|
to 480 characters of names (or the terminal width, whichever is smaller), and
|
|
198
199
|
an oversized selection is truncated from the beginning with a leading `...`.
|
|
199
200
|
|
|
201
|
+
**Live activity.** Every listed session that is currently running (a foreground
|
|
202
|
+
drain, per `GET /api/session/active`) shows an animated spinner in its row, and
|
|
203
|
+
the info panel's status says `BUSY`. The picker's initial `runningIds` is a
|
|
204
|
+
snapshot taken at open; while the picker stays open it re-polls that endpoint
|
|
205
|
+
every second and updates the set, so sessions that start a turn after the menu
|
|
206
|
+
opened begin spinning and sessions that finish stop — the animation timer starts
|
|
207
|
+
and stops to match. Failures of the poll degrade silently: the previous set
|
|
208
|
+
stays and the next poll retries.
|
|
209
|
+
|
|
200
210
|
**Pending-answer awareness.** While the picker is open it re-polls the project
|
|
201
211
|
every 2 seconds (`GET /api/form` and `GET /api/permission/request`, both
|
|
202
212
|
location-scoped with the `location[directory]=…` deepObject parameter) and
|
|
@@ -276,9 +286,17 @@ The old managed-window `kill` and `resurrect` commands do not exist.
|
|
|
276
286
|
## 8. `agentp`
|
|
277
287
|
|
|
278
288
|
`agentp` resolves project/session/server from explicit options and the nearest
|
|
279
|
-
state file, then sends directly to the session API.
|
|
280
|
-
|
|
281
|
-
|
|
289
|
+
state file, then sends directly to the session API. Sending a prompt (normal or
|
|
290
|
+
`--defer`) also routes the project's TUI to the target session, so the answer
|
|
291
|
+
streams in view: the pane is respawned only when it does not already show that
|
|
292
|
+
project/session. Re-submitting a deferred ticket does the same for the ticket's
|
|
293
|
+
session, whose project directory is derived from the session's own location.
|
|
294
|
+
|
|
295
|
+
Routing is best-effort and never fails the prompt. It resolves like section 4
|
|
296
|
+
(dedicated → shared → headless, silent when no pane is registered), is skipped
|
|
297
|
+
for broadcast prompts (no single session to show) and for the detached
|
|
298
|
+
`--defer-child` worker, and routing always keys on `sessionId`/`sessionIds` —
|
|
299
|
+
the human-readable session name on a ticket is display-only.
|
|
282
300
|
|
|
283
301
|
## 9. Failure behavior
|
|
284
302
|
|