@oddessentials/agent-guild 1.30.1 → 1.32.0
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/bin/agent-guild.mjs +52 -30
- package/bin/native/win32-arm64/agent-guild-report.exe +0 -0
- package/bin/native/win32-x64/agent-guild-report.exe +0 -0
- package/bin/windows/arm64/agent-guild-start.exe +0 -0
- package/bin/windows/x64/agent-guild-start.exe +0 -0
- package/docs/api.md +64 -0
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/package.json +1 -1
- package/src/manager/command-resolver.mjs +4 -2
- package/src/manager/server.mjs +10 -5
- package/src/manager/session-hooks.mjs +2 -1
- package/src/manager/session.mjs +85 -4
- package/src/manager/shell-env.mjs +4 -2
- package/src/mcp/server.mjs +77 -4
- package/web/index.html +1 -1
- package/web/yard/art.json +1 -1
- package/web/yard/model.mjs +133 -0
- package/web/yard/renderer.js +85 -24
- package/web/yard/view.js +20 -1
- package/web/yard/yard.css +1 -0
package/bin/agent-guild.mjs
CHANGED
|
@@ -66,17 +66,25 @@ function portOf(url) {
|
|
|
66
66
|
return parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
/** The manager's health, `{ busy: pid }` when the manager its runtime file names for `url` runs but did not answer in time, or null. */
|
|
70
|
+
async function reach(url, timeoutMs = 1000) {
|
|
71
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
70
72
|
try {
|
|
71
|
-
const res = await fetch(`${url}/api/v1/health`, { signal
|
|
73
|
+
const res = await fetch(`${url}/api/v1/health`, { signal });
|
|
72
74
|
if (!res.ok) return null;
|
|
73
75
|
const body = await res.json();
|
|
74
76
|
return body?.name === 'agent-guild' ? body : null;
|
|
75
77
|
} catch {
|
|
76
|
-
|
|
78
|
+
const owner = signal.aborted ? readRuntimeFile() : null;
|
|
79
|
+
return owner?.url === url && running(owner.pid) ? { busy: owner.pid } : null;
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
async function health(url, timeoutMs = 1000) {
|
|
84
|
+
const found = await reach(url, timeoutMs);
|
|
85
|
+
return found?.busy ? null : found;
|
|
86
|
+
}
|
|
87
|
+
|
|
80
88
|
function pageUrl(url, token) {
|
|
81
89
|
return `${url}/#token=${token}`;
|
|
82
90
|
}
|
|
@@ -114,7 +122,7 @@ async function ensureManager({ port = null } = {}) {
|
|
|
114
122
|
// A pinned port is the endpoint to serve, so it is also the one to check;
|
|
115
123
|
// a manager found anywhere else is not the one asked for.
|
|
116
124
|
const knownUrl = port === null ? baseUrl() : expectedUrl;
|
|
117
|
-
const answering = await
|
|
125
|
+
const answering = await reach(knownUrl);
|
|
118
126
|
if (answering) return { url: knownUrl, started: false, version: answering.version };
|
|
119
127
|
|
|
120
128
|
// Say why no manager can run here rather than start one that fails.
|
|
@@ -167,21 +175,23 @@ async function cmdOpen({ browser, signIn = false }) {
|
|
|
167
175
|
* a successor itself (a manager from before restarts only stops).
|
|
168
176
|
*/
|
|
169
177
|
async function requestShutdown(url, { restart = false } = {}) {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
throw new Error(`${restart ? 'restart' : 'stop'} failed: ${error?.message ?? `HTTP ${res.status}`}`);
|
|
178
|
+
const signal = AbortSignal.timeout(5000);
|
|
179
|
+
try {
|
|
180
|
+
const res = await fetch(`${url}/api/v1/shutdown`, {
|
|
181
|
+
method: 'POST', signal,
|
|
182
|
+
headers: { Authorization: `Bearer ${loadOrCreateToken()}`, 'Content-Type': 'application/json' },
|
|
183
|
+
body: JSON.stringify(restart ? { force: true, restart: true } : { force: true }),
|
|
184
|
+
});
|
|
185
|
+
const body = await res.json().catch(() => ({}));
|
|
186
|
+
signal.throwIfAborted();
|
|
187
|
+
if (!res.ok) throw new Error(`${restart ? 'restart' : 'stop'} failed: ${body.error?.message ?? `HTTP ${res.status}`}`);
|
|
188
|
+
const running = body.running ?? 0;
|
|
189
|
+
if (running > 0) console.log(`Ending ${running} running session(s).`);
|
|
190
|
+
return { running, restart: body.restart === true };
|
|
191
|
+
} catch (err) {
|
|
192
|
+
if (signal.aborted) throw new Error(`the session manager did not answer the ${restart ? 'restart' : 'stop'} request within 5 seconds; its outcome could not be confirmed.`);
|
|
193
|
+
throw err;
|
|
180
194
|
}
|
|
181
|
-
const body = await res.json().catch(() => ({}));
|
|
182
|
-
const running = body.running ?? 0;
|
|
183
|
-
if (running > 0) console.log(`Ending ${running} running session(s).`);
|
|
184
|
-
return { running, restart: body.restart === true };
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
function running(pid) {
|
|
@@ -209,13 +219,13 @@ async function waitForExit(pid, timeoutMs = 15000) {
|
|
|
209
219
|
|
|
210
220
|
async function cmdStop() {
|
|
211
221
|
const url = baseUrl();
|
|
212
|
-
const h = await
|
|
222
|
+
const h = await reach(url);
|
|
213
223
|
if (!h) {
|
|
214
224
|
console.log('Session manager is not running.');
|
|
215
225
|
return;
|
|
216
226
|
}
|
|
217
227
|
await requestShutdown(url);
|
|
218
|
-
if (await waitForExit(h.pid)) {
|
|
228
|
+
if (await waitForExit(h.busy ?? h.pid)) {
|
|
219
229
|
console.log('Session manager stopped.');
|
|
220
230
|
return;
|
|
221
231
|
}
|
|
@@ -225,13 +235,14 @@ async function cmdStop() {
|
|
|
225
235
|
|
|
226
236
|
async function cmdRestart() {
|
|
227
237
|
const url = baseUrl();
|
|
228
|
-
const
|
|
229
|
-
if (!
|
|
238
|
+
const found = await reach(url);
|
|
239
|
+
if (!found) {
|
|
230
240
|
// Nothing to stop: a restart of a stopped manager is a start.
|
|
231
241
|
const { url: started, version } = await ensureManager();
|
|
232
242
|
console.log(`Session manager was not running; started Agent Guild ${version} at ${started}.`);
|
|
233
243
|
return;
|
|
234
244
|
}
|
|
245
|
+
const before = found.busy ? { pid: found.busy } : found;
|
|
235
246
|
const { restart } = await requestShutdown(url, { restart: true });
|
|
236
247
|
if (!restart) {
|
|
237
248
|
// A manager from before restarts stops without starting a successor,
|
|
@@ -239,7 +250,7 @@ async function cmdRestart() {
|
|
|
239
250
|
if (!(await waitForExit(before.pid))) throw new Error('the session manager did not stop, so it could not be restarted.');
|
|
240
251
|
// On the port the old one served, which an ephemeral port setting would otherwise lose.
|
|
241
252
|
const { url: started, version } = await ensureManager({ port: portOf(url) });
|
|
242
|
-
const changed = version !== before.version ? `, now Agent Guild ${version} (was ${before.version})` : '';
|
|
253
|
+
const changed = before.version && version !== before.version ? `, now Agent Guild ${version} (was ${before.version})` : '';
|
|
243
254
|
console.log(`Session manager restarted at ${started}${changed}.`);
|
|
244
255
|
return;
|
|
245
256
|
}
|
|
@@ -250,7 +261,7 @@ async function cmdRestart() {
|
|
|
250
261
|
await new Promise((r) => setTimeout(r, 250));
|
|
251
262
|
const now = await health(url, 500);
|
|
252
263
|
if (now && now.pid !== before.pid) {
|
|
253
|
-
const changed = now.version !== before.version ? `, now Agent Guild ${now.version} (was ${before.version})` : '';
|
|
264
|
+
const changed = before.version && now.version !== before.version ? `, now Agent Guild ${now.version} (was ${before.version})` : '';
|
|
254
265
|
console.log(`Session manager restarted at ${url}${changed}.`);
|
|
255
266
|
return;
|
|
256
267
|
}
|
|
@@ -261,7 +272,12 @@ async function cmdRestart() {
|
|
|
261
272
|
|
|
262
273
|
async function cmdStatus() {
|
|
263
274
|
const url = baseUrl();
|
|
264
|
-
const h = await
|
|
275
|
+
const h = await reach(url);
|
|
276
|
+
if (h?.busy) {
|
|
277
|
+
console.log(`Session manager running at ${url} (pid ${h.busy}) did not answer in time.`);
|
|
278
|
+
process.exitCode = 1;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
265
281
|
if (!h) {
|
|
266
282
|
console.log('Session manager is not running.');
|
|
267
283
|
// With no manager to ask, the boot service is read from systemd directly.
|
|
@@ -294,15 +310,21 @@ async function cmdStatus() {
|
|
|
294
310
|
|
|
295
311
|
async function cmdFireworks() {
|
|
296
312
|
const url = baseUrl();
|
|
297
|
-
if (!(await
|
|
313
|
+
if (!(await reach(url))) {
|
|
298
314
|
console.log('Session manager is not running.');
|
|
299
315
|
process.exitCode = 3;
|
|
300
316
|
return;
|
|
301
317
|
}
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
318
|
+
const signal = AbortSignal.timeout(5000);
|
|
319
|
+
try {
|
|
320
|
+
const res = await fetch(`${url}/api/v1/yard/fireworks`, { method: 'POST', signal, headers: { Authorization: `Bearer ${loadOrCreateToken()}` } });
|
|
321
|
+
if (!res.ok) throw new Error(`the session manager answered ${res.status}.`);
|
|
322
|
+
const { pages } = await res.json();
|
|
323
|
+
console.log(pages ? `Fireworks sent to ${pages} open page(s); every Yard on show lights up.` : 'No page is open to see the fireworks.');
|
|
324
|
+
} catch (err) {
|
|
325
|
+
if (signal.aborted) throw new Error('the session manager did not answer the fireworks request within 5 seconds; delivery could not be confirmed.');
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
306
328
|
}
|
|
307
329
|
|
|
308
330
|
const MIN_NODE_MAJOR = 22;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/docs/api.md
CHANGED
|
@@ -430,6 +430,8 @@ files. When the tool cannot be asked, `count` is null, `scopes` is empty and
|
|
|
430
430
|
"toolSessionId": "581893e5-a93d-5e49-968b-1c1c277d3255",
|
|
431
431
|
"context": { "compactions": 2, "resets": 0 },
|
|
432
432
|
"reply": { "id": 3 },
|
|
433
|
+
"replyCursor": "00000000-0000-4000-8000-000000000001:3",
|
|
434
|
+
"inputInstance": "00000000-0000-4000-8000-000000000002",
|
|
433
435
|
"reporting": { "state": "active", "reason": null },
|
|
434
436
|
"agents": [ /* Agent */ ],
|
|
435
437
|
"shells": [{ "id": "shell-3", "kind": "shell" }]
|
|
@@ -468,10 +470,17 @@ files. When the tool cannot be asked, `count` is null, `scopes` is empty and
|
|
|
468
470
|
in a new session (`/clear`, `/new`) since the card started.
|
|
469
471
|
* `reply` names the answer that ended the agent's last turn by `id`, or is
|
|
470
472
|
null. Its text is read with `GET /sessions/:id/reply`.
|
|
473
|
+
* `replyCursor` is an opaque position after the latest recorded answer, or
|
|
474
|
+
before the first answer when none exists. An empty turn clears `reply`
|
|
475
|
+
without changing this cursor or erasing history.
|
|
471
476
|
* `reporting` says whether the tool's reporting hooks work, or is null for a
|
|
472
477
|
tool Agent Guild supplies no hooks to. `state` is `pending`, `active`,
|
|
473
478
|
`unavailable`, `setup_required` or `unsupported`; `reason` explains every
|
|
474
479
|
state but `active`.
|
|
480
|
+
* `inputInstance` identifies the current terminal attachment for keyed input.
|
|
481
|
+
It changes on reattachment, after every 4,096 new keys, and on session
|
|
482
|
+
replacement, including restoration after manager restart. It grants no
|
|
483
|
+
input authorization.
|
|
475
484
|
* `shells` lists the shell commands the tool is running, each `{ id, kind }`
|
|
476
485
|
with `kind` `shell` or `monitor`. Nothing about the command itself is
|
|
477
486
|
included.
|
|
@@ -703,6 +712,7 @@ All paths are under `/api/v1`.
|
|
|
703
712
|
| POST | `/sessions` | `{ providerId, account?, shell?, cwd?, cols?, rows?, name?, args?, resume? }` | `201 { session }`. 429 `too_many_sessions` at the session limit. |
|
|
704
713
|
| GET | `/sessions/:id` | | `{ session }` |
|
|
705
714
|
| GET | `/sessions/:id/reply` | | `{ reply: { id, text, spoken } }`, or `{ reply: null }`: the whole text of the answer `session.reply` names, and `spoken`, the words the page's Read aloud speaks for it: a line per paragraph line, heading, list item and table row, each fenced code block as `Code omitted.` |
|
|
715
|
+
| GET | `/sessions/:id/replies?after=&limit=` | | `{ replies: [{ id, text, cursor }], nextCursor, hasMore, gap, firstCursor }`: retained answers in recorded order. `after` is an exclusive cursor; omit it to start at the oldest retained answer. `limit` defaults to 20, from 1 to 100. |
|
|
706
716
|
| PATCH | `/sessions/:id` | `{ name }` | `{ session }`. |
|
|
707
717
|
| POST | `/sessions/:id/stop` | | `{ session }`: ends the process. The session stays listed as exited. |
|
|
708
718
|
| POST | `/sessions/:id/reattach` | | `{ session }`: runs an exited tmux or herdr session's multiplexer client again, keeping the session's id and screen. 409 `multiplexer_session_gone` while `multiplexer.reattachable` is false. |
|
|
@@ -738,6 +748,23 @@ Request bodies are limited to 64 KiB, except `PUT /notes`,
|
|
|
738
748
|
1 MiB, and `POST /sessions/:id/hook`, which accepts 32 MiB and checks the token
|
|
739
749
|
before reading the body. Messages a client sends on a WebSocket are limited to 1 MiB.
|
|
740
750
|
|
|
751
|
+
### Reply history
|
|
752
|
+
|
|
753
|
+
Reads are independent and never consume answers. Save each answer's `cursor`
|
|
754
|
+
after handling it; `nextCursor` is the last returned cursor, or the starting
|
|
755
|
+
position for an empty result. `hasMore` says whether more answers existed at
|
|
756
|
+
read time. `firstCursor` names the oldest retained answer, or is null.
|
|
757
|
+
`gap` is null normally, `evicted` when earlier requested answers were dropped,
|
|
758
|
+
or `reset` when the cursor belongs to another session instance. A reset read
|
|
759
|
+
starts at the oldest retained answer. Malformed cursors, positions ahead of
|
|
760
|
+
the current instance, and invalid limits return 400.
|
|
761
|
+
|
|
762
|
+
Each session retains at most 100 complete answers and 16 MiB of UTF-8 text,
|
|
763
|
+
evicting oldest answers first. The newest answer is always kept whole, even
|
|
764
|
+
if it alone exceeds 16 MiB. History remains after exit, but is lost on session
|
|
765
|
+
removal or manager restart. A replacement session has a new cursor identity,
|
|
766
|
+
even when its session id is reused. A removed session returns 404.
|
|
767
|
+
|
|
741
768
|
## WebSockets
|
|
742
769
|
|
|
743
770
|
Messages in both directions are JSON text frames.
|
|
@@ -788,6 +815,7 @@ Server to client:
|
|
|
788
815
|
| `{ type: "data", data }` | Terminal output, in order, directly after the snapshot. |
|
|
789
816
|
| `{ type: "resize", cols, rows, generation, owner }` | The size changed or was claimed, or this page's own `resize` was refused or changed nothing. |
|
|
790
817
|
| `{ type: "exit", exitCode, signal }` | The process ended. Also sent after the snapshot when attaching to an exited session. |
|
|
818
|
+
| `{ type: "input-result", idempotencyKey, outcome, code? }` | A receipt for keyed input, sent only to its requesting connection. `outcome` is `accepted`, `rejected` or `uncertain`; see below. |
|
|
791
819
|
| `{ type: "removed" }` | The session was removed. The socket then closes with code 4410. |
|
|
792
820
|
| `{ type: "pong" }` | The answer to a client's `ping`. |
|
|
793
821
|
| `{ type: "error", code: "not_found", message }` | The session does not exist. The socket then closes with code 4404. |
|
|
@@ -797,10 +825,46 @@ Client to server:
|
|
|
797
825
|
| Message | Meaning |
|
|
798
826
|
| --- | --- |
|
|
799
827
|
| `{ type: "input", data }` | Keystrokes or pasted text, as a terminal would send them. Send a bracketed paste as one message: in a message that starts with `ESC[200~` and ends with `ESC[201~`, those markers inside the text reach the program with their ESC replaced by `␛`, so the text cannot end the paste. |
|
|
828
|
+
| `{ type: "input", data, idempotencyKey }` | At most one write attempt per key. The key is the original `inputInstance`, a colon, and a caller-chosen ID of 1–128 ASCII letters, digits, underscores or hyphens. Save the full key before sending and reuse it unchanged for retries. |
|
|
800
829
|
| `{ type: "claim", cols, rows }` | Take the size for this page and set it. Needs `page`. |
|
|
801
830
|
| `{ type: "resize", generation, cols, rows }` | Resize the terminal. Refused unless this page owns the size and `generation` is the latest. |
|
|
802
831
|
| `{ type: "ping" }` | Ask for a `pong`, to check that the link is alive. |
|
|
803
832
|
|
|
833
|
+
### Keyed terminal input
|
|
834
|
+
|
|
835
|
+
Only send keyed input after a snapshot supplies `session.inputInstance`;
|
|
836
|
+
older managers do not support it. The manager records each valid request
|
|
837
|
+
before attempting its write. Identical keys and `data` return the stored
|
|
838
|
+
outcome, including after exit. Changed `data` returns `key_conflict` without
|
|
839
|
+
altering the original receipt. Comparison includes all characters, including
|
|
840
|
+
Enter, before bracketed-paste sanitization.
|
|
841
|
+
|
|
842
|
+
| Outcome | Meaning |
|
|
843
|
+
| --- | --- |
|
|
844
|
+
| `accepted` | The terminal writer returned without a synchronous error. Input may still be buffered. This does not confirm consumption or command execution. |
|
|
845
|
+
| `rejected` | This request made no write attempt. `code` identifies `invalid_key`, `invalid_input`, `key_conflict`, `stale_instance` or `not_writable` (exited/disposed session or empty input). A rejection does not undo a previous request. |
|
|
846
|
+
| `uncertain` | `write_uncertain`: a write may have started. The receipt is kept and retries never attempt another write. |
|
|
847
|
+
|
|
848
|
+
Receipts contain the key, input hash and outcome, not raw input. A Session
|
|
849
|
+
keeps them in two generations of up to 4,096 receipts, through exit and
|
|
850
|
+
reattachment. The receipt that fills a generation retires the current
|
|
851
|
+
`inputInstance`: a new one is issued, the full generation becomes the
|
|
852
|
+
previous one, and the generation before it is discarded. Keys in either
|
|
853
|
+
kept generation return their receipts. Every other key whose instance is
|
|
854
|
+
not current, including a discarded or never-recorded one, is rejected as
|
|
855
|
+
`stale_instance` without a write, and its original outcome cannot be
|
|
856
|
+
established. Reattachment also changes `inputInstance` and keeps both
|
|
857
|
+
generations. After `stale_instance` on a new request, read the session again
|
|
858
|
+
and form new keys from its current `inputInstance`. Removal or manager
|
|
859
|
+
restart loses the receipts. Replacement instances reject old keys, even if the session id is
|
|
860
|
+
reused; such a rejection cannot establish the old instance's outcome.
|
|
861
|
+
|
|
862
|
+
If an acknowledgement is lost, reconnect and retry the identical input with
|
|
863
|
+
the original full key. Never substitute a new instance prefix or key, or
|
|
864
|
+
fall back to unkeyed input to resolve uncertainty. The guarantee is **at most
|
|
865
|
+
one write attempt per keyed request within its valid session instance**,
|
|
866
|
+
not exactly-once execution. Unkeyed input has no receipt or retry protection.
|
|
867
|
+
|
|
804
868
|
### Terminal queries: clients must not answer
|
|
805
869
|
|
|
806
870
|
Programs ask the terminal questions by writing escape sequences, for
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oddessentials/agent-guild",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.0",
|
|
4
4
|
"description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
|
|
5
5
|
"mcpName": "ai.oddessentials/agent-guild",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
@@ -184,17 +184,19 @@ export function npmSpawnSpec(npm, args, env = process.env, platform = process.pl
|
|
|
184
184
|
function runInOwnGroup(file, args, { env, cwd, timeoutMs, input, maxBuffer, signal }) {
|
|
185
185
|
return new Promise((resolve, reject) => {
|
|
186
186
|
let child;
|
|
187
|
+
// Registered before the spawn: a signal that lands once the command runs must find it.
|
|
188
|
+
const end = () => killGroup(child?.pid);
|
|
189
|
+
const release = endWithProcess(end);
|
|
187
190
|
try {
|
|
188
191
|
child = spawn(file, args, { env, cwd, detached: true });
|
|
189
192
|
} catch (err) {
|
|
193
|
+
release();
|
|
190
194
|
return reject(Object.assign(err, { stdout: '', stderr: '' }));
|
|
191
195
|
}
|
|
192
196
|
let stdout = '';
|
|
193
197
|
let stderr = '';
|
|
194
198
|
let timedOut = false;
|
|
195
199
|
let settled = false;
|
|
196
|
-
const end = () => killGroup(child.pid);
|
|
197
|
-
const release = endWithProcess(end);
|
|
198
200
|
const finish = (err) => {
|
|
199
201
|
if (settled) return;
|
|
200
202
|
settled = true;
|
package/src/manager/server.mjs
CHANGED
|
@@ -330,9 +330,6 @@ export function createManagerServer({
|
|
|
330
330
|
return sendJson(res, 200, { ok: true, name: 'agent-guild', version, pid: process.pid });
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
-
// Agent, model and tool-session reports may authenticate with the
|
|
334
|
-
// per-session report token that the manager injects into each tool's
|
|
335
|
-
// environment.
|
|
336
333
|
const reportMatch = route.match(/^\/sessions\/([a-f0-9]+)\/(agents|model|tool-session|context|reporting|shells|docker|reply|hook)$/);
|
|
337
334
|
if (reportMatch && method === 'POST') {
|
|
338
335
|
const [, id, kind] = reportMatch;
|
|
@@ -627,7 +624,7 @@ export function createManagerServer({
|
|
|
627
624
|
return undefined;
|
|
628
625
|
}
|
|
629
626
|
|
|
630
|
-
const sessionMatch = route.match(/^\/sessions\/([a-f0-9]+)(\/stop|\/reattach|\/reply)?$/);
|
|
627
|
+
const sessionMatch = route.match(/^\/sessions\/([a-f0-9]+)(\/stop|\/reattach|\/reply|\/replies)?$/);
|
|
631
628
|
if (sessionMatch) {
|
|
632
629
|
const [, id, action] = sessionMatch;
|
|
633
630
|
if (!action && method === 'GET') {
|
|
@@ -639,6 +636,12 @@ export function createManagerServer({
|
|
|
639
636
|
const { reply } = manager.get(id);
|
|
640
637
|
return sendJson(res, 200, { reply: reply && { ...reply, spoken: spokenText(reply.text) } });
|
|
641
638
|
}
|
|
639
|
+
if (action === '/replies' && method === 'GET') {
|
|
640
|
+
const after = url.searchParams.get('after') ?? undefined;
|
|
641
|
+
const rawLimit = url.searchParams.get('limit');
|
|
642
|
+
if (rawLimit !== null && !/^[1-9][0-9]*$/.test(rawLimit)) throw new HttpError(400, 'limit must be an integer from 1 to 100', 'bad_request');
|
|
643
|
+
return sendJson(res, 200, manager.get(id).readReplies({ after, limit: rawLimit === null ? undefined : Number(rawLimit) }));
|
|
644
|
+
}
|
|
642
645
|
if (!action && method === 'PATCH') {
|
|
643
646
|
const body = await readJsonBody(req);
|
|
644
647
|
const session = manager.get(id);
|
|
@@ -836,7 +839,9 @@ export function createManagerServer({
|
|
|
836
839
|
if (isBinary || ws.revoked) return;
|
|
837
840
|
let msg;
|
|
838
841
|
try { msg = JSON.parse(raw.toString('utf8')); } catch { return; }
|
|
839
|
-
if (msg.type === 'input' &&
|
|
842
|
+
if (msg.type === 'input' && msg.idempotencyKey !== undefined) {
|
|
843
|
+
safeSend(ws, { type: 'input-result', idempotencyKey: msg.idempotencyKey, ...session.inputOnce(msg.data, msg.idempotencyKey) });
|
|
844
|
+
} else if (msg.type === 'input' && typeof msg.data === 'string') session.input(msg.data);
|
|
840
845
|
else if (msg.type === 'claim') session.claimSize(page, msg.cols, msg.rows);
|
|
841
846
|
else if (msg.type === 'resize') session.resizeFrom(page, msg.generation, msg.cols, msg.rows);
|
|
842
847
|
else if (msg.type === 'ping') safeSend(ws, { type: 'pong' });
|
|
@@ -214,6 +214,8 @@ export function codexHooksList(resolved, args, { env, platform = process.platfor
|
|
|
214
214
|
const stopped = () => finish(new Error('The manager is stopping'));
|
|
215
215
|
const timer = setTimeout(() => finish(new Error('Codex did not answer in time')), timeoutMs);
|
|
216
216
|
timer.unref?.();
|
|
217
|
+
// Registered before the spawn: a signal that lands once Codex runs must find it.
|
|
218
|
+
if (platform !== 'win32') release = endWithProcess(() => killGroup(child?.pid));
|
|
217
219
|
try {
|
|
218
220
|
const verbatim = typeof spec.args === 'string';
|
|
219
221
|
child = spawn(spec.file, verbatim ? [spec.args] : spec.args, {
|
|
@@ -222,7 +224,6 @@ export function codexHooksList(resolved, args, { env, platform = process.platfor
|
|
|
222
224
|
} catch (err) {
|
|
223
225
|
return finish(err);
|
|
224
226
|
}
|
|
225
|
-
if (platform !== 'win32') release = endWithProcess(() => killGroup(child.pid));
|
|
226
227
|
signal?.addEventListener('abort', stopped, { once: true });
|
|
227
228
|
let stderr = '';
|
|
228
229
|
let buffer = '';
|
package/src/manager/session.mjs
CHANGED
|
@@ -28,6 +28,9 @@ const SHELL_EVENTS = new Set(['start', 'waiting', 'asked', 'background', 'end',
|
|
|
28
28
|
const SHELL_KINDS = new Set(['shell', 'monitor']);
|
|
29
29
|
const MAX_SHELLS = 256;
|
|
30
30
|
const MAX_ENDED_TASKS = 256;
|
|
31
|
+
const MAX_REPLIES = 100;
|
|
32
|
+
const MAX_REPLY_BYTES = 16 * 1024 * 1024;
|
|
33
|
+
const MAX_INPUT_RECEIPTS = 4096;
|
|
31
34
|
// Keys and reports a terminal sends as escape sequences: X10 mouse reports (three raw bytes after ESC [ M), other CSI
|
|
32
35
|
// and SS3 sequences, OSC and DCS strings (to their end, or the next ESC, so a large paste is scanned once), and ESC
|
|
33
36
|
// with one character (Alt+key). Bracketed paste markers are CSI, so pasted text is kept.
|
|
@@ -136,6 +139,12 @@ export class Session extends EventEmitter {
|
|
|
136
139
|
this.toolSessionId = null;
|
|
137
140
|
this.reply = null;
|
|
138
141
|
this._replies = 0;
|
|
142
|
+
this._replyHistory = [];
|
|
143
|
+
this._replyBytes = 0;
|
|
144
|
+
this._replyInstance = crypto.randomUUID();
|
|
145
|
+
this.inputInstance = crypto.randomUUID();
|
|
146
|
+
this._inputReceipts = new Map();
|
|
147
|
+
this._previousReceipts = new Map();
|
|
139
148
|
this.context = { compactions: count(opts.context?.compactions), resets: count(opts.context?.resets) };
|
|
140
149
|
this.compactionRepeatMs = opts.compactionRepeatMs ?? 2000;
|
|
141
150
|
this._compactionKeys = [];
|
|
@@ -226,6 +235,7 @@ export class Session extends EventEmitter {
|
|
|
226
235
|
reattach(spawnSpec, { cwd = this.cwd } = {}) {
|
|
227
236
|
if (this.disposed || this.status !== 'exited') throw Object.assign(new Error('only an exited session can be reattached'), { status: 409 });
|
|
228
237
|
this._start(spawnSpec, cwd);
|
|
238
|
+
this.inputInstance = crypto.randomUUID();
|
|
229
239
|
Object.assign(this, { status: 'running', exitCode: null, signal: null, exitedAt: null, activity: 'quiet', startedAt: new Date().toISOString() });
|
|
230
240
|
this.exited = new Promise((resolve) => { this._resolveExited = resolve; });
|
|
231
241
|
this._changed();
|
|
@@ -341,12 +351,17 @@ export class Session extends EventEmitter {
|
|
|
341
351
|
}
|
|
342
352
|
|
|
343
353
|
write(data) {
|
|
344
|
-
if (this.disposed || this.status !== 'running' || typeof data !== 'string' || data.length === 0) return;
|
|
345
|
-
try {
|
|
354
|
+
if (this.disposed || this.status !== 'running' || typeof data !== 'string' || data.length === 0) return 'rejected';
|
|
355
|
+
try {
|
|
356
|
+
this.pty.write(data);
|
|
357
|
+
return 'accepted';
|
|
358
|
+
} catch {
|
|
359
|
+
return 'uncertain';
|
|
360
|
+
}
|
|
346
361
|
}
|
|
347
362
|
|
|
348
363
|
input(data) {
|
|
349
|
-
this.write(withoutInnerPasteMarkers(data));
|
|
364
|
+
const outcome = this.write(withoutInnerPasteMarkers(data));
|
|
350
365
|
if (this.reporting?.state === 'pending' && !this._reportingTimer && typeof data === 'string' && this._sendsTypedLine(data)) {
|
|
351
366
|
this._reportingTimer = setTimeout(() => {
|
|
352
367
|
if (this.reporting?.state !== 'pending' || this.status !== 'running') return;
|
|
@@ -358,6 +373,32 @@ export class Session extends EventEmitter {
|
|
|
358
373
|
}, this.reportingTimeoutMs);
|
|
359
374
|
this._reportingTimer.unref?.();
|
|
360
375
|
}
|
|
376
|
+
return outcome;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
inputOnce(data, key) {
|
|
380
|
+
const match = typeof key === 'string' && key.match(/^([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}):([A-Za-z0-9_-]{1,128})$/);
|
|
381
|
+
if (!match) return { outcome: 'rejected', code: 'invalid_key' };
|
|
382
|
+
if (typeof data !== 'string') return { outcome: 'rejected', code: 'invalid_input' };
|
|
383
|
+
const fingerprint = crypto.createHash('sha256').update(data, 'utf16le').digest('hex');
|
|
384
|
+
const previous = this._inputReceipts.get(key) ?? this._previousReceipts.get(key);
|
|
385
|
+
if (previous) return previous.fingerprint === fingerprint ? { ...previous.result } : { outcome: 'rejected', code: 'key_conflict' };
|
|
386
|
+
if (match[1] !== this.inputInstance) return { outcome: 'rejected', code: 'stale_instance' };
|
|
387
|
+
const receipt = { fingerprint, result: { outcome: 'uncertain', code: 'write_uncertain' } };
|
|
388
|
+
this._inputReceipts.set(key, receipt);
|
|
389
|
+
try {
|
|
390
|
+
const outcome = this.input(data);
|
|
391
|
+
if (outcome === 'accepted') receipt.result = { outcome };
|
|
392
|
+
else if (outcome === 'rejected') receipt.result = { outcome, code: 'not_writable' };
|
|
393
|
+
} catch {}
|
|
394
|
+
// A full generation retires its instance, so a key dropped with the generation before it can only be stale.
|
|
395
|
+
if (this._inputReceipts.size >= MAX_INPUT_RECEIPTS) {
|
|
396
|
+
this._previousReceipts = this._inputReceipts;
|
|
397
|
+
this._inputReceipts = new Map();
|
|
398
|
+
this.inputInstance = crypto.randomUUID();
|
|
399
|
+
this._changed();
|
|
400
|
+
}
|
|
401
|
+
return { ...receipt.result };
|
|
361
402
|
}
|
|
362
403
|
|
|
363
404
|
/**
|
|
@@ -788,7 +829,6 @@ export class Session extends EventEmitter {
|
|
|
788
829
|
return this.context;
|
|
789
830
|
}
|
|
790
831
|
|
|
791
|
-
/** The text of the agent's last answer, or null when its last turn ended without one. */
|
|
792
832
|
reportReply(report) {
|
|
793
833
|
if (!report || typeof report !== 'object') throw badRequest('reply report must be an object');
|
|
794
834
|
if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
|
|
@@ -796,10 +836,49 @@ export class Session extends EventEmitter {
|
|
|
796
836
|
this._reportingHeard();
|
|
797
837
|
const text = report.reply?.trim() || null;
|
|
798
838
|
this.reply = text ? { id: ++this._replies, text } : null;
|
|
839
|
+
if (this.reply) {
|
|
840
|
+
const bytes = Buffer.byteLength(text);
|
|
841
|
+
this._replyHistory.push({ ...this.reply, bytes });
|
|
842
|
+
this._replyBytes += bytes;
|
|
843
|
+
while (this._replyHistory.length > 1 && (this._replyHistory.length > MAX_REPLIES || this._replyBytes > MAX_REPLY_BYTES)) {
|
|
844
|
+
this._replyBytes -= this._replyHistory.shift().bytes;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
799
847
|
this._changed();
|
|
800
848
|
return this.reply && { id: this.reply.id };
|
|
801
849
|
}
|
|
802
850
|
|
|
851
|
+
replyCursor(id = this._replies) {
|
|
852
|
+
return `${this._replyInstance}:${id}`;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
readReplies({ after, limit = 20 } = {}) {
|
|
856
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_REPLIES) throw badRequest('limit must be an integer from 1 to 100');
|
|
857
|
+
let id = 0;
|
|
858
|
+
let gap = null;
|
|
859
|
+
if (after !== undefined) {
|
|
860
|
+
const match = typeof after === 'string' && after.match(/^([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}):(0|[1-9][0-9]*)$/);
|
|
861
|
+
if (!match || !Number.isSafeInteger(Number(match[2]))) throw badRequest('after must be a reply cursor');
|
|
862
|
+
if (match[1] !== this._replyInstance) gap = 'reset';
|
|
863
|
+
else {
|
|
864
|
+
id = Number(match[2]);
|
|
865
|
+
if (id > this._replies) throw badRequest('after is ahead of this session');
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const first = this._replyHistory[0]?.id ?? this._replies + 1;
|
|
869
|
+
if (!gap && id < first - 1) gap = 'evicted';
|
|
870
|
+
const replies = this._replyHistory.filter((reply) => reply.id > id).slice(0, limit)
|
|
871
|
+
.map(({ id, text }) => ({ id, text, cursor: this.replyCursor(id) }));
|
|
872
|
+
const last = replies.at(-1)?.id ?? id;
|
|
873
|
+
return {
|
|
874
|
+
replies,
|
|
875
|
+
nextCursor: this.replyCursor(last),
|
|
876
|
+
hasMore: last < this._replies,
|
|
877
|
+
gap,
|
|
878
|
+
firstCursor: this._replyHistory.length ? this.replyCursor(first) : null,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
803
882
|
reportHello() {
|
|
804
883
|
if (this.status !== 'running') throw Object.assign(new Error('session has exited'), { status: 409 });
|
|
805
884
|
this._reportingHeard();
|
|
@@ -878,6 +957,8 @@ export class Session extends EventEmitter {
|
|
|
878
957
|
toolSessionId: this.toolSessionId,
|
|
879
958
|
context: { ...this.context },
|
|
880
959
|
reply: this.reply && { id: this.reply.id },
|
|
960
|
+
replyCursor: this.replyCursor(),
|
|
961
|
+
inputInstance: this.inputInstance,
|
|
881
962
|
reporting: this.reporting,
|
|
882
963
|
agents: [...this.agents.values()],
|
|
883
964
|
shells: [...this.shells.values()].filter((s) => s.visible).map((s) => ({ id: s.id, kind: s.kind })),
|
|
@@ -78,6 +78,9 @@ export function probeLoginShell({ shell, env = process.env, timeoutMs = LOGIN_SH
|
|
|
78
78
|
if (process.platform === 'win32' || !shell) return Promise.resolve({ vars: null, failure: null });
|
|
79
79
|
return new Promise((resolve) => {
|
|
80
80
|
let child;
|
|
81
|
+
// Registered before the spawn: a signal that lands once the shell runs must find it.
|
|
82
|
+
const end = () => killGroup(child?.pid);
|
|
83
|
+
const release = endWithProcess(end);
|
|
81
84
|
try {
|
|
82
85
|
child = spawn(shell, LOGIN_SHELL_ARGS, {
|
|
83
86
|
detached: true,
|
|
@@ -85,12 +88,11 @@ export function probeLoginShell({ shell, env = process.env, timeoutMs = LOGIN_SH
|
|
|
85
88
|
env: { ...env, AGENT_GUILD_RESOLVING_ENV: '1' },
|
|
86
89
|
});
|
|
87
90
|
} catch (err) {
|
|
91
|
+
release();
|
|
88
92
|
return resolve({ vars: null, failure: `could not start (${err.code || err.message})` });
|
|
89
93
|
}
|
|
90
94
|
let stdout = '';
|
|
91
95
|
let settled = false;
|
|
92
|
-
const end = () => killGroup(child.pid);
|
|
93
|
-
const release = endWithProcess(end);
|
|
94
96
|
const finish = (result) => {
|
|
95
97
|
if (settled) return;
|
|
96
98
|
settled = true;
|