agentp 2.1.1 → 2.1.2
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 +38 -5
- package/bin/ocmux +666 -32
- package/docs/specification_v2.md +63 -1
- package/lib/opencode.js +95 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -348,10 +348,15 @@ the project found upward from `<directory>` (default: `$PWD`):
|
|
|
348
348
|
it, and `Backspace` on an already-empty search also exits it. The same `/`
|
|
349
349
|
search works in the model/agent pickers and the project switcher.
|
|
350
350
|
- `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.
|
|
351
|
+
**broadcast mode**: Space selects/deselects sessions; `Enter` switches to the
|
|
352
|
+
cursor session and ends broadcast; `ESC`/`q` cancels back to the session list
|
|
353
|
+
and returns the TUI to the stored session; deselecting down to a single
|
|
354
|
+
session selects that remaining session. Ending broadcast (`Enter`/`ESC`/`q`,
|
|
355
|
+
or any later session switch) clears the stored selection. Reopening `ocmux`
|
|
356
|
+
while a valid selection is stored starts directly in broadcast mode; a stale
|
|
357
|
+
or invalid stored list (fewer than two sessions, or ids that no longer exist)
|
|
358
|
+
is cleared first, so the picker always shows the real state. New selections
|
|
359
|
+
open in the TUI for inspection.
|
|
355
360
|
Broadcast mode also has `h` help, `d` delete, `D` (Shift+d) to delete every
|
|
356
361
|
selected session at once (confirmation in the status bar), and `m` to change
|
|
357
362
|
the model for every selected session. The `Broadcast to sessions: …` line
|
|
@@ -368,6 +373,29 @@ the project found upward from `<directory>` (default: `$PWD`):
|
|
|
368
373
|
state is obvious at a glance. Deleting the current session adopts the session
|
|
369
374
|
under the cursor as the new current (recorded, refreshed in the TUI, and
|
|
370
375
|
highlighted)
|
|
376
|
+
- **pending-answer awareness**: while the picker is open, ocmux re-checks the
|
|
377
|
+
project every 2 seconds and marks sessions that are waiting on the agent.
|
|
378
|
+
Two independent marker columns: ❓ = pending agent **question** (a form),
|
|
379
|
+
🔒 = pending **permission** request (each always reserves its slot so rows
|
|
380
|
+
stay aligned). A 🔔 leads the **status bar** when any listed session is
|
|
381
|
+
waiting, and the cursor session's marker columns are pinned to the **top-right
|
|
382
|
+
corner of the info panel**. The same markers appear in the project switcher,
|
|
383
|
+
per session and — when folded — aggregated over the project.
|
|
384
|
+
- `A` answers the pending **question(s)** of the selected session (only when it
|
|
385
|
+
shows a ❓ marker). Answer mode
|
|
386
|
+
lists the form's fields and options: `Space`/`Enter` selects (`(•)`), extra
|
|
387
|
+
rows toggle on multi-select fields, and "Type your own answer" / free-text
|
|
388
|
+
rows open the status-bar input. Fields gated by `when` conditions appear and
|
|
389
|
+
disappear live as you answer their gate. The final **Submit answers** row
|
|
390
|
+
sends the whole form in one request; the server's message shows in the status
|
|
391
|
+
bar on rejection (400), an already-answered form (409) reports and returns to
|
|
392
|
+
the list, and multi-form sessions advance form to form. `h` toggles the
|
|
393
|
+
answer-mode help, `q`/`ESC` cancels without answering.
|
|
394
|
+
- `P` answers the pending **permission request(s)** of the selected session
|
|
395
|
+
(only when it shows a 🔒 marker). Each request is shown with its action,
|
|
396
|
+
resources and optional message; `o`/`1` allow **once**, `a` allow **always**
|
|
397
|
+
(remembered), `r` **reject** — each posts the decision and advances to the
|
|
398
|
+
next pending request (`h` help, `q`/`ESC` leaves them pending).
|
|
371
399
|
- `m` opens the model list **sorted by provider** with the cursor already on the
|
|
372
400
|
session's **current model** — also in broadcast mode, where it starts from the
|
|
373
401
|
cursor session's model, so a bulk change begins where you are
|
|
@@ -394,7 +422,12 @@ prepends a session's reminder to every prompt sent to it.
|
|
|
394
422
|
Broadcast selections are stored in `.ocmux.json` as `broadcast`. `agentp`
|
|
395
423
|
sends a prompt to all selected sessions (waiting for busy sessions to go idle),
|
|
396
424
|
and prints a labeled answer section for each. Errors/incomplete targets are
|
|
397
|
-
reported individually with session name, id, error, and timestamp.
|
|
425
|
+
reported individually with session name, id, error, and timestamp. Leaving
|
|
426
|
+
broadcast mode (`Enter`/`ESC`/`q`) or picking a session in the normal switcher
|
|
427
|
+
removes the stored `broadcast`, so a stale selection never keeps broadcasting.
|
|
428
|
+
Opening `ocmux` reads it back: a valid list opens the picker directly in
|
|
429
|
+
broadcast mode, while a stale or invalid list is cleared and the picker shows
|
|
430
|
+
the (real) normal state.
|
|
398
431
|
|
|
399
432
|
Subcommands:
|
|
400
433
|
|
package/bin/ocmux
CHANGED
|
@@ -9,7 +9,7 @@ const child_process = require('child_process');
|
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const { tuiArgs, switchTui } = require('../lib/ocmux');
|
|
11
11
|
const tuiRegistry = require('../lib/tui-registry');
|
|
12
|
-
const { listSessions, listProjects: listOpenCodeProjects, createSession, createSessionWithModel, updateSession, listModels, switchModel, resolveModelRef, getAuthHeaders, sortSessionsByRecency, deleteSession, listAgents, getActiveSessions, getSession } = require('../lib/opencode');
|
|
12
|
+
const { listSessions, listProjects: listOpenCodeProjects, createSession, createSessionWithModel, updateSession, listModels, switchModel, resolveModelRef, getAuthHeaders, sortSessionsByRecency, deleteSession, listAgents, getActiveSessions, getSession, listPendingForms, listSessionForms, replyToForm, listPendingPermissions, replyToPermission } = require('../lib/opencode');
|
|
13
13
|
const projectState = require('../lib/project-state');
|
|
14
14
|
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
@@ -214,6 +214,23 @@ const BAR_BG = '\x1b[30;43m'; // title/status bar: black on brown
|
|
|
214
214
|
const LIGHT_YELLOW = '\x1b[1;33m'; // text: light-yellow (bold brown)
|
|
215
215
|
const QUESTION_BG = '\x1b[30;103m'; // question/input bar: black on light-yellow
|
|
216
216
|
|
|
217
|
+
// Pending-state markers shown in the session list rows, the info panel and the
|
|
218
|
+
// project switcher. Two INDEPENDENT columns — ❓ pending agent question(s) and
|
|
219
|
+
// 🔒 pending permission request(s) — each always reserves a 2-column slot
|
|
220
|
+
// (glyph or 2-space placeholder) so every row stays aligned with or without a
|
|
221
|
+
// marker. The status bar uses a single 🔔 when any listed session waits.
|
|
222
|
+
const GLYPH_FORMS = '❓'; // pending agent question (pending form)
|
|
223
|
+
const GLYPH_PERMS = '🔒'; // pending permission request
|
|
224
|
+
const GLYPH_BELL = '🔔'; // status bar: at least one marker in the list
|
|
225
|
+
const CELL_NONE = ' '; // placeholder reserving one absent marker column
|
|
226
|
+
const PENDING_POLL_MS = 2000; // how often the open picker re-checks pending state
|
|
227
|
+
|
|
228
|
+
// The two marker columns for a session/project: ❓ and/or 🔒, each column fixed
|
|
229
|
+
// at 2 columns wide so rows stay aligned whether or not a marker is present.
|
|
230
|
+
function pendingCells(hasForms, hasPerms) {
|
|
231
|
+
return (hasForms ? GLYPH_FORMS : CELL_NONE) + (hasPerms ? GLYPH_PERMS : CELL_NONE);
|
|
232
|
+
}
|
|
233
|
+
|
|
217
234
|
// ── '/' incremental search for interactive lists ──────────────────────────
|
|
218
235
|
// Filter `items` by `pattern`: case-insensitive, space-separated tokens that
|
|
219
236
|
// must ALL be substrings of `textOf(item)`. Empty pattern → list unchanged.
|
|
@@ -421,14 +438,23 @@ function labeledLine(label, value, cols, labelColor = '\x1b[1m') {
|
|
|
421
438
|
// grid. Fields are ALWAYS present (placeholders when unknown).
|
|
422
439
|
// Labels are brown (the same shade as the title/status bars); when `highlight`
|
|
423
440
|
// is true (the cursor is on the session currently selected in the TUI) they are
|
|
424
|
-
// light-yellow instead.
|
|
425
|
-
|
|
441
|
+
// light-yellow instead. `pending` (a 2-column-wide marker glyph) is pinned to
|
|
442
|
+
// the top-right corner of the panel so the pending state of the CURSOR session
|
|
443
|
+
// is visible at a glance.
|
|
444
|
+
function sessionInfoLines(s, runningIds, modelContext, cols, highlight = false, pending = '') {
|
|
426
445
|
if (!s) return [];
|
|
427
446
|
const labelColor = highlight ? LIGHT_YELLOW : BROWN;
|
|
428
447
|
const title = (s.title && s.title.trim()) || 'Untitled';
|
|
429
448
|
const location = (s.location && s.location.directory) || 'Unknown';
|
|
449
|
+
// When pending markers are shown they are pinned to the top-right corner:
|
|
450
|
+
// pad the Title line up to `cols - pending.length` (pending is the 4-column
|
|
451
|
+
// two-marker cell, or '' when nothing is pending) and append them.
|
|
452
|
+
const titleLine = `${labelColor}Title:\x1b[0m ${title}`;
|
|
453
|
+
const firstLine = pending
|
|
454
|
+
? titleLine + ' '.repeat(Math.max(0, cols - pending.length - titleLine.length)) + pending
|
|
455
|
+
: titleLine;
|
|
430
456
|
const out = [
|
|
431
|
-
|
|
457
|
+
firstLine,
|
|
432
458
|
labeledLine('Location', location, cols, labelColor),
|
|
433
459
|
];
|
|
434
460
|
|
|
@@ -539,20 +565,26 @@ function renderList({ title, items, cursor, row, footer, infoLines = [], extraLi
|
|
|
539
565
|
|
|
540
566
|
// Render an interactive session picker (alt screen). Sorted most-recently-viewed
|
|
541
567
|
// first by the caller. Keys: Enter/Space switch (menu stays open), n create,
|
|
542
|
-
// r rename, d delete (confirm), a annotate, p switch project,
|
|
568
|
+
// r rename, d delete (confirm), a annotate, p switch project, A answer the
|
|
569
|
+
// cursor session's pending questions, P answer its permission requests, h help,
|
|
543
570
|
// q/Ctrl+C quit. Input mode offers readline-style caret editing.
|
|
544
571
|
// Resolves on quit (null) or with the string 'switch-project' (caller then runs
|
|
545
572
|
// the project switcher and re-invokes this picker for the new project).
|
|
546
573
|
// `dir` names the project this list belongs to — it heads the title bar so a
|
|
547
574
|
// multi-project run always says which project's sessions are on screen.
|
|
548
|
-
|
|
575
|
+
// `loadPending` (optional async () => ({ forms: Set, perms: Set })) supplies the
|
|
576
|
+
// session ids that currently await an agent question (pending form) or a
|
|
577
|
+
// permission reply. When provided the picker polls it every PENDING_POLL_MS and
|
|
578
|
+
// renders the ❓/🔒 marker columns; `A` answers the cursor session's questions
|
|
579
|
+
// and `P` its pending permission requests (allow once/always, reject).
|
|
580
|
+
function sessionMenu(sessions, currentSession, annotations, server, { onPick, onRename, onDelete, onReminder, runningIds, modelContext, onBroadcastChange, onInspectSession, dir, notices = [], loadPending, broadcast = null } = {}) {
|
|
549
581
|
return new Promise((resolve) => {
|
|
550
582
|
if (!process.stdin.isTTY) return resolve(null);
|
|
551
583
|
|
|
552
584
|
let rows = [...sessions];
|
|
553
585
|
let current = currentSession;
|
|
554
586
|
let cursor = 0;
|
|
555
|
-
let mode = 'normal'; // 'normal' | 'help' | 'confirm' | 'input'
|
|
587
|
+
let mode = 'normal'; // 'normal' | 'help' | 'confirm' | 'input' | 'perm' | 'perm-help'
|
|
556
588
|
let confirmDel = null;
|
|
557
589
|
let confirmAll = false; // 'D' deletes every broadcast-selected session
|
|
558
590
|
let input = null; // { label, value, kind, caret }
|
|
@@ -568,17 +600,65 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
568
600
|
let view = rows; // rows filtered by `search` (what is displayed)
|
|
569
601
|
let inspected = false; // broadcast inspection moved the TUI off `current`
|
|
570
602
|
|
|
603
|
+
// Adopt a persisted broadcast selection, but only when it is still valid:
|
|
604
|
+
// at least two of its ids must name existing sessions in this project's
|
|
605
|
+
// list (main sessions only — the caller already excluded child sessions).
|
|
606
|
+
// A stale list (too few entries, or ids that no longer exist) is cleared and
|
|
607
|
+
// the correction written back, so the picker never shows a normal list while
|
|
608
|
+
// agentp would still broadcast to the old one.
|
|
609
|
+
const persistedBroadcast = (Array.isArray(broadcast) ? broadcast : [])
|
|
610
|
+
.map(String)
|
|
611
|
+
.filter((id, i, arr) => arr.indexOf(id) === i)
|
|
612
|
+
.filter((id) => rows.some((s) => String(s.id) === id));
|
|
613
|
+
if (persistedBroadcast.length >= 2) {
|
|
614
|
+
broadcastSel = new Set(persistedBroadcast);
|
|
615
|
+
mode = 'broadcast';
|
|
616
|
+
// Anchor the picker (and the TUI) on a session that is actually selected.
|
|
617
|
+
if (!broadcastSel.has(String(current))) {
|
|
618
|
+
current = persistedBroadcast[0];
|
|
619
|
+
if (onInspectSession) onInspectSession(current);
|
|
620
|
+
}
|
|
621
|
+
// Write back when validation dropped dead or duplicate ids.
|
|
622
|
+
if (onBroadcastChange && persistedBroadcast.length !== (Array.isArray(broadcast) ? broadcast.length : 0)) {
|
|
623
|
+
onBroadcastChange(persistedBroadcast);
|
|
624
|
+
}
|
|
625
|
+
} else if (onBroadcastChange && Array.isArray(broadcast) && broadcast.length > 0) {
|
|
626
|
+
// Stale or invalid persisted list: clear it so agentp stops broadcasting.
|
|
627
|
+
onBroadcastChange([]);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Pending agent questions / permission replies, polled via `loadPending`.
|
|
631
|
+
// Each is a Set of session ids that currently await that thing.
|
|
632
|
+
let pendingForms = new Set();
|
|
633
|
+
let pendingPerms = new Set();
|
|
634
|
+
let pendingBusy = false;
|
|
635
|
+
let pendingTimer = null;
|
|
636
|
+
// Answer mode ('A') state: the pending forms of the cursor session being
|
|
637
|
+
// answered one at a time, the answers chosen so far and the flat item list.
|
|
638
|
+
let answer = null;
|
|
639
|
+
// Permission mode ('P') state: the pending permission request(s) of the
|
|
640
|
+
// cursor session being answered one at a time.
|
|
641
|
+
let perm = null;
|
|
642
|
+
// The two marker columns (❓ and/or 🔒), always 4 columns wide — each in a
|
|
643
|
+
// fixed 2-column slot — so rows stay aligned with or without a marker.
|
|
644
|
+
const cellsOf = (id) => pendingCells(pendingForms.has(String(id)), pendingPerms.has(String(id)));
|
|
645
|
+
|
|
571
646
|
const pad = (s, w) => s + ' '.repeat(Math.max(0, w - s.length));
|
|
572
647
|
const helpLines = [
|
|
573
648
|
'Enter switch to the selected session (stays open)',
|
|
574
649
|
'Space start BROADCAST: add the selected session to the current one',
|
|
575
|
-
' (Space toggles each session; Enter
|
|
650
|
+
' (Space toggles each session; Enter ends broadcast and',
|
|
651
|
+
' switches; ESC/q also end it and clear the selection)',
|
|
576
652
|
'n create a new session (type a name, Enter; blank = auto)',
|
|
577
653
|
'r rename the selected session (edit in place; ESC cancels)',
|
|
578
654
|
'R (Shift+r) set a REMINDER for the selected session (sent in front of',
|
|
579
655
|
' every agentp prompt; blank clears)',
|
|
580
656
|
'd delete the selected session (then y/N to confirm)',
|
|
581
657
|
'D (Shift+d) in BROADCAST: delete ALL selected sessions (then y/N)',
|
|
658
|
+
'A answer pending agent question(s) of the selected session',
|
|
659
|
+
' (only when it shows a ❓ marker)',
|
|
660
|
+
'P answer pending permission request(s) of the selected session',
|
|
661
|
+
' (only when it shows a 🔒 marker)',
|
|
582
662
|
'a switch the AGENT of the selected session',
|
|
583
663
|
'm switch the MODEL of the selected session',
|
|
584
664
|
'p open the project switcher',
|
|
@@ -598,6 +678,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
598
678
|
process.stderr.write('\x1b[?25h');
|
|
599
679
|
process.stderr.write('\x1b[?1049l');
|
|
600
680
|
if (spinTimer) { clearInterval(spinTimer); spinTimer = null; }
|
|
681
|
+
if (pendingTimer) { clearInterval(pendingTimer); pendingTimer = null; }
|
|
601
682
|
process.stdout.removeListener('resize', draw);
|
|
602
683
|
try { process.stdin.setRawMode(false); } catch {}
|
|
603
684
|
process.stdin.pause();
|
|
@@ -619,12 +700,20 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
619
700
|
};
|
|
620
701
|
|
|
621
702
|
const draw = () => {
|
|
703
|
+
if (answer && (mode === 'answer' || mode === 'answer-help' || (mode === 'input' && input && input.kind === 'answer'))) {
|
|
704
|
+
drawAnswer();
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
if (perm && (mode === 'perm' || mode === 'perm-help')) {
|
|
708
|
+
drawPerm();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
622
711
|
if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
|
|
623
712
|
let out = '\x1b[2J\x1b[H';
|
|
624
713
|
if (mode === 'broadcast-help') {
|
|
625
714
|
out += 'ocmux broadcast mode — help\n\n';
|
|
626
715
|
out += ' Space select/deselect the cursor session\n';
|
|
627
|
-
out += ' Enter
|
|
716
|
+
out += ' Enter end broadcast (clear the selection); switch TUI to cursor\n';
|
|
628
717
|
out += ' m switch the model for ALL selected sessions\n';
|
|
629
718
|
out += ' d delete the cursor session (with confirmation)\n';
|
|
630
719
|
out += ' D (Shift+d) delete ALL selected sessions (with confirmation)\n';
|
|
@@ -692,10 +781,15 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
692
781
|
names = '... ' + keep;
|
|
693
782
|
}
|
|
694
783
|
infoLines = [bcHead + names];
|
|
695
|
-
footer = 'Enter: switch · Space: toggle · d: delete · D: delete all · m: model · /: search · h: help · q: cancel · Ctrl+C: quit';
|
|
784
|
+
footer = 'Enter: switch (ends) · Space: toggle · d: delete · D: delete all · m: model · /: search · h: help · q: cancel · Ctrl+C: quit';
|
|
696
785
|
} else {
|
|
697
786
|
const selIsCurrent = !!(selected && String(selected.id) === String(current));
|
|
698
|
-
|
|
787
|
+
// The cursor session's marker columns (❓/🔒) are pinned to the info
|
|
788
|
+
// panel's top-right corner ('' when nothing is pending).
|
|
789
|
+
const selF = selected && pendingForms.has(String(selected.id));
|
|
790
|
+
const selP = selected && pendingPerms.has(String(selected.id));
|
|
791
|
+
const selPending = (selF || selP) ? pendingCells(selF, selP) : '';
|
|
792
|
+
infoLines = sessionInfoLines(selected, runningIds, modelContext, cols, selIsCurrent, selPending);
|
|
699
793
|
}
|
|
700
794
|
// 'd'/n/r/R prompts take over the status bar (previously an easy-to-miss
|
|
701
795
|
// line at the bottom of the list) with their essential keys on the right.
|
|
@@ -720,6 +814,19 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
720
814
|
} else {
|
|
721
815
|
footer = searchFooter(search, footer);
|
|
722
816
|
}
|
|
817
|
+
// Pending-response awareness: when any session in this list is waiting
|
|
818
|
+
// for an agent question and/or a permission reply, lead the status bar
|
|
819
|
+
// with the bell (🔔) and flip the bar left-aligned so it sits at the very
|
|
820
|
+
// beginning.
|
|
821
|
+
if (!questionBar) {
|
|
822
|
+
const anyF = rows.some(r => pendingForms.has(String(r.id)));
|
|
823
|
+
const anyP = rows.some(r => pendingPerms.has(String(r.id)));
|
|
824
|
+
const bell = (anyF || anyP) ? GLYPH_BELL : '';
|
|
825
|
+
if (bell && footer.length < cols - 4) {
|
|
826
|
+
footer = `${bell} ${footer}`;
|
|
827
|
+
footerLeft = true;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
723
830
|
process.stderr.write(renderList({
|
|
724
831
|
// The project name heads the title so you always know which project's
|
|
725
832
|
// sessions are listed (matters when --all-projects moves you around).
|
|
@@ -733,7 +840,7 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
733
840
|
const mark = String(s.id) === String(current) ? ' *' : '';
|
|
734
841
|
const hasNote = annotations && annotations[s.id] ? ' ◈' : '';
|
|
735
842
|
const spin = (runningIds && runningIds.has(s.id)) ? SPINNER[spinIdx % SPINNER.length] + ' ' : ' ';
|
|
736
|
-
const text = `${sel}${spin}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
|
|
843
|
+
const text = `${sel}${spin}${cellsOf(s.id)}${timeStr}${pad(title, maxLen)}${mark}${hasNote}`;
|
|
737
844
|
// Brown for the session currently selected in the TUI window.
|
|
738
845
|
return String(s.id) === String(current) ? `${BROWN}${text}\x1b[0m` : text;
|
|
739
846
|
},
|
|
@@ -746,8 +853,8 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
746
853
|
}));
|
|
747
854
|
};
|
|
748
855
|
|
|
749
|
-
const startInput = (prompt, hint, value, kind) => {
|
|
750
|
-
input = { prompt, hint, value, kind, caret: value.length };
|
|
856
|
+
const startInput = (prompt, hint, value, kind, targetKey) => {
|
|
857
|
+
input = { prompt, hint, value, kind, targetKey, caret: value.length };
|
|
751
858
|
mode = 'input';
|
|
752
859
|
draw();
|
|
753
860
|
};
|
|
@@ -778,6 +885,11 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
778
885
|
};
|
|
779
886
|
|
|
780
887
|
const applyPick = (row) => {
|
|
888
|
+
// Selecting a session always drops any persisted broadcast list: a stale
|
|
889
|
+
// 'broadcast' in .ocmux.json would otherwise keep agentp sending prompts
|
|
890
|
+
// to sessions that are no longer selected.
|
|
891
|
+
persistBroadcast([]);
|
|
892
|
+
broadcastSel = new Set();
|
|
781
893
|
Promise.resolve(onPick ? onPick(row) : null).then((created) => {
|
|
782
894
|
if (row.new && created) {
|
|
783
895
|
rows.unshift(created);
|
|
@@ -800,6 +912,404 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
800
912
|
});
|
|
801
913
|
};
|
|
802
914
|
|
|
915
|
+
// ── Pending-response polling (❓/🔒 marker columns) ─────────────
|
|
916
|
+
// Re-read `loadPending` every PENDING_POLL_MS while the picker is open so
|
|
917
|
+
// the markers appear/disappear without re-opening the menu. Errors degrade
|
|
918
|
+
// gracefully: the previous set stays and the next poll retries.
|
|
919
|
+
const refreshPending = () => {
|
|
920
|
+
if (!loadPending || pendingBusy) return Promise.resolve();
|
|
921
|
+
pendingBusy = true;
|
|
922
|
+
return Promise.resolve(loadPending()).then((p) => {
|
|
923
|
+
pendingForms = (p && p.forms) || new Set();
|
|
924
|
+
pendingPerms = (p && p.perms) || new Set();
|
|
925
|
+
pendingBusy = false;
|
|
926
|
+
if (!done) draw();
|
|
927
|
+
}).catch(() => { pendingBusy = false; });
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
// ── Answer mode (A): answer the agent's pending form(s) ─────────
|
|
931
|
+
// Field applicability honours `hidden` and `when` conditions over the
|
|
932
|
+
// answers chosen SO FAR (live re-validation: groups appear/disappear as you
|
|
933
|
+
// answer their gate questions).
|
|
934
|
+
const fieldVisible = (field) => {
|
|
935
|
+
if (!field || field.hidden) return false;
|
|
936
|
+
if (!Array.isArray(field.when) || field.when.length === 0) return true;
|
|
937
|
+
return field.when.every((w) => {
|
|
938
|
+
const got = answer.answers[w.key];
|
|
939
|
+
if (got === undefined || got === null) return false;
|
|
940
|
+
// For multiselect gates `eq` means the value is among the chosen ones.
|
|
941
|
+
const matches = Array.isArray(got) ? got.includes(w.value) : got === w.value;
|
|
942
|
+
return (w.op === 'neq') ? !matches : matches;
|
|
943
|
+
});
|
|
944
|
+
};
|
|
945
|
+
const fieldOptions = (field) => {
|
|
946
|
+
if (field.type === 'boolean') {
|
|
947
|
+
return [{ label: 'Yes', value: true }, { label: 'No', value: false }];
|
|
948
|
+
}
|
|
949
|
+
if (field.type === 'string' || field.type === 'multiselect') {
|
|
950
|
+
return Array.isArray(field.options) ? field.options : [];
|
|
951
|
+
}
|
|
952
|
+
return [];
|
|
953
|
+
};
|
|
954
|
+
const knownValues = (field) => new Set((field.options || []).map(o => String(o.value)));
|
|
955
|
+
// The custom text (if any) behind "Type your own answer" — distinguishes a
|
|
956
|
+
// typed answer from a chosen option for single-choice and multiselect.
|
|
957
|
+
const customText = (field) => {
|
|
958
|
+
const v = answer.answers[field.key];
|
|
959
|
+
if (v === undefined || v === null) return null;
|
|
960
|
+
if (Array.isArray(v)) {
|
|
961
|
+
const extra = v.filter(x => !knownValues(field).has(String(x)));
|
|
962
|
+
return extra.length ? extra.join(', ') : null;
|
|
963
|
+
}
|
|
964
|
+
return knownValues(field).has(String(v)) ? null : String(v);
|
|
965
|
+
};
|
|
966
|
+
const isSelected = (field, opt) => {
|
|
967
|
+
const v = answer.answers[field.key];
|
|
968
|
+
if (v === undefined || v === null) return false;
|
|
969
|
+
if (Array.isArray(v)) return v.some(x => String(x) === String(opt.value));
|
|
970
|
+
return String(v) === String(opt.value);
|
|
971
|
+
};
|
|
972
|
+
const toggleSelect = (field, opt) => {
|
|
973
|
+
if (field.type === 'multiselect') {
|
|
974
|
+
const arr = Array.isArray(answer.answers[field.key]) ? [...answer.answers[field.key]] : [];
|
|
975
|
+
const i = arr.findIndex(x => String(x) === String(opt.value));
|
|
976
|
+
if (i >= 0) arr.splice(i, 1); else arr.push(opt.value);
|
|
977
|
+
answer.answers[field.key] = arr;
|
|
978
|
+
} else {
|
|
979
|
+
answer.answers[field.key] = opt.value;
|
|
980
|
+
}
|
|
981
|
+
answer.error = null;
|
|
982
|
+
};
|
|
983
|
+
// Save a typed value (custom / free-text / numeric field). No client-side
|
|
984
|
+
// validation beyond numeric coercion — the server decides and its message
|
|
985
|
+
// is shown in the status bar.
|
|
986
|
+
const applyAnswerValue = (fieldKey, raw) => {
|
|
987
|
+
const form = answer.forms[answer.index];
|
|
988
|
+
const field = (form.fields || []).find(f => f.key === fieldKey);
|
|
989
|
+
const v = raw.trim();
|
|
990
|
+
if (!v) {
|
|
991
|
+
delete answer.answers[fieldKey];
|
|
992
|
+
} else if (field && (field.type === 'number' || field.type === 'integer')) {
|
|
993
|
+
const num = Number(v);
|
|
994
|
+
answer.answers[fieldKey] = Number.isNaN(num) ? v : num;
|
|
995
|
+
} else if (field && field.type === 'multiselect') {
|
|
996
|
+
// Keep the built-in selections and replace any custom entries with the
|
|
997
|
+
// newly typed value.
|
|
998
|
+
const known = knownValues(field);
|
|
999
|
+
const base = Array.isArray(answer.answers[fieldKey]) ? answer.answers[fieldKey].filter(x => known.has(String(x))) : [];
|
|
1000
|
+
base.push(v);
|
|
1001
|
+
answer.answers[fieldKey] = base;
|
|
1002
|
+
} else {
|
|
1003
|
+
answer.answers[fieldKey] = v;
|
|
1004
|
+
}
|
|
1005
|
+
answer.error = null;
|
|
1006
|
+
};
|
|
1007
|
+
// Prefill `default` values once per form, before the user starts editing
|
|
1008
|
+
// (rebuilt answers never clobber what was already chosen).
|
|
1009
|
+
const seedDefaults = () => {
|
|
1010
|
+
const form = answer.forms[answer.index];
|
|
1011
|
+
if (!form || !Array.isArray(form.fields)) return;
|
|
1012
|
+
for (const f of form.fields) {
|
|
1013
|
+
if (fieldVisible(f) && f.default !== undefined && f.default !== null && answer.answers[f.key] === undefined) {
|
|
1014
|
+
answer.answers[f.key] = f.default;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
// Flat list of *visible* rows for the current form: a section header per
|
|
1019
|
+
// applicable field, one row per option (or a single type-in row for
|
|
1020
|
+
// free-text/numeric fields), then the Submit row.
|
|
1021
|
+
const buildAnswerItems = (form) => {
|
|
1022
|
+
const items = [];
|
|
1023
|
+
for (const field of (form.fields || []).filter(fieldVisible)) {
|
|
1024
|
+
if (field.type === 'external') {
|
|
1025
|
+
items.push({ kind: 'external', field });
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
items.push({ kind: 'field', field });
|
|
1029
|
+
const opts = fieldOptions(field);
|
|
1030
|
+
for (const opt of opts) items.push({ kind: 'option', field, opt });
|
|
1031
|
+
const wantTypedRow = opts.length === 0 || ((field.type === 'string' || field.type === 'multiselect') && field.custom);
|
|
1032
|
+
if (wantTypedRow) {
|
|
1033
|
+
items.push({ kind: 'option', field, opt: null, custom: !!field.custom, inputOnly: opts.length === 0 });
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
items.push({ kind: 'submit' });
|
|
1037
|
+
return items;
|
|
1038
|
+
};
|
|
1039
|
+
const answerItems = () => {
|
|
1040
|
+
const form = answer.forms[answer.index];
|
|
1041
|
+
return form ? buildAnswerItems(form) : [];
|
|
1042
|
+
};
|
|
1043
|
+
const isAnswerClickable = (item) => item && (item.kind === 'option' || item.kind === 'submit');
|
|
1044
|
+
// After a `when` re-validation some rows disappear; keep the cursor on a
|
|
1045
|
+
// selectable row (called from drawAnswer so the frame always self-heals).
|
|
1046
|
+
const clampAnswerCursor = () => {
|
|
1047
|
+
const items = answerItems();
|
|
1048
|
+
if (!items.length) { answer.cursor = 0; return; }
|
|
1049
|
+
if (isAnswerClickable(items[answer.cursor])) return;
|
|
1050
|
+
for (let off = 1; off < items.length; off++) {
|
|
1051
|
+
const i = (answer.cursor + off) % items.length;
|
|
1052
|
+
if (isAnswerClickable(items[i])) { answer.cursor = i; return; }
|
|
1053
|
+
}
|
|
1054
|
+
answer.cursor = 0;
|
|
1055
|
+
};
|
|
1056
|
+
const moveAnswerCursor = (delta) => {
|
|
1057
|
+
const items = answerItems();
|
|
1058
|
+
const n = items.length;
|
|
1059
|
+
if (!n) return;
|
|
1060
|
+
let i = answer.cursor;
|
|
1061
|
+
do {
|
|
1062
|
+
i = (i + delta + n) % n;
|
|
1063
|
+
} while (!isAnswerClickable(items[i]) && i !== answer.cursor);
|
|
1064
|
+
answer.cursor = i;
|
|
1065
|
+
draw();
|
|
1066
|
+
};
|
|
1067
|
+
const activateOption = (item) => {
|
|
1068
|
+
if (item.inputOnly || item.custom) {
|
|
1069
|
+
// Open the text input (custom answer / free-text / numeric field).
|
|
1070
|
+
const label = item.field.title || item.field.key;
|
|
1071
|
+
const cur = (item.inputOnly && answer.answers[item.field.key] !== undefined)
|
|
1072
|
+
? String(answer.answers[item.field.key])
|
|
1073
|
+
: (item.custom ? (customText(item.field) || '') : '');
|
|
1074
|
+
startInput(`Answer for "${label}":`, 'Enter: save · Esc: cancel', cur, 'answer', item.field.key);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
toggleSelect(item.field, item.opt);
|
|
1078
|
+
draw();
|
|
1079
|
+
};
|
|
1080
|
+
const submitAnswer = () => {
|
|
1081
|
+
if (answer.busy) return;
|
|
1082
|
+
const form = answer.forms[answer.index];
|
|
1083
|
+
if (!form) return;
|
|
1084
|
+
answer.busy = true;
|
|
1085
|
+
answer.error = null;
|
|
1086
|
+
draw();
|
|
1087
|
+
replyToForm(server, form.sessionID, form.id, answer.answers).then(() => {
|
|
1088
|
+
answer.busy = false;
|
|
1089
|
+
return listSessionForms(server, form.sessionID).then((forms) => {
|
|
1090
|
+
if (!forms || forms.length === 0) {
|
|
1091
|
+
// All questions answered — back to the session list (marker clears
|
|
1092
|
+
// after the pending set is re-polled).
|
|
1093
|
+
answer = null;
|
|
1094
|
+
mode = 'normal';
|
|
1095
|
+
notices.push('✔ Answers submitted.');
|
|
1096
|
+
return refreshPending();
|
|
1097
|
+
}
|
|
1098
|
+
// Move on to the session's next pending form.
|
|
1099
|
+
answer.forms = forms;
|
|
1100
|
+
answer.index = 0;
|
|
1101
|
+
answer.answers = {};
|
|
1102
|
+
answer.cursor = 0;
|
|
1103
|
+
answer.error = null;
|
|
1104
|
+
seedDefaults();
|
|
1105
|
+
draw();
|
|
1106
|
+
return refreshPending();
|
|
1107
|
+
});
|
|
1108
|
+
}).catch((err) => {
|
|
1109
|
+
answer.busy = false;
|
|
1110
|
+
if (err && err.status === 409) {
|
|
1111
|
+
// Answered elsewhere (e.g. from the TUI) while we were editing.
|
|
1112
|
+
answer = null;
|
|
1113
|
+
mode = 'normal';
|
|
1114
|
+
notices.push(err.message || 'Already answered.');
|
|
1115
|
+
return refreshPending();
|
|
1116
|
+
}
|
|
1117
|
+
// The server rejected the answers — surface its message in the status
|
|
1118
|
+
// bar and stay in answer mode so the user can adjust.
|
|
1119
|
+
answer.error = (err && err.message) || 'Failed to submit answers.';
|
|
1120
|
+
draw();
|
|
1121
|
+
});
|
|
1122
|
+
};
|
|
1123
|
+
const startAnswer = (target) => {
|
|
1124
|
+
listSessionForms(server, target.id).then((forms) => {
|
|
1125
|
+
if (!forms || forms.length === 0) { draw(); return; }
|
|
1126
|
+
answer = {
|
|
1127
|
+
forms,
|
|
1128
|
+
index: 0,
|
|
1129
|
+
answers: {},
|
|
1130
|
+
cursor: 0,
|
|
1131
|
+
error: null,
|
|
1132
|
+
busy: false,
|
|
1133
|
+
title: (target.title && target.title.trim()) || target.id || 'session',
|
|
1134
|
+
};
|
|
1135
|
+
seedDefaults();
|
|
1136
|
+
mode = 'answer';
|
|
1137
|
+
draw();
|
|
1138
|
+
}).catch(commandErr);
|
|
1139
|
+
};
|
|
1140
|
+
const renderAnswerItem = (item, cols) => {
|
|
1141
|
+
if (item.kind === 'field') {
|
|
1142
|
+
const label = (item.field.title || item.field.key).slice(0, Math.max(4, cols - 6));
|
|
1143
|
+
const req = item.field.required ? ' *' : '';
|
|
1144
|
+
return `${BROWN}▸ ${label}${req}\x1b[0m`;
|
|
1145
|
+
}
|
|
1146
|
+
if (item.kind === 'external') {
|
|
1147
|
+
const label = (item.field.title || item.field.key).slice(0, Math.max(4, cols - 6));
|
|
1148
|
+
return ` ⛓ ${label} (external link, can't be answered here)`;
|
|
1149
|
+
}
|
|
1150
|
+
if (item.kind === 'submit') {
|
|
1151
|
+
const fields = answerItems().filter(i => i.kind === 'field');
|
|
1152
|
+
const done = fields.filter(i => answer.answers[i.field.key] !== undefined).length;
|
|
1153
|
+
const total = fields.length;
|
|
1154
|
+
const tally = total > 0 ? ` (${done}/${total} answered)` : '';
|
|
1155
|
+
return ` ${done === total && total > 0 ? '✓' : ' '} Submit answers${tally}`;
|
|
1156
|
+
}
|
|
1157
|
+
// Option / custom / free-text row.
|
|
1158
|
+
let selected;
|
|
1159
|
+
let label;
|
|
1160
|
+
if (item.custom) {
|
|
1161
|
+
const cv = customText(item.field);
|
|
1162
|
+
selected = cv !== null;
|
|
1163
|
+
label = selected ? `Custom: ${cv}` : 'Type your own answer';
|
|
1164
|
+
} else if (item.inputOnly) {
|
|
1165
|
+
const v = answer.answers[item.field.key];
|
|
1166
|
+
selected = v !== undefined;
|
|
1167
|
+
label = selected ? String(v) : (item.field.placeholder || 'Type your answer');
|
|
1168
|
+
} else {
|
|
1169
|
+
selected = isSelected(item.field, item.opt);
|
|
1170
|
+
label = item.opt.label;
|
|
1171
|
+
}
|
|
1172
|
+
const prefix = selected ? '(•)' : '( )';
|
|
1173
|
+
return ` ${prefix} ${String(label).slice(0, Math.max(4, cols - 10))}`;
|
|
1174
|
+
};
|
|
1175
|
+
const drawAnswer = () => {
|
|
1176
|
+
let out = '\x1b[2J\x1b[H';
|
|
1177
|
+
if (mode === 'answer-help') {
|
|
1178
|
+
out += 'ocmux — answer mode help\n\n';
|
|
1179
|
+
out += ' j / k, arrows move between the questions and their options\n';
|
|
1180
|
+
out += ' Space / Enter select an option (extra entries toggle on\n';
|
|
1181
|
+
out += ' multi-select questions); on "Type your own\n';
|
|
1182
|
+
out += ' answer" / free-text rows it opens the input\n';
|
|
1183
|
+
out += ' Submit answers the last row: send all answers in one request\n';
|
|
1184
|
+
out += ' h toggle this help\n';
|
|
1185
|
+
out += ' q / ESC cancel WITHOUT answering (back to the list)\n';
|
|
1186
|
+
out += ' Ctrl+C quit ocmux\n';
|
|
1187
|
+
out += '\nPress any key to return.\n';
|
|
1188
|
+
process.stderr.write(out);
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
const form = answer.forms[answer.index];
|
|
1192
|
+
const items = answerItems();
|
|
1193
|
+
clampAnswerCursor();
|
|
1194
|
+
const cols = process.stdout.columns || 80;
|
|
1195
|
+
const termRows = process.stdout.rows || 24;
|
|
1196
|
+
// Title bar + form line + status bar eat 3 rows.
|
|
1197
|
+
const viewHeight = Math.max(1, termRows - 3);
|
|
1198
|
+
const win = windowFor(items.length, answer.cursor, viewHeight);
|
|
1199
|
+
const title = `ocmux — answer: ${answer.title}`.slice(0, cols);
|
|
1200
|
+
out += BAR_BG + centerIn(title, cols) + '\x1b[0m\n';
|
|
1201
|
+
const formLine = (answer.forms.length > 1 ? `Form ${answer.index + 1}/${answer.forms.length} · ` : '')
|
|
1202
|
+
+ (form && form.title ? form.title : '');
|
|
1203
|
+
out += formLine ? `${BROWN}${formLine.slice(0, cols)}\x1b[0m\n` : '\n';
|
|
1204
|
+
for (let i = win.start; i < win.start + win.count && i < items.length; i++) {
|
|
1205
|
+
const item = items[i];
|
|
1206
|
+
const clickable = isAnswerClickable(item);
|
|
1207
|
+
const ptr = (clickable && i === answer.cursor) ? `${LIGHT_YELLOW}▶\x1b[0m ` : ' ';
|
|
1208
|
+
out += ptr + renderAnswerItem(item, cols) + '\n';
|
|
1209
|
+
}
|
|
1210
|
+
for (let i = win.start + win.count; i < viewHeight; i++) out += '\n';
|
|
1211
|
+
if (mode === 'input' && input) {
|
|
1212
|
+
out += QUESTION_BG + padLeft(`${input.prompt} ${renderInputValue(input.value)}`, cols) + '\x1b[0m\n';
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
let footer;
|
|
1216
|
+
if (answer.busy) footer = 'Submitting answers…';
|
|
1217
|
+
else if (answer.error) footer = `✖ ${answer.error}`;
|
|
1218
|
+
else footer = 'Space/Enter: select · h: help · q: cancel · Ctrl+C: quit';
|
|
1219
|
+
out += BAR_BG + (footer.length > cols ? footer.slice(0, cols) : padLeft(footer, cols)) + '\x1b[0m\n';
|
|
1220
|
+
process.stderr.write(out);
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
// ── Permission-answer screen ('P') ────────────────────────────────
|
|
1224
|
+
// Each pending permission request of the cursor session is answered in
|
|
1225
|
+
// turn: o/1 allow once, a allow always, r reject. drawPerm renders the
|
|
1226
|
+
// request summary; the decision keys post the reply and advance.
|
|
1227
|
+
const drawPerm = () => {
|
|
1228
|
+
let out = '\x1b[2J\x1b[H';
|
|
1229
|
+
if (mode === 'perm-help') {
|
|
1230
|
+
out += 'ocmux — permission help\n\n';
|
|
1231
|
+
out += ' o / 1 allow this request ONCE\n';
|
|
1232
|
+
out += ' a allow ALWAYS (remembered for the session)\n';
|
|
1233
|
+
out += ' r REJECT\n';
|
|
1234
|
+
out += ' h toggle this help\n';
|
|
1235
|
+
out += ' q / ESC back to the session list (leave it pending)\n';
|
|
1236
|
+
out += ' Ctrl+C quit ocmux\n';
|
|
1237
|
+
out += '\nPress any key to return.\n';
|
|
1238
|
+
process.stderr.write(out);
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
const cols = process.stdout.columns || 80;
|
|
1242
|
+
const termRows = process.stdout.rows || 24;
|
|
1243
|
+
const req = perm.list[perm.cursor];
|
|
1244
|
+
out += BAR_BG + centerIn(`ocmux — permission: ${perm.title}`, cols) + '\x1b[0m\n';
|
|
1245
|
+
const countLine = perm.list.length > 1 ? `Request ${perm.cursor + 1}/${perm.list.length}` : '';
|
|
1246
|
+
out += (countLine ? `${BROWN}${countLine.slice(0, cols)}\x1b[0m\n` : '\n');
|
|
1247
|
+
if (req) {
|
|
1248
|
+
out += ` Action: ${String(req.action || 'unknown').slice(0, cols - 9)}\n`;
|
|
1249
|
+
const res = (req.resources || []).join(', ') || '(none)';
|
|
1250
|
+
out += ` Resources: ${res.slice(0, cols - 12)}\n`;
|
|
1251
|
+
if (req.message) out += ` Message: ${String(req.message).slice(0, cols - 10)}\n`;
|
|
1252
|
+
if (req.save && req.save.length) out += ` Remember: ${req.save.join(', ').slice(0, cols - 11)}\n`;
|
|
1253
|
+
}
|
|
1254
|
+
const bodyCount = req ? 2 + (req.message ? 1 : 0) + (req.save && req.save.length ? 1 : 0) : 0;
|
|
1255
|
+
for (let i = 2 + bodyCount; i < termRows - 1; i++) out += '\n';
|
|
1256
|
+
let footer;
|
|
1257
|
+
if (perm.busy) footer = 'Replying…';
|
|
1258
|
+
else if (perm.error) footer = `✖ ${perm.error}`;
|
|
1259
|
+
else footer = 'o: allow once · a: allow always · r: reject · h: help · q: back · Ctrl+C: quit';
|
|
1260
|
+
out += BAR_BG + (footer.length > cols ? footer.slice(0, cols) : padLeft(footer, cols)) + '\x1b[0m\n';
|
|
1261
|
+
process.stderr.write(out);
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1264
|
+
// Open permission mode for the cursor session: fetch the location's pending
|
|
1265
|
+
// permission requests and keep only this session's (the poll only tracks
|
|
1266
|
+
// ids, not the full request objects).
|
|
1267
|
+
const startPerm = (target) => {
|
|
1268
|
+
listPendingPermissions(server, dir).then((all) => {
|
|
1269
|
+
const list = (all || []).filter((r) => String(r.sessionID) === String(target.id));
|
|
1270
|
+
if (!list.length) { notices.push('No pending permission to answer.'); draw(); return; }
|
|
1271
|
+
perm = {
|
|
1272
|
+
list,
|
|
1273
|
+
cursor: 0,
|
|
1274
|
+
error: null,
|
|
1275
|
+
busy: false,
|
|
1276
|
+
title: (target.title && target.title.trim()) || target.id || 'session',
|
|
1277
|
+
};
|
|
1278
|
+
mode = 'perm';
|
|
1279
|
+
draw();
|
|
1280
|
+
}).catch(commandErr);
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
// Post a decision for the current permission request; on success drop it
|
|
1284
|
+
// from the list and advance to the next (or return to the session list
|
|
1285
|
+
// once all are answered).
|
|
1286
|
+
const submitPerm = (decision) => {
|
|
1287
|
+
if (perm.busy) return;
|
|
1288
|
+
const req = perm.list[perm.cursor];
|
|
1289
|
+
if (!req) return;
|
|
1290
|
+
perm.busy = true;
|
|
1291
|
+
perm.error = null;
|
|
1292
|
+
draw();
|
|
1293
|
+
replyToPermission(server, req.sessionID, req.id, decision).then(() => {
|
|
1294
|
+
perm.busy = false;
|
|
1295
|
+
perm.list.splice(perm.cursor, 1);
|
|
1296
|
+
if (perm.list.length === 0) {
|
|
1297
|
+
perm = null;
|
|
1298
|
+
mode = 'normal';
|
|
1299
|
+
notices.push('✔ Permission answered.');
|
|
1300
|
+
return refreshPending();
|
|
1301
|
+
}
|
|
1302
|
+
perm.cursor = Math.min(perm.cursor, perm.list.length - 1);
|
|
1303
|
+
perm.error = null;
|
|
1304
|
+
draw();
|
|
1305
|
+
return refreshPending();
|
|
1306
|
+
}).catch((err) => {
|
|
1307
|
+
perm.busy = false;
|
|
1308
|
+
perm.error = (err && err.message) || 'Failed to reply.';
|
|
1309
|
+
draw();
|
|
1310
|
+
});
|
|
1311
|
+
};
|
|
1312
|
+
|
|
803
1313
|
const applyDelete = () => {
|
|
804
1314
|
const s = confirmDel;
|
|
805
1315
|
if (!s || !onDelete) return;
|
|
@@ -821,26 +1331,27 @@ function sessionMenu(sessions, currentSession, annotations, server, { onPick, on
|
|
|
821
1331
|
// Deleting the whole broadcast selection ends broadcast mode; adopt
|
|
822
1332
|
// the row now under the cursor as the new current (state + TUI +
|
|
823
1333
|
// highlight) so the list and the TUI agree on the new selection.
|
|
824
|
-
|
|
1334
|
+
// (applyPick clears the persisted broadcast; the explicit clear is
|
|
1335
|
+
// only needed when there is no row left to adopt.)
|
|
825
1336
|
broadcastSel = new Set();
|
|
826
1337
|
mode = 'normal';
|
|
827
1338
|
const next = view[cursor];
|
|
828
1339
|
if (next) applyPick(next);
|
|
829
|
-
else { current = null; draw(); }
|
|
1340
|
+
else { persistBroadcast([]); current = null; draw(); }
|
|
830
1341
|
return;
|
|
831
1342
|
}
|
|
832
1343
|
if (wasBroadcast) {
|
|
833
1344
|
broadcastSel.delete(String(s.id));
|
|
834
1345
|
if (broadcastSel.size < 2) {
|
|
835
1346
|
// Broadcast ended: make the remaining selected session the real
|
|
836
|
-
// selection (switch the TUI to it and store it).
|
|
1347
|
+
// selection (switch the TUI to it and store it). applyPick clears
|
|
1348
|
+
// the persisted broadcast; clear it here too when no row remains.
|
|
837
1349
|
const remaining = [...broadcastSel][0];
|
|
838
|
-
persistBroadcast([]);
|
|
839
1350
|
broadcastSel = new Set();
|
|
840
1351
|
mode = 'normal';
|
|
841
1352
|
const row = rows.find(r => String(r.id) === String(remaining));
|
|
842
1353
|
if (row) applyPick(row);
|
|
843
|
-
else { draw(); }
|
|
1354
|
+
else { persistBroadcast([]); draw(); }
|
|
844
1355
|
} else {
|
|
845
1356
|
persistBroadcast([...broadcastSel]);
|
|
846
1357
|
mode = 'broadcast';
|
|
@@ -914,14 +1425,14 @@ const toggleBroadcast = (idx) => {
|
|
|
914
1425
|
if (adding && onInspectSession) { onInspectSession(id); inspected = true; }
|
|
915
1426
|
if (broadcastSel.size < 2) {
|
|
916
1427
|
// Deselecting down to one session ends broadcast mode by making that
|
|
917
|
-
// remaining session the actual selection.
|
|
1428
|
+
// remaining session the actual selection. applyPick clears the persisted
|
|
1429
|
+
// broadcast; clear it here too when no row remains.
|
|
918
1430
|
const remaining = [...broadcastSel][0];
|
|
919
|
-
persistBroadcast([]);
|
|
920
1431
|
broadcastSel = new Set();
|
|
921
1432
|
mode = 'normal';
|
|
922
1433
|
const row = rows.find(r => String(r.id) === String(remaining));
|
|
923
1434
|
if (row) applyPick(row);
|
|
924
|
-
else { restoreCurrent(); draw(); }
|
|
1435
|
+
else { persistBroadcast([]); restoreCurrent(); draw(); }
|
|
925
1436
|
return;
|
|
926
1437
|
}
|
|
927
1438
|
persistBroadcast([...broadcastSel]);
|
|
@@ -968,8 +1479,11 @@ const toggleBroadcast = (idx) => {
|
|
|
968
1479
|
}
|
|
969
1480
|
return finish(null);
|
|
970
1481
|
}
|
|
971
|
-
if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help') {
|
|
972
|
-
mode = mode === 'broadcast-help' ? 'broadcast'
|
|
1482
|
+
if (mode === 'help' || mode === 'broadcast-help' || mode === 'list-help' || mode === 'answer-help' || mode === 'perm-help') {
|
|
1483
|
+
mode = mode === 'broadcast-help' ? 'broadcast'
|
|
1484
|
+
: (mode === 'list-help' ? 'list'
|
|
1485
|
+
: (mode === 'answer-help' ? 'answer'
|
|
1486
|
+
: (mode === 'perm-help' ? 'perm' : 'normal')));
|
|
973
1487
|
draw();
|
|
974
1488
|
return;
|
|
975
1489
|
}
|
|
@@ -985,19 +1499,24 @@ const toggleBroadcast = (idx) => {
|
|
|
985
1499
|
}
|
|
986
1500
|
if (mode === 'input') {
|
|
987
1501
|
if (key.name === 'escape') {
|
|
1502
|
+
const wasAnswer = input && input.kind === 'answer';
|
|
988
1503
|
input = null;
|
|
989
|
-
mode = 'normal';
|
|
1504
|
+
mode = wasAnswer ? 'answer' : 'normal';
|
|
990
1505
|
draw();
|
|
991
1506
|
} else if (key.name === 'return' || key.name === 'enter') {
|
|
992
1507
|
const v = input.value;
|
|
993
1508
|
const kind = input.kind;
|
|
1509
|
+
const targetKey = input.targetKey;
|
|
994
1510
|
input = null;
|
|
995
|
-
mode = 'normal';
|
|
1511
|
+
mode = kind === 'answer' ? 'answer' : 'normal';
|
|
996
1512
|
draw();
|
|
997
1513
|
if (kind === 'create') {
|
|
998
1514
|
applyPick({ new: true, name: v.trim() });
|
|
999
1515
|
} else if (kind === 'reminder') {
|
|
1000
1516
|
applyReminder(v.trim());
|
|
1517
|
+
} else if (kind === 'answer') {
|
|
1518
|
+
applyAnswerValue(targetKey, v);
|
|
1519
|
+
draw();
|
|
1001
1520
|
} else if (v.trim()) {
|
|
1002
1521
|
applyRenameWith(v.trim());
|
|
1003
1522
|
}
|
|
@@ -1062,6 +1581,47 @@ const toggleBroadcast = (idx) => {
|
|
|
1062
1581
|
}
|
|
1063
1582
|
const sr = handleSearch(str, key, search);
|
|
1064
1583
|
if (sr !== 'pass') { applySearch(); draw(); return; }
|
|
1584
|
+
if (mode === 'answer' && answer) {
|
|
1585
|
+
if (answer.busy) return;
|
|
1586
|
+
if (key.name === 'escape' || key.name === 'q') {
|
|
1587
|
+
// Cancel WITHOUT answering — back to the session list.
|
|
1588
|
+
answer = null;
|
|
1589
|
+
mode = 'normal';
|
|
1590
|
+
draw();
|
|
1591
|
+
} else if (key.name === 'h') {
|
|
1592
|
+
mode = 'answer-help';
|
|
1593
|
+
draw();
|
|
1594
|
+
} else if (key.name === 'up' || str === 'k') {
|
|
1595
|
+
moveAnswerCursor(-1);
|
|
1596
|
+
} else if (key.name === 'down' || str === 'j') {
|
|
1597
|
+
moveAnswerCursor(1);
|
|
1598
|
+
} else if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
|
|
1599
|
+
const items = answerItems();
|
|
1600
|
+
const item = items[answer.cursor];
|
|
1601
|
+
if (item && item.kind === 'submit') submitAnswer();
|
|
1602
|
+
else if (item && item.kind === 'option') activateOption(item);
|
|
1603
|
+
}
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
if (mode === 'perm' && perm) {
|
|
1607
|
+
if (perm.busy) return;
|
|
1608
|
+
if (key.name === 'escape' || key.name === 'q') {
|
|
1609
|
+
// Back to the session list, leaving the request(s) pending.
|
|
1610
|
+
perm = null;
|
|
1611
|
+
mode = 'normal';
|
|
1612
|
+
draw();
|
|
1613
|
+
} else if (key.name === 'h') {
|
|
1614
|
+
mode = 'perm-help';
|
|
1615
|
+
draw();
|
|
1616
|
+
} else if (str === 'o' || str === '1') {
|
|
1617
|
+
submitPerm('once');
|
|
1618
|
+
} else if (str === 'a') {
|
|
1619
|
+
submitPerm('always');
|
|
1620
|
+
} else if (str === 'r') {
|
|
1621
|
+
submitPerm('reject');
|
|
1622
|
+
}
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1065
1625
|
if (mode === 'broadcast') {
|
|
1066
1626
|
if (key.name === 'escape') {
|
|
1067
1627
|
persistBroadcast([]);
|
|
@@ -1103,8 +1663,9 @@ const toggleBroadcast = (idx) => {
|
|
|
1103
1663
|
});
|
|
1104
1664
|
} else if (key.name === 'return' || key.name === 'enter') {
|
|
1105
1665
|
if (cursor < view.length) {
|
|
1666
|
+
// Enter ends broadcast mode: applyPick clears the persisted
|
|
1667
|
+
// selection so agentp stops broadcasting to these sessions.
|
|
1106
1668
|
mode = 'normal';
|
|
1107
|
-
broadcastSel = new Set(); // selection stays persisted for agentp
|
|
1108
1669
|
applyPick(view[cursor]);
|
|
1109
1670
|
}
|
|
1110
1671
|
} else if (view.length > 0 && (key.name === 'up' || str === 'k')) {
|
|
@@ -1117,6 +1678,14 @@ const toggleBroadcast = (idx) => {
|
|
|
1117
1678
|
return;
|
|
1118
1679
|
}
|
|
1119
1680
|
if (key.name === 'q') return finish(null);
|
|
1681
|
+
// 'P' (Shift+p) answers the cursor session's pending permission request(s).
|
|
1682
|
+
// Checked before the 'p' project-switcher below for the same readline
|
|
1683
|
+
// reason as 'A'/'a': Shift+P arrives as name 'p' with the shift flag.
|
|
1684
|
+
if (str === 'P' || (key.name === 'p' && key.shift)) {
|
|
1685
|
+
const target = view[cursor];
|
|
1686
|
+
if (target && pendingPerms.has(String(target.id))) startPerm(target);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1120
1689
|
if (key.name === 'p') return finish('switch-project');
|
|
1121
1690
|
if (key.name === 'h') {
|
|
1122
1691
|
mode = 'help';
|
|
@@ -1138,6 +1707,17 @@ const toggleBroadcast = (idx) => {
|
|
|
1138
1707
|
startInput('Rename to:', 'Enter: rename · Esc: cancel', view[cursor].title || '', 'rename');
|
|
1139
1708
|
return;
|
|
1140
1709
|
}
|
|
1710
|
+
// 'A' (Shift+a) answers pending agent questions. It MUST be checked
|
|
1711
|
+
// before the 'a' agent handler below: readline reports Shift+A as
|
|
1712
|
+
// name 'a' with the shift flag (str 'A'), so the lowercase branch would
|
|
1713
|
+
// otherwise swallow it and open the agent switcher instead.
|
|
1714
|
+
if (str === 'A' || (key.name === 'a' && key.shift)) {
|
|
1715
|
+
// Answer-mode: only when the cursor session actually has a pending
|
|
1716
|
+
// agent question (a pending form) — 'P' answers permissions instead.
|
|
1717
|
+
const target = view[cursor];
|
|
1718
|
+
if (target && pendingForms.has(String(target.id))) startAnswer(target);
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1141
1721
|
if (key.name === 'a') {
|
|
1142
1722
|
const target = view[cursor];
|
|
1143
1723
|
if (target) {
|
|
@@ -1189,6 +1769,12 @@ const toggleBroadcast = (idx) => {
|
|
|
1189
1769
|
if (rows.some(s => runningIds && runningIds.has(s.id))) {
|
|
1190
1770
|
spinTimer = setInterval(() => { spinIdx++; draw(); }, 120);
|
|
1191
1771
|
}
|
|
1772
|
+
// Poll for pending agent questions / permission replies while the picker is
|
|
1773
|
+
// open (the ❓/🔒 marker columns and the 'A' entry refresh live).
|
|
1774
|
+
if (loadPending) {
|
|
1775
|
+
refreshPending();
|
|
1776
|
+
pendingTimer = setInterval(() => { refreshPending(); }, PENDING_POLL_MS);
|
|
1777
|
+
}
|
|
1192
1778
|
process.stderr.write('\x1b[?1049h\x1b[?25l');
|
|
1193
1779
|
draw();
|
|
1194
1780
|
});
|
|
@@ -1206,7 +1792,7 @@ const toggleBroadcast = (idx) => {
|
|
|
1206
1792
|
// row also records the target directory, so the caller moves its picker there
|
|
1207
1793
|
// and can then write that project's `.ocmux.json` — the file `agentp` reads in
|
|
1208
1794
|
// that directory.
|
|
1209
|
-
function switchMenu(rows, { allProjects = false, onOpen } = {}) {
|
|
1795
|
+
function switchMenu(rows, { allProjects = false, onOpen, pending } = {}) {
|
|
1210
1796
|
return new Promise((resolve) => {
|
|
1211
1797
|
if (!process.stdin.isTTY) {
|
|
1212
1798
|
console.error('Error: "switch" is interactive and requires a TTY.');
|
|
@@ -1218,6 +1804,8 @@ function switchMenu(rows, { allProjects = false, onOpen } = {}) {
|
|
|
1218
1804
|
return;
|
|
1219
1805
|
}
|
|
1220
1806
|
|
|
1807
|
+
const pendingOf = (dir) => (pending && pending.get(dir)) || { forms: new Set(), perms: new Set() };
|
|
1808
|
+
|
|
1221
1809
|
const nameW = Math.max(...rows.map(r => path.basename(r.dir).length), 4);
|
|
1222
1810
|
const stsW = Math.max(...rows.map(r => r.status.length), 6);
|
|
1223
1811
|
const ssnW = Math.max(...rows.map(r => (r.session || '').length), 7);
|
|
@@ -1317,6 +1905,10 @@ function switchMenu(rows, { allProjects = false, onOpen } = {}) {
|
|
|
1317
1905
|
out += ' h toggle this help\n';
|
|
1318
1906
|
out += ' q return to the session menu\n';
|
|
1319
1907
|
out += ' Ctrl+C quit ocmux\n';
|
|
1908
|
+
out += ' \n';
|
|
1909
|
+
out += ' Markers: ❓ pending question · 🔒 pending permission, always two\n';
|
|
1910
|
+
out += ' columns (the slot is reserved when absent); 🔔 in the\n';
|
|
1911
|
+
out += ' status bar when any listed session waits\n';
|
|
1320
1912
|
out += '\n';
|
|
1321
1913
|
for (const l of modeNote) out += `${l}\n`;
|
|
1322
1914
|
out += '\nPress any key to return.\n';
|
|
@@ -1343,12 +1935,19 @@ function switchMenu(rows, { allProjects = false, onOpen } = {}) {
|
|
|
1343
1935
|
const id = String(s.id || '');
|
|
1344
1936
|
const mark = String(s.id) === String(node.project.session) ? ' *' : '';
|
|
1345
1937
|
const title = (s.title || id).slice(0, Math.max(8, nameW + 6));
|
|
1346
|
-
|
|
1938
|
+
// Pending marker columns for this session (❓/🔒), always 4 cols.
|
|
1939
|
+
const pe = pendingOf(node.project.dir);
|
|
1940
|
+
const gr = pendingCells(pe.forms.has(id), pe.perms.has(id));
|
|
1941
|
+
return ` ${title}${mark} ${gr} ${id}`;
|
|
1347
1942
|
}
|
|
1348
1943
|
const p = node.project;
|
|
1349
1944
|
const fold = foldOf(p).open ? '▾' : '▸';
|
|
1350
1945
|
const session = p.session ? p.session.slice(0, 14) : '(none)';
|
|
1351
|
-
|
|
1946
|
+
// A folded project shows its aggregate marker columns when ANY of its
|
|
1947
|
+
// sessions (per the location-scoped pending fetch) awaits an answer.
|
|
1948
|
+
const pe = pendingOf(p.dir);
|
|
1949
|
+
const gr = pendingCells(pe.forms.size > 0, pe.perms.size > 0);
|
|
1950
|
+
const text = `${fold} ${gr} ${pad(path.basename(p.dir), nameW)} ${pad(p.status, stsW)} ${pad(session, ssnW)} ${p.dir}`;
|
|
1352
1951
|
return text;
|
|
1353
1952
|
},
|
|
1354
1953
|
footer: searchFooter(search, `${enterHint} · Space: fold · /: search · h: help · q: back · Ctrl+C: quit`),
|
|
@@ -1529,7 +2128,9 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
|
1529
2128
|
} catch { /* context limits unavailable */ }
|
|
1530
2129
|
|
|
1531
2130
|
const applySession = (id) => {
|
|
1532
|
-
|
|
2131
|
+
// A session switch also drops any persisted broadcast list, so agentp
|
|
2132
|
+
// never keeps broadcasting to sessions that are no longer selected.
|
|
2133
|
+
projectState.writeProjectState(sf, { session: id, broadcast: [] });
|
|
1533
2134
|
refreshTui(dir, server, id);
|
|
1534
2135
|
};
|
|
1535
2136
|
|
|
@@ -1566,6 +2167,18 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
|
1566
2167
|
runningIds,
|
|
1567
2168
|
modelContext,
|
|
1568
2169
|
notices,
|
|
2170
|
+
// A persisted broadcast list, so the picker opens in broadcast mode when
|
|
2171
|
+
// one is active (validated and cleaned inside `sessionMenu`).
|
|
2172
|
+
broadcast: state.broadcast,
|
|
2173
|
+
// Pending agent questions / permission replies for this project, polled
|
|
2174
|
+
// by the picker while it is open. Failures degrade to empty sets.
|
|
2175
|
+
loadPending: () => Promise.all([
|
|
2176
|
+
listPendingForms(server, dir).catch(() => []),
|
|
2177
|
+
listPendingPermissions(server, dir).catch(() => []),
|
|
2178
|
+
]).then(([forms, perms]) => ({
|
|
2179
|
+
forms: new Set((forms || []).map(f => f.sessionID).filter(Boolean)),
|
|
2180
|
+
perms: new Set((perms || []).map(x => x.sessionID).filter(Boolean)),
|
|
2181
|
+
})),
|
|
1569
2182
|
onBroadcastChange: (ids) => projectState.writeProjectState(sf, { broadcast: ids }),
|
|
1570
2183
|
onInspectSession: (id) => refreshTui(dir, server, id),
|
|
1571
2184
|
onPick: async (row) => {
|
|
@@ -1616,8 +2229,24 @@ async function cmdSessionSwitch(targetDir, directRef, allProjects = false) {
|
|
|
1616
2229
|
console.error(`Error listing projects: ${err.message}`);
|
|
1617
2230
|
return [];
|
|
1618
2231
|
});
|
|
2232
|
+
// Fetch each listed project's pending questions/permissions once, so
|
|
2233
|
+
// folded project rows (and unfolded sessions) can carry the markers.
|
|
2234
|
+
// Errors degrade to empty sets (no markers for that project).
|
|
2235
|
+
const pendingByDir = new Map();
|
|
2236
|
+
await Promise.all(rows.map(async (p) => {
|
|
2237
|
+
if (!p.server) return;
|
|
2238
|
+
const [forms, perms] = await Promise.all([
|
|
2239
|
+
listPendingForms(p.server, p.dir).catch(() => []),
|
|
2240
|
+
listPendingPermissions(p.server, p.dir).catch(() => []),
|
|
2241
|
+
]);
|
|
2242
|
+
pendingByDir.set(p.dir, {
|
|
2243
|
+
forms: new Set((forms || []).map(f => f.sessionID).filter(Boolean)),
|
|
2244
|
+
perms: new Set((perms || []).map(x => x.sessionID).filter(Boolean)),
|
|
2245
|
+
});
|
|
2246
|
+
}));
|
|
1619
2247
|
const picked = await switchMenu(rows, {
|
|
1620
2248
|
allProjects,
|
|
2249
|
+
pending: pendingByDir,
|
|
1621
2250
|
onOpen: (project, id) => refreshTui(project.dir, project.server, id),
|
|
1622
2251
|
});
|
|
1623
2252
|
if (picked === SWITCH_QUIT) break;
|
|
@@ -1930,8 +2559,13 @@ function printHelp() {
|
|
|
1930
2559
|
console.error('project (requires a TTY). The title bar names the project whose');
|
|
1931
2560
|
console.error('sessions are listed. In the picker:');
|
|
1932
2561
|
console.error(' Enter switch · n new · r rename · R remind · d delete · a agents · m model');
|
|
1933
|
-
console.error(' p projects · h help · q quit');
|
|
2562
|
+
console.error(' p projects · h help · q quit · A answer the selected session\'s questions');
|
|
2563
|
+
console.error(' P answer its pending permission requests (allow once / always / reject)');
|
|
1934
2564
|
console.error(' In BROADCAST: Space toggles a session · d deletes cursor · D deletes all');
|
|
2565
|
+
console.error('List rows and the project switcher carry pending marker columns: ❓ agent');
|
|
2566
|
+
console.error('question, 🔒 permission request (one column each, the slot is always');
|
|
2567
|
+
console.error('reserved); the status bar shows 🔔 when any listed session waits.');
|
|
2568
|
+
console.error('The picker re-checks every 2s while open.');
|
|
1935
2569
|
console.error('Selecting updates .ocmux.json and refreshes a dedicated TUI, falling');
|
|
1936
2570
|
console.error('back to the shared TUI. Opening the picker also routes that project\'s');
|
|
1937
2571
|
console.error('TUI to its stored session. Silent on success. (p opens projects; by');
|
package/docs/specification_v2.md
CHANGED
|
@@ -34,7 +34,11 @@ is the durable source of truth:
|
|
|
34
34
|
|
|
35
35
|
`directory`, `session`, and `server` route prompts. `annotations` stores optional
|
|
36
36
|
per-session reminders. `broadcast` exists only with at least two selected main
|
|
37
|
-
sessions
|
|
37
|
+
sessions and is removed as soon as broadcast mode ends (`Enter`/`ESC`/`q`) or any
|
|
38
|
+
session is picked in the normal switcher. On open it is validated against the
|
|
39
|
+
live session list: a valid list starts the picker directly in broadcast mode,
|
|
40
|
+
while a stale/invalid one is cleared and the normal state shown. Writes use a
|
|
41
|
+
temporary file plus atomic rename.
|
|
38
42
|
|
|
39
43
|
No tmux socket, pane ID, process ID, or TUI assignment belongs in project state.
|
|
40
44
|
Those values are ephemeral and machine-local.
|
|
@@ -178,6 +182,14 @@ TUI refresh, and list highlight); deleting any other session leaves the current
|
|
|
178
182
|
selection unchanged. In broadcast mode `d` deletes just the cursor session
|
|
179
183
|
while `D` (Shift+d) deletes every selected session at once — after confirming,
|
|
180
184
|
broadcast mode ends and the row under the cursor becomes the new current.
|
|
185
|
+
In broadcast mode `Enter` switches to the cursor session and ends broadcast,
|
|
186
|
+
while `ESC`/`q` cancel it; every exit clears the persisted `broadcast` list, as
|
|
187
|
+
does picking any session in the normal switcher, so a stale selection never
|
|
188
|
+
survives to keep `agentp` broadcasting. On start the picker adopts a stored
|
|
189
|
+
`broadcast` only when at least two of its ids still name listed sessions; it then
|
|
190
|
+
opens in broadcast mode (re-anchoring on a selected session when the stored
|
|
191
|
+
`session` is not in the list), and otherwise clears the invalid list before
|
|
192
|
+
showing the normal state.
|
|
181
193
|
Interactive prompts (create/rename/reminder/delete/delete-all) take over the
|
|
182
194
|
status bar and flip its background from brown to light yellow — the same color
|
|
183
195
|
as the session-list pointer — so an active question is immediately visible.
|
|
@@ -185,6 +197,50 @@ The broadcast info line (`Broadcast to sessions: …`) never wraps: it holds up
|
|
|
185
197
|
to 480 characters of names (or the terminal width, whichever is smaller), and
|
|
186
198
|
an oversized selection is truncated from the beginning with a leading `...`.
|
|
187
199
|
|
|
200
|
+
**Pending-answer awareness.** While the picker is open it re-polls the project
|
|
201
|
+
every 2 seconds (`GET /api/form` and `GET /api/permission/request`, both
|
|
202
|
+
location-scoped with the `location[directory]=…` deepObject parameter) and
|
|
203
|
+
tracks which session ids currently await an agent question (a form) or a
|
|
204
|
+
permission reply. Sessions carry two independent marker columns — ❓ (question)
|
|
205
|
+
and 🔒 (permission) — each a fixed 2-column cell whose placeholder keeps rows
|
|
206
|
+
aligned when a session has no marker. A single 🔔 leads the status bar (forced
|
|
207
|
+
left-aligned) whenever any listed session is waiting, and the cursor session's
|
|
208
|
+
marker columns are pinned to the info panel's top-right corner (the Title line
|
|
209
|
+
is padded out to `cols - cells.length`).
|
|
210
|
+
Failures of the poll degrade silently: the previous sets stay and the next poll
|
|
211
|
+
retries.
|
|
212
|
+
|
|
213
|
+
**Answer mode (`A`).** When the cursor session has a pending form, `A` fetches
|
|
214
|
+
`GET /api/session/{id}/form` and switches to a dedicated answer screen. It
|
|
215
|
+
renders each visible field as a header plus its option rows; multi-select
|
|
216
|
+
fields toggle extra entries, boolean fields render Yes/No, choice fields with
|
|
217
|
+
`custom` get a "Type your own answer" row, and option-less (free-text/numeric)
|
|
218
|
+
fields open the status-bar input directly. Fields with `hidden` or `when`
|
|
219
|
+
conditions are filtered live over the answers chosen so far (a gated group
|
|
220
|
+
appears the moment its gate question is answered). The final `Submit answers
|
|
221
|
+
(n/m answered)` row posts the keyed answer map
|
|
222
|
+
(`POST /api/session/{id}/form/{formID}/reply`, `{answer: {fieldKey: value}}`).
|
|
223
|
+
On success the remaining forms are re-fetched: the next pending form is
|
|
224
|
+
answered in turn, or the picker returns to the session list with a
|
|
225
|
+
`✔ Answers submitted.` notice. A 400 rejection shows the server's message
|
|
226
|
+
(`message` or `error.message` from the body) in the status bar and stays in
|
|
227
|
+
answer mode for correction; a 409 (already settled elsewhere) reports the
|
|
228
|
+
server message and returns to the list. `h` toggles the answer-mode help,
|
|
229
|
+
`q`/`ESC` cancels without answering, `Ctrl+C` exits.
|
|
230
|
+
|
|
231
|
+
**Permission answering (`P`).** Permissions are answered from a dedicated
|
|
232
|
+
screen opened with `P` on a session with a pending permission request (🔒
|
|
233
|
+
marker). It re-reads the location-scoped `GET /api/permission/request` list
|
|
234
|
+
(currently answered requests settle out of it) and filters to the cursor
|
|
235
|
+
session. Each request is shown with its action, resources and optional message;
|
|
236
|
+
one key answers it — `o`/`1` allow once, `a` allow always, `r` reject — posting
|
|
237
|
+
`POST /api/session/{sessionID}/permission/{requestID}/reply` with
|
|
238
|
+
`{decision: 'once' | 'always' | 'reject'}`. Success drops the request and
|
|
239
|
+
advances to the next; after the last it returns to the session list with a
|
|
240
|
+
`✔ Permission answered.` notice. A rejected reply (400) shows the server's
|
|
241
|
+
message in the status bar and stays; `h` toggles a help screen, `q`/`ESC` leaves
|
|
242
|
+
everything pending, `Ctrl+C` exits.
|
|
243
|
+
|
|
188
244
|
### `ocmux session <id|title> [dir]`
|
|
189
245
|
|
|
190
246
|
Non-interactively writes the selected session and refreshes its routed TUI.
|
|
@@ -199,6 +255,12 @@ By default the switcher only inspects sessions through the routed TUI and never
|
|
|
199
255
|
writes another project's state. `--all-projects` lets the outer session picker
|
|
200
256
|
move to another project and subsequently update that project's state.
|
|
201
257
|
|
|
258
|
+
The switcher shows the same pending-answer awareness as the session picker:
|
|
259
|
+
session rows carry their own ❓/🔒 marker columns, and a folded project row
|
|
260
|
+
shows them aggregated over all of its sessions' pending forms/permissions
|
|
261
|
+
(fetched once per project when the switcher opens, from each project's recorded
|
|
262
|
+
server).
|
|
263
|
+
|
|
202
264
|
### `ocmux list [-l]`
|
|
203
265
|
|
|
204
266
|
Lists configured projects, selected sessions, and display status:
|
package/lib/opencode.js
CHANGED
|
@@ -782,7 +782,9 @@ function isServerAlive(url) {
|
|
|
782
782
|
}).catch(() => false);
|
|
783
783
|
}
|
|
784
784
|
|
|
785
|
-
// Respond to a question (choice) asked by the AI (v2 form reply).
|
|
785
|
+
// Respond to a question (choice) asked by the AI (v2 form reply). The v2 reply
|
|
786
|
+
// body takes a KEYED answer map (one reply settles the whole form), but the
|
|
787
|
+
// legacy scalar shape still works for the single-question case.
|
|
786
788
|
async function respondToQuestion(server, sessionId, questionId, answer) {
|
|
787
789
|
const encodedS = encodeURIComponent(sessionId);
|
|
788
790
|
const encodedQ = encodeURIComponent(questionId);
|
|
@@ -792,6 +794,92 @@ async function respondToQuestion(server, sessionId, questionId, answer) {
|
|
|
792
794
|
if (status !== 200 && status !== 204) throw new Error(`Failed to respond to question: ${status}`);
|
|
793
795
|
}
|
|
794
796
|
|
|
797
|
+
// Pull the human-readable message out of a v2 error payload when possible
|
|
798
|
+
// (e.g. `{ _tag: 'FormInvalidAnswerError', message, ... }`).
|
|
799
|
+
function serverMessage(body) {
|
|
800
|
+
try {
|
|
801
|
+
const parsed = JSON.parse(body || '');
|
|
802
|
+
if (parsed && typeof parsed === 'object') {
|
|
803
|
+
if (typeof parsed.message === 'string' && parsed.message) return parsed.message;
|
|
804
|
+
if (parsed.error && typeof parsed.error.message === 'string' && parsed.error.message) return parsed.error.message;
|
|
805
|
+
}
|
|
806
|
+
} catch { /* not JSON */ }
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Pending forms (agent questions) across a location (project directory).
|
|
811
|
+
// `location` is a deepObject query param ({ directory }): /api/form?location[directory]=…
|
|
812
|
+
async function listPendingForms(server, directory) {
|
|
813
|
+
const base = await apiBase(server);
|
|
814
|
+
const params = new URLSearchParams();
|
|
815
|
+
if (directory) params.set('location[directory]', directory);
|
|
816
|
+
const { status, body } = await makeRequest(buildJsonRequest(`${base}/form?${params}`, 'GET'));
|
|
817
|
+
if (status !== 200) throw new Error(`Failed to list pending forms: ${status}`);
|
|
818
|
+
const parsed = parseBody(body);
|
|
819
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Pending forms for a single session (GET /api/session/:id/form). Each entry
|
|
823
|
+
// carries its own `fields` array — enough to render an answer UI.
|
|
824
|
+
async function listSessionForms(server, sessionId) {
|
|
825
|
+
const encoded = encodeURIComponent(sessionId);
|
|
826
|
+
const base = await apiBase(server);
|
|
827
|
+
const { status, body } = await makeRequest(buildJsonRequest(`${base}/session/${encoded}/form`, 'GET'));
|
|
828
|
+
if (status !== 200) throw new Error(`Failed to list session forms: ${status}`);
|
|
829
|
+
const parsed = parseBody(body);
|
|
830
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Submit answers to a pending form. `answers` maps field keys to values
|
|
834
|
+
// (string | number | boolean | string[] for multiselect); one reply settles
|
|
835
|
+
// the whole form. Throws with `err.status` (400 invalid answer, 409 already
|
|
836
|
+
// answered) and the server's message when it provides one.
|
|
837
|
+
async function replyToForm(server, sessionId, formId, answers) {
|
|
838
|
+
const encodedS = encodeURIComponent(sessionId);
|
|
839
|
+
const encodedF = encodeURIComponent(formId);
|
|
840
|
+
const base = await apiBase(server);
|
|
841
|
+
const body = JSON.stringify({ answer: answers });
|
|
842
|
+
const { status, body: resBody } = await makeRequest(
|
|
843
|
+
buildJsonRequest(`${base}/session/${encodedS}/form/${encodedF}/reply`, 'POST', body), body,
|
|
844
|
+
);
|
|
845
|
+
if (status !== 200 && status !== 204) {
|
|
846
|
+
const err = new Error(serverMessage(resBody) || `Failed to answer form: ${status}`);
|
|
847
|
+
err.status = status;
|
|
848
|
+
throw err;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Pending permission requests across a location (GET /api/permission/request).
|
|
853
|
+
// The server returns { location, data: Permission.Request[] }; parseBody
|
|
854
|
+
// unwraps the data envelope.
|
|
855
|
+
async function listPendingPermissions(server, directory) {
|
|
856
|
+
const base = await apiBase(server);
|
|
857
|
+
const params = new URLSearchParams();
|
|
858
|
+
if (directory) params.set('location[directory]', directory);
|
|
859
|
+
const { status, body } = await makeRequest(buildJsonRequest(`${base}/permission/request?${params}`, 'GET'));
|
|
860
|
+
if (status !== 200) throw new Error(`Failed to list pending permissions: ${status}`);
|
|
861
|
+
const parsed = parseBody(body);
|
|
862
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Reply to a pending permission request. `decision` is 'once' | 'always' |
|
|
866
|
+
// 'reject'. Throws with `err.status` and the server's message when it provides
|
|
867
|
+
// one.
|
|
868
|
+
async function replyToPermission(server, sessionId, requestId, decision) {
|
|
869
|
+
const encodedS = encodeURIComponent(sessionId);
|
|
870
|
+
const encodedR = encodeURIComponent(requestId);
|
|
871
|
+
const base = await apiBase(server);
|
|
872
|
+
const body = JSON.stringify({ decision });
|
|
873
|
+
const { status, body: resBody } = await makeRequest(
|
|
874
|
+
buildJsonRequest(`${base}/session/${encodedS}/permission/${encodedR}/reply`, 'POST', body), body,
|
|
875
|
+
);
|
|
876
|
+
if (status !== 200 && status !== 204) {
|
|
877
|
+
const err = new Error(serverMessage(resBody) || `Failed to reply to permission: ${status}`);
|
|
878
|
+
err.status = status;
|
|
879
|
+
throw err;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
795
883
|
// Sort sessions most-recently-viewed first (`time.viewed`), tie-broken by
|
|
796
884
|
// `time.updated`, then `time.created`. Used for target selection so agentp and
|
|
797
885
|
// ocmux line up with what the TUI last showed.
|
|
@@ -877,6 +965,12 @@ module.exports = {
|
|
|
877
965
|
sendText,
|
|
878
966
|
listenForFinalAnswer,
|
|
879
967
|
respondToQuestion,
|
|
968
|
+
replyToForm,
|
|
969
|
+
listPendingForms,
|
|
970
|
+
listSessionForms,
|
|
971
|
+
listPendingPermissions,
|
|
972
|
+
replyToPermission,
|
|
973
|
+
serverMessage,
|
|
880
974
|
listSessions,
|
|
881
975
|
listProjects,
|
|
882
976
|
getActiveSessions,
|