agentp 2.1.1 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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. Writes use a temporary file plus atomic rename.
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.
@@ -79,7 +83,8 @@ There is no `--global` alias.
79
83
 
80
84
  ## 4. TUI routing
81
85
 
82
- When `ocmux` selects or inspects a session, it resolves the display in order:
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:
83
88
 
84
89
  1. live TUI dedicated to the target directory;
85
90
  2. the one live shared TUI;
@@ -178,6 +183,14 @@ TUI refresh, and list highlight); deleting any other session leaves the current
178
183
  selection unchanged. In broadcast mode `d` deletes just the cursor session
179
184
  while `D` (Shift+d) deletes every selected session at once — after confirming,
180
185
  broadcast mode ends and the row under the cursor becomes the new current.
186
+ In broadcast mode `Enter` switches to the cursor session and ends broadcast,
187
+ while `ESC`/`q` cancel it; every exit clears the persisted `broadcast` list, as
188
+ does picking any session in the normal switcher, so a stale selection never
189
+ survives to keep `agentp` broadcasting. On start the picker adopts a stored
190
+ `broadcast` only when at least two of its ids still name listed sessions; it then
191
+ opens in broadcast mode (re-anchoring on a selected session when the stored
192
+ `session` is not in the list), and otherwise clears the invalid list before
193
+ showing the normal state.
181
194
  Interactive prompts (create/rename/reminder/delete/delete-all) take over the
182
195
  status bar and flip its background from brown to light yellow — the same color
183
196
  as the session-list pointer — so an active question is immediately visible.
@@ -185,6 +198,59 @@ The broadcast info line (`Broadcast to sessions: …`) never wraps: it holds up
185
198
  to 480 characters of names (or the terminal width, whichever is smaller), and
186
199
  an oversized selection is truncated from the beginning with a leading `...`.
187
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
+
210
+ **Pending-answer awareness.** While the picker is open it re-polls the project
211
+ every 2 seconds (`GET /api/form` and `GET /api/permission/request`, both
212
+ location-scoped with the `location[directory]=…` deepObject parameter) and
213
+ tracks which session ids currently await an agent question (a form) or a
214
+ permission reply. Sessions carry two independent marker columns — ❓ (question)
215
+ and 🔒 (permission) — each a fixed 2-column cell whose placeholder keeps rows
216
+ aligned when a session has no marker. A single 🔔 leads the status bar (forced
217
+ left-aligned) whenever any listed session is waiting, and the cursor session's
218
+ marker columns are pinned to the info panel's top-right corner (the Title line
219
+ is padded out to `cols - cells.length`).
220
+ Failures of the poll degrade silently: the previous sets stay and the next poll
221
+ retries.
222
+
223
+ **Answer mode (`A`).** When the cursor session has a pending form, `A` fetches
224
+ `GET /api/session/{id}/form` and switches to a dedicated answer screen. It
225
+ renders each visible field as a header plus its option rows; multi-select
226
+ fields toggle extra entries, boolean fields render Yes/No, choice fields with
227
+ `custom` get a "Type your own answer" row, and option-less (free-text/numeric)
228
+ fields open the status-bar input directly. Fields with `hidden` or `when`
229
+ conditions are filtered live over the answers chosen so far (a gated group
230
+ appears the moment its gate question is answered). The final `Submit answers
231
+ (n/m answered)` row posts the keyed answer map
232
+ (`POST /api/session/{id}/form/{formID}/reply`, `{answer: {fieldKey: value}}`).
233
+ On success the remaining forms are re-fetched: the next pending form is
234
+ answered in turn, or the picker returns to the session list with a
235
+ `✔ Answers submitted.` notice. A 400 rejection shows the server's message
236
+ (`message` or `error.message` from the body) in the status bar and stays in
237
+ answer mode for correction; a 409 (already settled elsewhere) reports the
238
+ server message and returns to the list. `h` toggles the answer-mode help,
239
+ `q`/`ESC` cancels without answering, `Ctrl+C` exits.
240
+
241
+ **Permission answering (`P`).** Permissions are answered from a dedicated
242
+ screen opened with `P` on a session with a pending permission request (🔒
243
+ marker). It re-reads the location-scoped `GET /api/permission/request` list
244
+ (currently answered requests settle out of it) and filters to the cursor
245
+ session. Each request is shown with its action, resources and optional message;
246
+ one key answers it — `o`/`1` allow once, `a` allow always, `r` reject — posting
247
+ `POST /api/session/{sessionID}/permission/{requestID}/reply` with
248
+ `{decision: 'once' | 'always' | 'reject'}`. Success drops the request and
249
+ advances to the next; after the last it returns to the session list with a
250
+ `✔ Permission answered.` notice. A rejected reply (400) shows the server's
251
+ message in the status bar and stays; `h` toggles a help screen, `q`/`ESC` leaves
252
+ everything pending, `Ctrl+C` exits.
253
+
188
254
  ### `ocmux session <id|title> [dir]`
189
255
 
190
256
  Non-interactively writes the selected session and refreshes its routed TUI.
@@ -199,6 +265,12 @@ By default the switcher only inspects sessions through the routed TUI and never
199
265
  writes another project's state. `--all-projects` lets the outer session picker
200
266
  move to another project and subsequently update that project's state.
201
267
 
268
+ The switcher shows the same pending-answer awareness as the session picker:
269
+ session rows carry their own ❓/🔒 marker columns, and a folded project row
270
+ shows them aggregated over all of its sessions' pending forms/permissions
271
+ (fetched once per project when the switcher opens, from each project's recorded
272
+ server).
273
+
202
274
  ### `ocmux list [-l]`
203
275
 
204
276
  Lists configured projects, selected sessions, and display status:
@@ -214,9 +286,17 @@ The old managed-window `kill` and `resurrect` commands do not exist.
214
286
  ## 8. `agentp`
215
287
 
216
288
  `agentp` resolves project/session/server from explicit options and the nearest
217
- state file, then sends directly to the session API. It does not focus, move, or
218
- refresh a TUI; TUI routing is an explicit consequence of ocmux session
219
- selection, not prompt submission.
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.
220
300
 
221
301
  ## 9. Failure behavior
222
302
 
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "Pipe prompts into a running OpenCode TUI session and stream the final answer",
5
5
  "bin": {
6
6
  "agentp": "bin/agentp",