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.
- package/README.md +64 -11
- package/bin/ocmux +823 -60
- package/docs/specification_v2.md +92 -5
- package/lib/ocmux.js +10 -2
- package/lib/opencode.js +95 -1
- package/lib/tui-registry.js +8 -0
- 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.
|
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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,
|
package/lib/tui-registry.js
CHANGED
|
@@ -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
|
|