@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.
@@ -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
- async function health(url, timeoutMs = 1000) {
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: AbortSignal.timeout(timeoutMs) });
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
- return null;
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 health(knownUrl);
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
- // `stop` and `restart` are documented as ending every session, so they do
171
- // not ask; the web page's buttons are the ones that confirm first.
172
- const res = await fetch(`${url}/api/v1/shutdown`, {
173
- method: 'POST',
174
- headers: { Authorization: `Bearer ${loadOrCreateToken()}`, 'Content-Type': 'application/json' },
175
- body: JSON.stringify(restart ? { force: true, restart: true } : { force: true }),
176
- });
177
- if (!res.ok) {
178
- const { error } = await res.json().catch(() => ({}));
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 health(url);
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 before = await health(url);
229
- if (!before) {
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 health(url);
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 health(url))) {
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 res = await fetch(`${url}/api/v1/yard/fireworks`, { method: 'POST', headers: { Authorization: `Bearer ${loadOrCreateToken()}` } });
303
- if (!res.ok) throw new Error(`the session manager answered ${res.status}.`);
304
- const { pages } = await res.json();
305
- console.log(pages ? `Fireworks sent to ${pages} open page(s); every Yard on show lights up.` : 'No page is open to see the fireworks.');
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;
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "1.30.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;
@@ -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' && typeof msg.data === 'string') session.input(msg.data);
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 = '';
@@ -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 { this.pty.write(data); } catch { /* process is exiting */ }
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;