agentp 2.1.1 → 2.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -5
- package/bin/agentp +122 -16
- package/bin/ocmux +717 -36
- package/docs/specification_v2.md +85 -5
- package/lib/opencode.js +95 -1
- package/package.json +1 -1
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.
|
|
@@ -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
|
|
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.
|
|
218
|
-
|
|
219
|
-
|
|
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,
|