@runuai/host 0.2.4 → 0.2.6

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.
@@ -59,6 +59,13 @@ export function dockerCli(
59
59
  child.on("close", (code) => {
60
60
  finish(code);
61
61
  });
62
+ // EPIPE guard: if the child exits without draining stdin (or our own
63
+ // timeout SIGKILLs it mid-write), the failed write surfaces as an 'error'
64
+ // event on the stdin stream — unhandled, that's an uncaughtException that
65
+ // would take down the whole host service. Today's inputs are tiny (a gh
66
+ // token) and complete synchronously, but this keeps the helper safe for
67
+ // any future caller piping real data.
68
+ child.stdin?.on("error", () => {});
62
69
  if (opts.input !== undefined) child.stdin?.write(opts.input);
63
70
  child.stdin?.end();
64
71
  });
@@ -16,7 +16,11 @@ import type { CloudToHost } from "../src/protocol";
16
16
 
17
17
  const REFRESH_LEAD_MS = 5 * 60 * 1000; // refresh 5 min before expiry
18
18
  const EXEC_TIMEOUT_MS = 10_000;
19
- const EXCHANGE_TIMEOUT_MS = 15_000; // cap the cloud token-exchange round-trip
19
+ // Cap the cloud token-exchange round-trip. Generous on purpose: aborting an
20
+ // exchange that GitHub already completed burns the single-use refresh token
21
+ // (we never see the rotated replacement), so a slow success beats a fast
22
+ // ambiguous abort.
23
+ const EXCHANGE_TIMEOUT_MS = 30_000;
20
24
 
21
25
  type GhConnectSet = Extract<CloudToHost, { kind: "gh.connect.set" }>;
22
26
 
@@ -25,6 +29,9 @@ type GhConnectSet = Extract<CloudToHost, { kind: "gh.connect.set" }>;
25
29
  /** Encrypt + persist the user's refresh token (gh.connect.set handler). */
26
30
  export function onConnectSet(frame: GhConnectSet): { ok: boolean; error?: string } {
27
31
  try {
32
+ // Fresh grant — a cached access token from the previous grant may be
33
+ // revoked; drop it so the next mint exchanges against the new token.
34
+ clearAccessCache(frame.userId);
28
35
  const sealed = sealAesGcm(frame.refreshToken);
29
36
  const now = Date.now();
30
37
  getDb()
@@ -64,6 +71,7 @@ export function onConnectClear(userId: string): void {
64
71
  }
65
72
 
66
73
  export function deleteToken(userId: string): void {
74
+ clearAccessCache(userId);
67
75
  getDb()
68
76
  .delete(schema.githubTokens)
69
77
  .where(eq(schema.githubTokens.userId, userId))
@@ -115,8 +123,68 @@ interface ExchangeResult {
115
123
  * Mint a fresh access token for the user via the cloud exchange. Persists a
116
124
  * rotated refresh token if GitHub returned one. Returns null when the user has
117
125
  * no token on this host. Throws on exchange failure.
126
+ *
127
+ * Deduped PER USER: GitHub refresh tokens are single-use (each exchange
128
+ * rotates them), and GitHub treats reuse of a consumed token as a compromise
129
+ * signal that can kill the whole grant. Concurrent task setups for the same
130
+ * user (several task-ups at once, or every channel re-ensure after a host
131
+ * restart) used to race the rotation — the loser burned the grant and every
132
+ * exchange afterwards failed until a manual re-connect. The access token is
133
+ * per-user anyway, so concurrent callers share one in-flight exchange.
134
+ */
135
+ const inflightExchanges = new Map<
136
+ string,
137
+ Promise<{ accessToken: string; expiresAt: number } | null>
138
+ >();
139
+
140
+ /**
141
+ * Per-user ACCESS-token cache. Access tokens live ~8h, but every exchange
142
+ * ROTATES the single-use refresh token — and a rotation whose response is
143
+ * lost mid-flight (our timeout firing while GitHub already rotated) burns the
144
+ * grant, forcing a manual re-connect. So exchange as rarely as possible: all
145
+ * task setups and refresh timers for a user share one cached access token
146
+ * until it nears expiry. The refresh timers fire at expiry−5min, inside the
147
+ * 15min stale window, so exactly one of them performs the real exchange (the
148
+ * in-flight dedupe serializes any ties) and the rest re-inject from cache.
118
149
  */
119
- export async function requestAccessToken(
150
+ const ACCESS_CACHE_LEAD_MS = 15 * 60 * 1000;
151
+ const accessCache = new Map<
152
+ string,
153
+ { accessToken: string; expiresAt: number }
154
+ >();
155
+
156
+ /** Drop a user's cached access token (token deleted / re-granted). */
157
+ function clearAccessCache(userId: string): void {
158
+ accessCache.delete(userId);
159
+ }
160
+
161
+ /** Test hook: reset the access-token cache. */
162
+ export function clearAllAccessCache(): void {
163
+ accessCache.clear();
164
+ }
165
+
166
+ export function requestAccessToken(
167
+ userId: string,
168
+ ): Promise<{ accessToken: string; expiresAt: number } | null> {
169
+ const cached = accessCache.get(userId);
170
+ if (cached && cached.expiresAt - Date.now() > ACCESS_CACHE_LEAD_MS) {
171
+ return Promise.resolve(cached);
172
+ }
173
+ const existing = inflightExchanges.get(userId);
174
+ if (existing) return existing;
175
+ const run = doRequestAccessToken(userId)
176
+ .then((tok) => {
177
+ if (tok) accessCache.set(userId, tok);
178
+ return tok;
179
+ })
180
+ .finally(() => {
181
+ inflightExchanges.delete(userId);
182
+ });
183
+ inflightExchanges.set(userId, run);
184
+ return run;
185
+ }
186
+
187
+ async function doRequestAccessToken(
120
188
  userId: string,
121
189
  ): Promise<{ accessToken: string; expiresAt: number } | null> {
122
190
  const row = getDb()
@@ -140,7 +208,19 @@ export async function requestAccessToken(
140
208
  signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
141
209
  });
142
210
  if (!res.ok) {
143
- throw new Error(`gh token exchange failed: HTTP ${res.status}`);
211
+ // Include the cloud's error body: it passes GitHub's OAuth error code
212
+ // through (e.g. "bad_refresh_token"/"invalid_grant" inside the 502
213
+ // message), which is the ONLY reliable revocation signal. The bare HTTP
214
+ // status is not one — the cloud returns 401 solely for a host-token
215
+ // mismatch and wraps every GitHub-side failure as 502.
216
+ let detail = "";
217
+ try {
218
+ const body = (await res.json()) as { message?: string };
219
+ if (body && typeof body.message === "string") detail = ` — ${body.message}`;
220
+ } catch {
221
+ // non-JSON body — status alone will have to do
222
+ }
223
+ throw new Error(`gh token exchange failed: HTTP ${res.status}${detail}`);
144
224
  }
145
225
  const data = (await res.json()) as ExchangeResult;
146
226
  if (data.refreshToken) {
@@ -226,6 +306,19 @@ export function scheduleRefresh(
226
306
  timers.set(taskId, timer);
227
307
  }
228
308
 
309
+ /**
310
+ * True when the exchange error names a GitHub-side revocation — the refresh
311
+ * token itself is dead, so retrying is pointless and the user must re-grant.
312
+ * Deliberately keyed on GitHub's OAuth error strings (passed through the
313
+ * cloud's 502 body by requestAccessToken), NOT on bare HTTP status: the cloud
314
+ * returns 401 only for a host-token mismatch and wraps all GitHub failures as
315
+ * 502, so a status like "401"/"403" alone never means "revoked" — the old
316
+ * \b40[13]\b match here deleted VALID refresh tokens on host-auth/proxy noise.
317
+ */
318
+ function isRevokedTokenError(reason: string): boolean {
319
+ return /revoked|invalid_grant|bad_refresh/i.test(reason);
320
+ }
321
+
229
322
  async function runRefresh(taskId: string, userId: string): Promise<void> {
230
323
  try {
231
324
  const tok = await requestAccessToken(userId);
@@ -240,7 +333,7 @@ async function runRefresh(taskId: string, userId: string): Promise<void> {
240
333
  // A revoked / expired refresh token can't recover — drop it to force a
241
334
  // clean re-grant through the OAuth flow. Anything else is likely transient
242
335
  // (network / cloud blip) — self-heal with a bounded retry.
243
- if (/\b40[13]\b|revoked|invalid_grant|bad_refresh/i.test(reason)) {
336
+ if (isRevokedTokenError(reason)) {
244
337
  deleteToken(userId);
245
338
  } else {
246
339
  scheduleGithubRetry(taskId, userId, 0);
@@ -276,22 +369,42 @@ function clearGithubRetry(taskId: string): void {
276
369
  }
277
370
  }
278
371
 
372
+ /** Is the task still live on this host? Retries for ended tasks would docker
373
+ * exec against a removed container and post notes into a finished chat. */
374
+ function taskIsActive(taskId: string): boolean {
375
+ const row = getDb()
376
+ .select({ endedAt: schema.hostTasks.endedAt })
377
+ .from(schema.hostTasks)
378
+ .where(eq(schema.hostTasks.taskId, taskId))
379
+ .get();
380
+ return row != null && row.endedAt == null;
381
+ }
382
+
279
383
  function scheduleGithubRetry(
280
384
  taskId: string,
281
385
  userId: string,
282
386
  attempt: number,
387
+ deps: SetupTaskDeps = {},
283
388
  ): void {
284
389
  clearGithubRetry(taskId);
285
390
  if (attempt >= RETRY_BACKOFF_MS.length) {
391
+ // attempt counts retries fired so far; +1 for the initial try.
286
392
  console.warn(
287
- `[github] task ${taskId}: gh setup still failing after ${attempt} attempts — giving up until /retry-gh`,
393
+ `[github] task ${taskId}: gh setup still failing after ${attempt + 1} attempts — giving up until /retry-gh`,
394
+ );
395
+ authExpiredHandler?.(
396
+ taskId,
397
+ userId,
398
+ "gh setup kept failing — send /retry-gh once the connection recovers",
288
399
  );
289
400
  return;
290
401
  }
291
402
  const delay = RETRY_BACKOFF_MS[attempt] ?? 300_000;
292
403
  const timer = setTimeout(() => {
293
404
  retryTimers.delete(taskId);
294
- void setupTaskGithub(taskId, userId, {}, attempt + 1);
405
+ const isActive = deps.taskIsActive ?? taskIsActive;
406
+ if (!isActive(taskId)) return; // task ended while we backed off
407
+ void setupTaskGithub(taskId, userId, deps, attempt + 1);
295
408
  }, delay);
296
409
  timer.unref?.();
297
410
  retryTimers.set(taskId, timer);
@@ -313,6 +426,8 @@ export interface SetupTaskDeps {
313
426
  ) => Promise<{ accessToken: string; expiresAt: number } | null>;
314
427
  inject?: (taskId: string, token: string) => void | Promise<void>;
315
428
  schedule?: (taskId: string, userId: string, expiresAt: number) => void;
429
+ deleteToken?: (userId: string) => void;
430
+ taskIsActive?: (taskId: string) => boolean;
316
431
  }
317
432
 
318
433
  /**
@@ -363,10 +478,19 @@ export async function setupTaskGithub(
363
478
  } catch (err) {
364
479
  const reason = err instanceof Error ? err.message : String(err);
365
480
  console.warn(`[github] task ${taskId}: gh setup failed: ${reason}`);
366
- authExpiredHandler?.(taskId, userId, reason);
367
- // Self-heal: a transient exchange/inject failure shouldn't strand the task
368
- // on a stale token until a manual /retry-gh. Retry with bounded backoff.
369
- scheduleGithubRetry(taskId, userId, attempt);
481
+ // A revoked refresh token can't recover by retrying — drop it so the user
482
+ // gets a clean re-grant, and tell the chat once.
483
+ if (isRevokedTokenError(reason)) {
484
+ (deps.deleteToken ?? deleteToken)(userId);
485
+ authExpiredHandler?.(taskId, userId, reason);
486
+ return false;
487
+ }
488
+ // Transient: self-heal with bounded backoff. Post the chat note only on
489
+ // the FIRST failure of a chain — each retry re-enters this catch, and six
490
+ // "gh auth" notes for one outage is noise. Exhaustion posts its own note
491
+ // (in scheduleGithubRetry).
492
+ if (attempt === 0) authExpiredHandler?.(taskId, userId, reason);
493
+ scheduleGithubRetry(taskId, userId, attempt, deps);
370
494
  return false;
371
495
  } finally {
372
496
  setupInFlight.delete(taskId);
@@ -56,8 +56,19 @@ interface Channel {
56
56
  /** Per-agent first-turn message — only agents whose `initialPrompt` is
57
57
  * non-empty have an entry. Delivered once at container-ready. */
58
58
  firstTurns: Map<string, string>;
59
- /** True once agent sessions have been spawned (lazily, on first send). */
60
- sessionsStarted: boolean;
59
+ /** In-flight (or completed) session start. A memoized promise not a
60
+ * boolean flag — so a concurrent deliver() awaits actual readiness instead
61
+ * of observing "started" while the sessions map is still empty (the start
62
+ * awaits docker work before populating it). Reset to null on failure so the
63
+ * next ensure retries. */
64
+ sessionsReady: Promise<boolean> | null;
65
+ /** Agents with turn output already streamed for the current turn — i.e. the
66
+ * cloud has buffered content for them (see ChannelRouter.turnBuffer). */
67
+ openTurns: Set<string>;
68
+ /** Agents whose current turn was interrupted (ESC) — their next
69
+ * turn_complete is flagged `aborted` so the cloud DISCARDS the buffered
70
+ * half-turn instead of delivering it to @-mentioned peers. */
71
+ interrupted: Set<string>;
61
72
  /** Per-agent respawn counter — bounded so a broken agent can't
62
73
  * loop forever rewriting its config. */
63
74
  respawns: Map<string, number>;
@@ -149,7 +160,9 @@ class Orchestrator {
149
160
  containerName: `task-${taskId.toLowerCase()}-app-1`,
150
161
  preambles,
151
162
  firstTurns,
152
- sessionsStarted: false,
163
+ sessionsReady: null,
164
+ openTurns: new Set(),
165
+ interrupted: new Set(),
153
166
  respawns: new Map(),
154
167
  };
155
168
  this.channels.set(taskId, channel);
@@ -167,16 +180,31 @@ class Orchestrator {
167
180
  *
168
181
  * Returns whether sessions are ready.
169
182
  */
170
- private async ensureSessions(channel: Channel): Promise<boolean> {
171
- if (channel.sessionsStarted) return true;
183
+ private ensureSessions(channel: Channel): Promise<boolean> {
184
+ // Memoized: every caller awaits the SAME in-flight start, so a concurrent
185
+ // deliver() can't observe "ready" while the sessions map is still empty
186
+ // (startSessions awaits docker work before populating it — the old boolean
187
+ // flag flipped before that await and opened exactly that window).
188
+ if (!channel.sessionsReady) {
189
+ channel.sessionsReady = this.startSessions(channel);
190
+ // A failed/aborted start must not poison the channel — reset so the
191
+ // next ensure retries (e.g. the task wasn't `running` yet).
192
+ channel.sessionsReady.then(
193
+ (ok) => {
194
+ if (!ok) channel.sessionsReady = null;
195
+ },
196
+ () => {
197
+ channel.sessionsReady = null;
198
+ },
199
+ );
200
+ }
201
+ return channel.sessionsReady;
202
+ }
172
203
 
204
+ private async startSessions(channel: Channel): Promise<boolean> {
173
205
  const task = getHostTask(channel.taskId);
174
206
  if (!task || task.statusMirror !== "running") return false;
175
207
 
176
- // Flip the flag before the await so a concurrent send can't
177
- // double-spawn the sessions.
178
- channel.sessionsStarted = true;
179
-
180
208
  // Set the task creator's git author identity in the container (ADR-029).
181
209
  // The SSH key itself is installed earlier by task-up.sh (host clone +
182
210
  // container), using the creator's per-user key. Best-effort. Awaited but
@@ -267,8 +295,15 @@ class Orchestrator {
267
295
  taskId: string,
268
296
  agentId: string,
269
297
  ): Promise<{ ok: true } | { ok: false; error: string }> {
270
- const session = this.channels.get(taskId)?.sessions.get(agentId);
271
- if (!session) return { ok: false, error: "no active session" };
298
+ const channel = this.channels.get(taskId);
299
+ const session = channel?.sessions.get(agentId);
300
+ if (!channel || !session) return { ok: false, error: "no active session" };
301
+ // An interrupted turn is a half-turn — its eventual turn_complete must not
302
+ // hand the buffered fragment to @-mentioned peers. Flag it so the boundary
303
+ // goes out `aborted` and the cloud discards the buffer. Only when the turn
304
+ // actually streamed output (openTurns): an idle-agent ESC must not mark
305
+ // the NEXT legitimate turn as aborted.
306
+ if (channel.openTurns.has(agentId)) channel.interrupted.add(agentId);
272
307
  void session.interrupt();
273
308
  this.emitSystemNote(taskId, `Stopped @${agentId}.`);
274
309
  return { ok: true };
@@ -300,6 +335,7 @@ class Orchestrator {
300
335
  ): Promise<void> {
301
336
  switch (event.type) {
302
337
  case "message_delta": {
338
+ channel.openTurns.add(agentId);
303
339
  this.emitHost({
304
340
  kind: "agent.message_delta",
305
341
  taskId: channel.taskId,
@@ -309,6 +345,7 @@ class Orchestrator {
309
345
  break;
310
346
  }
311
347
  case "message_complete": {
348
+ channel.openTurns.add(agentId);
312
349
  this.emitHost({
313
350
  kind: "agent.message_complete",
314
351
  taskId: channel.taskId,
@@ -354,6 +391,10 @@ class Orchestrator {
354
391
  break;
355
392
  }
356
393
  case "error": {
394
+ // The turn died with the session — drop its turn-state flags so a
395
+ // respawned session starts clean.
396
+ channel.openTurns.delete(agentId);
397
+ channel.interrupted.delete(agentId);
357
398
  // Claude under load (especially Docker Desktop macOS) occasionally
358
399
  // unlinks ~/.claude.json mid-write during atomic config rewrites,
359
400
  // and a concurrent claude spawn lands during the gap and exits 0
@@ -381,10 +422,15 @@ class Orchestrator {
381
422
  case "turn_complete": {
382
423
  // Turn boundary — the cloud flushes any @-mentions buffered across this
383
424
  // turn's messages and wakes the mentioned peers with the full turn.
425
+ // An interrupted (ESC'd) turn goes out `aborted`: it's a half-turn, so
426
+ // the cloud discards the buffer instead of handing it to peers.
427
+ const aborted = channel.interrupted.delete(agentId);
428
+ channel.openTurns.delete(agentId);
384
429
  this.emitHost({
385
430
  kind: "agent.turn_complete",
386
431
  taskId: channel.taskId,
387
432
  agentId,
433
+ aborted,
388
434
  });
389
435
  break;
390
436
  }
@@ -817,15 +863,21 @@ interface DockerPs {
817
863
  State: string; // "running" | "exited" | "created" | "paused"
818
864
  }
819
865
 
820
- async function dockerListContainersByLabel(label: string): Promise<DockerPs[]> {
821
- const res = await dockerCli([
822
- "ps",
823
- "--all",
824
- "--filter",
825
- `label=${label}`,
826
- "--format",
827
- "{{json .}}",
828
- ]);
866
+ /**
867
+ * Returns the task's containers, [] when docker reports none, or NULL when
868
+ * docker itself was unreachable/timed out (status null = spawn failure or our
869
+ * SIGKILL). The distinction matters: recovery treats [] as "container gone"
870
+ * and DOWNGRADES the task to stopped/error — doing that because a `docker ps`
871
+ * timed out under load would destroy a perfectly recoverable task.
872
+ */
873
+ async function dockerListContainersByLabel(
874
+ label: string,
875
+ ): Promise<DockerPs[] | null> {
876
+ const res = await dockerCli(
877
+ ["ps", "--all", "--filter", `label=${label}`, "--format", "{{json .}}"],
878
+ { timeoutMs: 60_000 },
879
+ );
880
+ if (res.status === null) return null; // docker state UNKNOWN — don't act on it
829
881
  if (res.status !== 0) return [];
830
882
  return res.stdout
831
883
  .split("\n")
@@ -842,7 +894,9 @@ async function dockerListContainersByLabel(label: string): Promise<DockerPs[]> {
842
894
  }
843
895
 
844
896
  async function dockerStart(containerName: string): Promise<boolean> {
845
- const res = await dockerCli(["start", containerName]);
897
+ // Starting a big dev container can legitimately take a while — give it far
898
+ // more than dockerCli's 30s default before declaring the task stopped.
899
+ const res = await dockerCli(["start", containerName], { timeoutMs: 120_000 });
846
900
  if (res.status !== 0) {
847
901
  console.error(
848
902
  `[orchestrator] docker start ${containerName} failed: ${res.stderr.trim()}`,
@@ -866,8 +920,15 @@ async function dockerPort(
866
920
  return Number.isFinite(port) ? port : null;
867
921
  }
868
922
 
869
- async function dockerExec(containerName: string, cmd: string[]): Promise<boolean> {
870
- const res = await dockerCli(["exec", containerName, ...cmd]);
923
+ async function dockerExec(
924
+ containerName: string,
925
+ cmd: string[],
926
+ timeoutMs?: number,
927
+ ): Promise<boolean> {
928
+ const res = await dockerCli(
929
+ ["exec", containerName, ...cmd],
930
+ timeoutMs === undefined ? {} : { timeoutMs },
931
+ );
871
932
  return res.status === 0;
872
933
  }
873
934
 
@@ -915,6 +976,14 @@ async function recoverOneTask(
915
976
  const containers = await dockerListContainersByLabel(
916
977
  `com.docker.compose.project=${composeProject}`,
917
978
  );
979
+ if (containers === null) {
980
+ // Docker unreachable / timed out — actual state unknown. Leave the row
981
+ // alone; the next recovery pass (or the next ensure) will see the truth.
982
+ console.warn(
983
+ `[orchestrator] recovery: ${task.taskId} skipped — docker did not answer`,
984
+ );
985
+ return;
986
+ }
918
987
 
919
988
  if (containers.length === 0) {
920
989
  // Container gone. Worktree state determines whether resume is
@@ -945,6 +1014,13 @@ async function recoverOneTask(
945
1014
  });
946
1015
  console.log(`[orchestrator] recovery: ${task.taskId} ports refreshed`);
947
1016
  }
1017
+ // Re-establish gh proactively (ADR-027): the restart killed the in-memory
1018
+ // refresh timers, so an idle task's access token would silently expire
1019
+ // ~8h later. Cheap with the per-user access-token cache — one exchange
1020
+ // per user, every task re-injects from it. Best-effort.
1021
+ if (task.ownerUserId) {
1022
+ void setupTaskGithub(task.taskId, task.ownerUserId);
1023
+ }
948
1024
  return;
949
1025
  }
950
1026
 
@@ -960,7 +1036,11 @@ async function recoverOneTask(
960
1036
  });
961
1037
  return;
962
1038
  }
963
- if (!(await dockerExec(containerName, ["/usr/local/bin/uai-init"]))) {
1039
+ // uai-init reinstalls workspace deps (pnpm/npm install) — minutes on a big
1040
+ // repo. dockerCli's 30s default would SIGKILL it mid-install.
1041
+ if (
1042
+ !(await dockerExec(containerName, ["/usr/local/bin/uai-init"], 10 * 60_000))
1043
+ ) {
964
1044
  console.warn(
965
1045
  `[orchestrator] recovery: ${task.taskId} uai-init failed; container is up but Editor may be down`,
966
1046
  );
@@ -969,6 +1049,11 @@ async function recoverOneTask(
969
1049
  db_setRuntime(task.taskId, {
970
1050
  codeServerPort: port,
971
1051
  });
1052
+ // Same gh re-establish as the running branch — the restarted container's gh
1053
+ // config is whatever it held when it exited.
1054
+ if (task.ownerUserId) {
1055
+ void setupTaskGithub(task.taskId, task.ownerUserId);
1056
+ }
972
1057
  console.log(
973
1058
  `[orchestrator] recovery: ${task.taskId} resumed (port ${port ?? "?"})`,
974
1059
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
package/src/protocol.ts CHANGED
@@ -402,11 +402,14 @@ export type HostEvent =
402
402
  // The agent finished a turn (it may have emitted several message_complete
403
403
  // items within it). The cloud waits for this before waking @-mentioned peers,
404
404
  // so a peer is handed the agent's COMPLETE turn rather than a mid-turn
405
- // fragment that happened to contain the mention.
405
+ // fragment that happened to contain the mention. `aborted` marks a turn that
406
+ // was interrupted (ESC): the cloud discards the buffered half-turn instead
407
+ // of delivering it.
406
408
  | {
407
409
  kind: "agent.turn_complete";
408
410
  taskId: string;
409
411
  agentId: string;
412
+ aborted?: boolean;
410
413
  }
411
414
  | {
412
415
  kind: "agent.tool_call";