agentp 2.1.0 → 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.
@@ -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.
@@ -59,6 +63,8 @@ Each instance records:
59
63
  - tmux socket and pane ID;
60
64
  - dedicated/shared mode;
61
65
  - last directory, server, and session;
66
+ - the directory and session currently displayed (so routing can skip a
67
+ respawn that would change nothing);
62
68
  - foreground wrapper PID and current child PID (diagnostic only);
63
69
  - registration/update timestamps.
64
70
 
@@ -95,7 +101,9 @@ a different server, the same pane reconnects to that server.
95
101
  `ocmux tui [--shared] [directory]`:
96
102
 
97
103
  1. Requires `$TMUX` and `$TMUX_PANE`.
98
- 2. Resolves `.ocmux.json`, server, directory, and session.
104
+ 2. Resolves `.ocmux.json`, server, directory, and session. A shared TUI may run
105
+ without a state file, in which case it uses the current directory and the
106
+ default server (`$OCMUX_SERVER` or `http://localhost:4096`).
99
107
  3. Health-checks the server and validates the session, creating one with a
100
108
  model when necessary.
101
109
  4. Marks and registers the current pane.
@@ -127,6 +135,10 @@ The new `ocmux tui` process re-registers the pane and hosts the new OpenCode
127
135
  child. Server-side session execution continues while the old client disconnects.
128
136
  Client-local state—draft prompt, scroll position, dialogs, and tabs—is lost.
129
137
 
138
+ Routing is idempotent: the registry records the directory and session the pane
139
+ currently displays, so a switch to the same project/session is skipped without
140
+ respawning.
141
+
130
142
  Native TUI navigation is temporary inspection and is not observable through
131
143
  the OpenCode HTTP API. The next ocmux selection restores the canonical target.
132
144
 
@@ -142,7 +154,8 @@ or creating one with a model. It creates no tmux session, window, or pane.
142
154
 
143
155
  Registers and hosts a dedicated or shared TUI as described above. `--shared`
144
156
  is valid only for this command. `--server` overrides the project's recorded
145
- server for the launched wrapper. Management modes are:
157
+ server for the launched wrapper; when `--shared` runs without a state file it
158
+ falls back to the default server. Management modes are:
146
159
 
147
160
  - `ocmux tui --list` — prune stale entries and print every live registration;
148
161
  - `ocmux tui [dir] --status` — inspect the dedicated slot for that project;
@@ -156,9 +169,77 @@ destroy the pane. Management modes do not require running inside tmux.
156
169
  ### `ocmux`
157
170
 
158
171
  Opens the current project's session picker. Picking writes that project's
159
- session and refreshes its routed TUI. Session creation, rename/delete,
172
+ session and refreshes its routed TUI. Merely opening the picker also routes the
173
+ applicable TUI to the project's stored session (skipped when it already shows
174
+ it), so starting `ocmux` in a project switches the display even without picking
175
+ a row. When the recorded server is unreachable but the default server answers,
176
+ `ocmux` offers on a TTY to repoint the project to the default and rewrites
177
+ `server`. Session creation, rename/delete,
160
178
  annotations, agent/model choice, broadcast selection, search, and project
161
- inspection operate through OpenCode's API.
179
+ inspection operate through OpenCode's API. Deleting the session currently
180
+ selected adopts the session under the cursor as the new current (state write,
181
+ TUI refresh, and list highlight); deleting any other session leaves the current
182
+ selection unchanged. In broadcast mode `d` deletes just the cursor session
183
+ while `D` (Shift+d) deletes every selected session at once — after confirming,
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.
193
+ Interactive prompts (create/rename/reminder/delete/delete-all) take over the
194
+ status bar and flip its background from brown to light yellow — the same color
195
+ as the session-list pointer — so an active question is immediately visible.
196
+ The broadcast info line (`Broadcast to sessions: …`) never wraps: it holds up
197
+ to 480 characters of names (or the terminal width, whichever is smaller), and
198
+ an oversized selection is truncated from the beginning with a leading `...`.
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.
162
243
 
163
244
  ### `ocmux session <id|title> [dir]`
164
245
 
@@ -174,6 +255,12 @@ By default the switcher only inspects sessions through the routed TUI and never
174
255
  writes another project's state. `--all-projects` lets the outer session picker
175
256
  move to another project and subsequently update that project's state.
176
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
+
177
264
  ### `ocmux list [-l]`
178
265
 
179
266
  Lists configured projects, selected sessions, and display status:
package/lib/ocmux.js CHANGED
@@ -18,7 +18,15 @@ function tuiArgs(server, session, directory) {
18
18
  function switchTui(directory, server, session, executable) {
19
19
  const target = registry.resolve(directory);
20
20
  if (!target) return { ok: false, reason: 'none' };
21
- const result = registry.respawn(target.instance, {
21
+ // Skip the respawn when the routed pane already shows this project/session,
22
+ // so merely opening ocmux does not restart a TUI that is already correct.
23
+ const instance = target.instance;
24
+ if (instance.activeDirectory
25
+ && path.resolve(instance.activeDirectory) === path.resolve(directory)
26
+ && String(instance.activeSession || '') === String(session || '')) {
27
+ return { ok: true, kind: target.kind, instance, unchanged: true };
28
+ }
29
+ const result = registry.respawn(instance, {
22
30
  executable: executable || path.resolve(__dirname, '..', 'bin', 'ocmux'),
23
31
  directory,
24
32
  server,
@@ -28,7 +36,7 @@ function switchTui(directory, server, session, executable) {
28
36
  registry.pruneDead();
29
37
  return { ok: false, reason: 'failed', kind: target.kind, error: result.error };
30
38
  }
31
- return { ok: true, kind: target.kind, instance: target.instance };
39
+ return { ok: true, kind: target.kind, instance };
32
40
  }
33
41
 
34
42
  module.exports = {
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,
@@ -171,6 +171,10 @@ function register({ socket, pane, shared, directory, server, session, pid }) {
171
171
  directory: directory ? path.resolve(directory) : null,
172
172
  server: server || null,
173
173
  session: session || null,
174
+ // What the pane is currently displaying. `respawn()` refreshes these so a
175
+ // caller (ocmux) can skip restarting a TUI that already shows the target.
176
+ activeDirectory: directory ? path.resolve(directory) : null,
177
+ activeSession: session || null,
174
178
  pid: Number(pid) || process.pid,
175
179
  registeredAt: now,
176
180
  updatedAt: now,
@@ -276,6 +280,10 @@ function respawn(instance, { executable, directory, server, session }) {
276
280
  if (result.status !== 0) {
277
281
  return { ok: false, error: (result.stderr || '').trim() || `failed to respawn ${instance.pane}` };
278
282
  }
283
+ updateInstance(instance.id, instance.token, {
284
+ activeDirectory: directory ? path.resolve(directory) : null,
285
+ activeSession: session || null,
286
+ });
279
287
  return { ok: true };
280
288
  }
281
289
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentp",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "Pipe prompts into a running OpenCode TUI session and stream the final answer",
5
5
  "bin": {
6
6
  "agentp": "bin/agentp",