@gajae-code/coding-agent 0.16.1 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1699,9 +1699,13 @@ export class AcpAgent implements Agent {
1699
1699
  try {
1700
1700
  await record.adapter.ensureProviders();
1701
1701
  } catch (error) {
1702
- if (waiter.settled || record.cancelRequested || record.activePrompt !== waiter) {
1702
+ if (waiter.settled || record.activePrompt !== waiter) {
1703
1703
  this.#retiredPromptAcknowledgements.delete(params.sessionId);
1704
- if (!waiter.settled) await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1704
+ return await response;
1705
+ }
1706
+ if (record.cancelRequested && waiter.cancelAcknowledged) {
1707
+ this.#retiredPromptAcknowledgements.delete(params.sessionId);
1708
+ await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1705
1709
  return await response;
1706
1710
  }
1707
1711
  if (record.activePrompt === waiter) {
@@ -1712,9 +1716,13 @@ export class AcpAgent implements Agent {
1712
1716
  }
1713
1717
  throw error;
1714
1718
  }
1715
- if (waiter.settled || record.cancelRequested || record.activePrompt !== waiter) {
1719
+ if (waiter.settled || record.activePrompt !== waiter) {
1716
1720
  this.#retiredPromptAcknowledgements.delete(params.sessionId);
1717
- if (!waiter.settled) await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1721
+ return await response;
1722
+ }
1723
+ if (record.cancelRequested && waiter.cancelAcknowledged) {
1724
+ this.#retiredPromptAcknowledgements.delete(params.sessionId);
1725
+ await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1718
1726
  return await response;
1719
1727
  }
1720
1728
 
@@ -1739,8 +1747,11 @@ export class AcpAgent implements Agent {
1739
1747
  );
1740
1748
  }
1741
1749
  waiter.dispatched = true;
1742
- if (waiter.settled || record.cancelRequested || record.activePrompt !== waiter) {
1743
- if (!waiter.settled) await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1750
+ if (waiter.settled || record.activePrompt !== waiter) {
1751
+ return await response;
1752
+ }
1753
+ if (record.cancelRequested && waiter.cancelAcknowledged) {
1754
+ await this.#settleCancelledPrompt(params.sessionId, record, waiter);
1744
1755
  return await response;
1745
1756
  }
1746
1757
 
@@ -1922,7 +1933,7 @@ export class AcpAgent implements Agent {
1922
1933
  // return the semantically meaningful `cancelled` stop reason, so that Clients
1923
1934
  // can reliably confirm the cancellation." Surfacing the transport's `busy`
1924
1935
  // rejection instead would show the user a spurious error for their own cancel.
1925
- if (record.cancelRequested) {
1936
+ if (record.cancelRequested && waiter.cancelAcknowledged) {
1926
1937
  record.cancelRequested = false;
1927
1938
  // The client's turn is settled by the return; the advisory idle publication
1928
1939
  // must not gate it and must still be attempted so the running phase is
@@ -2006,11 +2017,6 @@ export class AcpAgent implements Agent {
2006
2017
  }
2007
2018
  waiter?.cancelAttemptResolve?.(true);
2008
2019
  } catch (error) {
2009
- if (waiter && !waiter.dispatched) {
2010
- await this.#settleCancelledPrompt(params.sessionId, record, waiter);
2011
- waiter.cancelAttemptResolve?.(true);
2012
- return;
2013
- }
2014
2020
  if (!waiter) record.cancelRequested = false;
2015
2021
  // Only the LAST in-flight attempt resolves the shared promise false;
2016
2022
  // an earlier attempt may still acknowledge (review thread P2). After
@@ -2058,7 +2064,8 @@ export class AcpAgent implements Agent {
2058
2064
 
2059
2065
  async #settleCancelledPrompt(id: string, record: SessionRecord, waiter: PromptWaiter): Promise<void> {
2060
2066
  // The authoritative terminal wins whenever it arrives in time; this only runs
2061
- // when nothing settled the prompt the client already asked to cancel.
2067
+ // when nothing settled the prompt after the SDK acknowledged the cancellation.
2068
+ if (!waiter.cancelAcknowledged) return;
2062
2069
  if (this.#sessions.get(id) !== record || record.activePrompt !== waiter || waiter.settled) return;
2063
2070
  if (waiter.terminalReserved) return;
2064
2071
  record.activePrompt = undefined;
@@ -2896,6 +2903,14 @@ export class AcpAgent implements Agent {
2896
2903
  if (reconnected) {
2897
2904
  const waiter = record.activePrompt;
2898
2905
  if (waiter && !waiter.settled && !waiter.terminal) {
2906
+ // A successful abort acknowledgement is authoritative for this prompt even
2907
+ // when the SDK transport changes identity during the cancellation grace.
2908
+ // Treating the reconnect as owner loss would replace the acknowledged
2909
+ // cancellation with connection_closed and leave cancellation nondeterministic.
2910
+ if (record.cancelRequested && waiter.cancelAcknowledged) {
2911
+ await this.#settleCancelledPrompt(id, record, waiter);
2912
+ return;
2913
+ }
2899
2914
  record.activePrompt = undefined;
2900
2915
  record.busy = record.backgroundBusy;
2901
2916
  clearPromptWatchdog(waiter);
@@ -3113,7 +3128,7 @@ export class AcpAgent implements Agent {
3113
3128
  id: string,
3114
3129
  notification: SessionNotification,
3115
3130
  adapter: AcpSdkAdapter,
3116
- publicationGeneration: number,
3131
+ _publicationGeneration: number,
3117
3132
  ): void {
3118
3133
  const prior = this.#failureDiagnosticTails.get(id) ?? Promise.resolve();
3119
3134
  const task = prior.then(async () => {
@@ -18,7 +18,7 @@ Executes bash command in shell session for terminal operations like git, bun, ca
18
18
  {{/if}}
19
19
  {{#if foregroundFoldEnabled}}
20
20
  - In the interactive TUI, the user can press the fold chord twice (default `Alt+Shift+B`, plus `Cmd+B` on macOS; remappable via the `app.tool.backgroundFold` keybinding) while a foreground command is still running to fold it into a background job. All three command surfaces fold: managed non-PTY bash, editor/ACP client-terminal commands, and PTY-mode commands. A folded command keeps its original timeout and is never killed or restarted by folding; a folded PTY continues output-only, so it stops accepting input. The turn then ends cleanly and a later turn resumes automatically with the command's result, so the interrupted task is finished rather than abandoned. `task`/`subagent` waits are deliberately NOT foldable. Do not instruct users to use raw shell `Ctrl+Z`/`bg` inside the GJC TUI; ownership and output routing are not safe there.
21
- - A user steer folds the same way: when the user sends a message while a foreground command has already run for at least 2 seconds, the command is folded into a background job and this call returns the background-start result plus the line `Folded into background job <id> because a user steer arrived; the command keeps running with its original timeout and its result will wake a later turn.` The steer is then delivered at this tool boundary, so handle it next; do not re-run the folded command. Steers that arrive within the first 2 seconds let the command finish normally. This applies only while `busyPromptMode` is `steer` and `toolInterruptPolicy` is `abort_tools`.
21
+ - A user steer folds the same way: when the user sends a message while a foreground command has already run for at least 2 seconds, the command is folded into a background job and this call returns the background-start result plus the line `Folded into background job <id> because a user steer arrived; the command keeps running with its original timeout and its result will wake a later turn.` The steer is then delivered at this tool boundary, so handle it next; do not re-run the folded command. Steers that arrive within the first 2 seconds let the command finish normally. This applies only while `busyPromptMode` is `steer`; `toolInterruptPolicy` is not a gate, since a fold never kills the command.
22
22
  {{/if}}
23
23
  </instruction>
24
24
  {{#if restrictedAllowedPrefixes}}
@@ -2,7 +2,7 @@ Inspects, waits, or cancels async jobs.
2
2
 
3
3
  Background job results are delivered automatically when complete. Jobs that back task subagents should be controlled via the `subagent` tool when it is available; use `job` for non-subagent jobs (async bash, monitors) and as a compatibility fallback. Running job output stays quiet by default to avoid flooding the conversation; use `tail` when you explicitly want to show/reopen retained output. Reach for this tool only when you need to inspect or intervene.
4
4
 
5
- In the interactive TUI, a running foreground command can be folded into a background job by pressing the fold chord twice (default `Alt+Shift+B`, plus `Cmd+B` on macOS; remappable via `app.tool.backgroundFold`), or automatically when the user sends a steer after the command has run for at least 2 seconds (with `busyPromptMode=steer` and `toolInterruptPolicy=abort_tools`). This covers managed non-PTY bash, editor/ACP client-terminal commands, and PTY-mode commands. A folded job keeps its original deadline, is never killed or restarted by folding, and its result wakes a later turn automatically; a folded PTY continues output-only and no longer accepts input. Job listings expose `foldReason` (`steer`, `chord`, `timer`, or `sdk_control`) beside `backgrounded` so you can tell why a job left the foreground. `task`/`subagent` waits are not foldable. Raw shell `Ctrl+Z`/`bg` is not the supported path inside GJC because it bypasses job ownership and output-routing contracts.
5
+ In the interactive TUI, a running foreground command can be folded into a background job by pressing the fold chord twice (default `Alt+Shift+B`, plus `Cmd+B` on macOS; remappable via `app.tool.backgroundFold`), or automatically when the user sends a steer after the command has run for at least 2 seconds (with `busyPromptMode=steer` (`toolInterruptPolicy` is not a gate)). This covers managed non-PTY bash, editor/ACP client-terminal commands, and PTY-mode commands. A folded job keeps its original deadline, is never killed or restarted by folding, and its result wakes a later turn automatically; a folded PTY continues output-only and no longer accepts input. Job listings expose `foldReason` (`steer`, `chord`, `timer`, or `sdk_control`) beside `backgrounded` so you can tell why a job left the foreground. `task`/`subagent` waits are not foldable. Raw shell `Ctrl+Z`/`bg` is not the supported path inside GJC because it bypasses job ownership and output-routing contracts.
6
6
 
7
7
  # Operations
8
8
 
@@ -875,7 +875,7 @@ export class AcpSdkAdapter {
875
875
 
876
876
  #expiredOwnedLeaseFrame(frame: Record<string, unknown>): boolean {
877
877
  const response = this.#leaseErrorFrame(frame);
878
- if (!response || response.ok !== false) return false;
878
+ if (response?.ok !== false) return false;
879
879
  const leaseId = typeof response.leaseId === "string" ? response.leaseId : "";
880
880
  const connectionId = typeof response.connectionId === "string" ? response.connectionId : "";
881
881
  if (!leaseId || !this.#ownsLeaseId(leaseId)) return false;
@@ -471,7 +471,14 @@ export class ChatDaemonRuntime {
471
471
  }
472
472
 
473
473
  async #onAttachment(attachment: SessionAttachment): Promise<void> {
474
+ const revivedTransport = this.#attachments.get(attachment.sessionId) === attachment;
474
475
  this.#attachments.set(attachment.sessionId, attachment);
476
+ if (revivedTransport) {
477
+ this.#presentation?.connectSession(attachment.sessionId, {
478
+ sendReply: route => attachment.send({ type: "reply", id: route.actionId, answer: route.answer }),
479
+ });
480
+ return;
481
+ }
475
482
  const discord = this.#discord;
476
483
  const slack = this.#slack;
477
484
  const barrier = (async () => {
@@ -78,6 +78,43 @@ export interface SdkTailEnvelopeV1 {
78
78
  items: SdkTailItemV1[];
79
79
  }
80
80
 
81
+ export function tailItemKey(item: SdkTailItemV1): string {
82
+ // (generation, seq) restarts per revision; without the revision two turns'
83
+ // first frames share "1:1" and the second is silently dropped (#5200).
84
+ if (item.generation !== undefined && item.seq !== undefined) {
85
+ if (item.revision === undefined || !Number.isSafeInteger(item.revision) || item.revision < 0)
86
+ throw new Error("Cannot key a positioned tail item without its authoritative revision.");
87
+ return `${item.kind}\u0000${item.revision}\u0000${item.generation}\u0000${item.seq}`;
88
+ }
89
+ if (item.id !== undefined) return `${item.kind}\u0000${item.id}`;
90
+ return `${item.kind}\u0000${JSON.stringify(item.payload)}`;
91
+ }
92
+
93
+ /** Holds positioned live frames until the checkpoint can stamp their authoritative revision. */
94
+ export class TailRevisionBuffer {
95
+ #pending: SdkTailItemV1[] = [];
96
+ #resolved = false;
97
+
98
+ push(item: SdkTailItemV1): SdkTailItemV1[] {
99
+ if (item.revision !== undefined || item.generation === undefined || item.seq === undefined) return [item];
100
+ if (!this.#resolved) {
101
+ this.#pending.push(item);
102
+ return [];
103
+ }
104
+ throw new Error("A positioned live tail item arrived without an authoritative revision after checkpoint.");
105
+ }
106
+
107
+ resolve(revision: number): SdkTailItemV1[] {
108
+ if (!Number.isSafeInteger(revision) || revision < 0)
109
+ throw new Error("Tail revision must be a non-negative integer.");
110
+ this.#resolved = true;
111
+ const pending = this.#pending;
112
+ this.#pending = [];
113
+ for (const item of pending) item.revision = revision;
114
+ return pending;
115
+ }
116
+ }
117
+
81
118
  const SECRET_FIELD = /(?:secret|token|password|credential|authorization|api[_-]?key)/i;
82
119
 
83
120
  /** Recursively removes secret-shaped fields (defense in depth for DTO output). */
@@ -228,12 +265,20 @@ export function toTailItemV1(
228
265
  const item: SdkTailItemV1 = {
229
266
  kind,
230
267
  ...(payloadId !== undefined ? { id: payloadId } : id !== undefined ? { id } : {}),
231
- ...(typeof value.revision === "number" ? { revision: value.revision } : {}),
268
+ ...(typeof value.revision === "number" && Number.isSafeInteger(value.revision) && value.revision >= 0
269
+ ? { revision: value.revision }
270
+ : {}),
232
271
  ...(typeof value.generation === "number" ? { generation: value.generation } : {}),
233
272
  ...(typeof value.seq === "number" ? { seq: value.seq } : {}),
234
273
  payload: stripSecretFields(payload),
235
274
  };
236
- if (fallback.revision !== undefined && item.revision === undefined) item.revision = fallback.revision;
275
+ if (
276
+ fallback.revision !== undefined &&
277
+ Number.isSafeInteger(fallback.revision) &&
278
+ fallback.revision >= 0 &&
279
+ item.revision === undefined
280
+ )
281
+ item.revision = fallback.revision;
237
282
  if (fallback.generation !== undefined && item.generation === undefined) item.generation = fallback.generation;
238
283
  if (fallback.seq !== undefined && item.seq === undefined) item.seq = fallback.seq;
239
284
  return item;
@@ -38,10 +38,13 @@ import { SESSION_REQUEST_TIMEOUT_MS } from "../session-reconnect";
38
38
  import {
39
39
  type SdkCheckpointRecordV1,
40
40
  type SdkRetentionGapV1,
41
+ type SdkRingPositionV1,
41
42
  type SdkSessionRowV1,
42
43
  type SdkTailItemV1,
43
44
  SESSION_ROWS_VERSION,
44
45
  stripSecretFields,
46
+ TailRevisionBuffer,
47
+ tailItemKey,
45
48
  toCheckpointRecordV1,
46
49
  toRetentionGapV1,
47
50
  toSessionRowV1,
@@ -985,13 +988,6 @@ function extractTranscriptPage(response: unknown): { items: unknown[]; complete:
985
988
  };
986
989
  }
987
990
 
988
- function tailItemKey(item: SdkTailItemV1): string {
989
- if (item.generation !== undefined && item.seq !== undefined)
990
- return `${item.kind}\u0000${item.generation}\u0000${item.seq}`;
991
- if (item.id !== undefined) return `${item.kind}\u0000${item.id}`;
992
- return `${item.kind}\u0000${JSON.stringify(item.payload)}`;
993
- }
994
-
995
991
  function mergeTailItems(
996
992
  target: SdkTailItemV1[],
997
993
  seen: Set<string>,
@@ -1006,8 +1002,9 @@ function mergeTailItems(
1006
1002
  }
1007
1003
  }
1008
1004
 
1009
- /** Canonical ring position of an event item, when the host stated one. */
1010
- type TailSeqKey = { generation: number; seq: number };
1005
+ /** Canonical session-wide ring position of an event item, when the host stated one. */
1006
+ type TailSeqKey = SdkRingPositionV1;
1007
+ type TailLocalPosition = Pick<SdkRingPositionV1, "generation" | "seq">;
1011
1008
 
1012
1009
  function isPositionCoordinate(value: unknown): value is number {
1013
1010
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
@@ -1057,7 +1054,7 @@ function classifyRawEventPosition(event: unknown): "unpositioned" | "malformed"
1057
1054
  * it is never silently downgraded to unpositioned. Claiming neither coordinate
1058
1055
  * is a genuine unpositioned event.
1059
1056
  */
1060
- function classifyTailPosition(item: SdkTailItemV1): TailSeqKey | "unpositioned" | "malformed" {
1057
+ function classifyTailPosition(item: SdkTailItemV1): TailLocalPosition | "unpositioned" | "malformed" {
1061
1058
  if (item.generation === undefined && item.seq === undefined) return "unpositioned";
1062
1059
  if (!isPositionCoordinate(item.generation) || !isPositionCoordinate(item.seq)) return "malformed";
1063
1060
  return { generation: item.generation, seq: item.seq };
@@ -1065,10 +1062,13 @@ function classifyTailPosition(item: SdkTailItemV1): TailSeqKey | "unpositioned"
1065
1062
 
1066
1063
  function tailSeqKey(item: SdkTailItemV1): TailSeqKey | undefined {
1067
1064
  const position = classifyTailPosition(item);
1068
- return typeof position === "string" ? undefined : position;
1065
+ return typeof position === "string" || !isPositionCoordinate(item.revision)
1066
+ ? undefined
1067
+ : { revision: item.revision, ...position };
1069
1068
  }
1070
1069
 
1071
1070
  function compareTailSeqKeys(left: TailSeqKey, right: TailSeqKey): number {
1071
+ if (left.revision !== right.revision) return left.revision - right.revision;
1072
1072
  return left.generation !== right.generation ? left.generation - right.generation : left.seq - right.seq;
1073
1073
  }
1074
1074
 
@@ -1112,10 +1112,12 @@ function mergeEventTailItems(
1112
1112
  target.unpositioned.push(item);
1113
1113
  continue;
1114
1114
  }
1115
+ const orderedPosition = tailSeqKey(item);
1116
+ if (orderedPosition === undefined) throw new Error("A positioned tail item has no authoritative revision.");
1115
1117
  let index = target.positioned.length;
1116
1118
  for (; index > 0; index--) {
1117
1119
  const previous = tailSeqKey(target.positioned[index - 1]!);
1118
- if (previous === undefined || compareTailSeqKeys(previous, position) <= 0) break;
1120
+ if (previous === undefined || compareTailSeqKeys(previous, orderedPosition) <= 0) break;
1119
1121
  }
1120
1122
  target.positioned.splice(index, 0, item);
1121
1123
  }
@@ -1158,11 +1160,13 @@ function tailItemFromRouterFrame(frame: SessionRouterFrame): SdkTailItemV1 | und
1158
1160
  return toTailItemV1(
1159
1161
  {
1160
1162
  kind: frame.name,
1163
+ ...(frame.revision === undefined ? {} : { revision: frame.revision }),
1161
1164
  ...(seq === undefined ? {} : { generation: frame.generation, seq }),
1162
1165
  payload: frame.body,
1163
1166
  },
1164
1167
  {
1165
1168
  kind: frame.name,
1169
+ ...(frame.revision === undefined ? {} : { revision: frame.revision }),
1166
1170
  ...(seq === undefined ? {} : { generation: frame.generation, seq }),
1167
1171
  },
1168
1172
  );
@@ -1406,6 +1410,7 @@ async function runLiveTail(
1406
1410
  // evidence.
1407
1411
  const seenTranscript = new Set<string>();
1408
1412
  const seenEvents = new Set<string>();
1413
+ const liveRevisionBuffer = new TailRevisionBuffer();
1409
1414
  let checkpoint: SdkCheckpointRecordV1 | undefined;
1410
1415
  let gap: SdkRetentionGapV1 | undefined;
1411
1416
  let liveReason: TailExitReason | undefined;
@@ -1445,7 +1450,7 @@ async function runLiveTail(
1445
1450
  // close at a contested position win instead of failing closed, and
1446
1451
  // checking it after the monotonic guard would silently skip a
1447
1452
  // conflicting kind at an already-decided position.
1448
- const positionKey = `${position.generation}\u0000${position.seq}`;
1453
+ const positionKey = `${position.revision}\u0000${position.generation}\u0000${position.seq}`;
1449
1454
  const claimed = claimedLifecycleKinds.get(positionKey);
1450
1455
  if (claimed !== undefined && claimed !== item.kind) {
1451
1456
  malformed ??= new SdkSessionCliError(
@@ -1487,9 +1492,17 @@ async function runLiveTail(
1487
1492
  if (attachment.sessionId !== sessionId) return;
1488
1493
  const item = tailItemFromRouterFrame(frame);
1489
1494
  if (!item) return;
1490
- if (item.revision === undefined && checkpoint?.revision !== undefined) item.revision = checkpoint.revision;
1491
- // If checkpoint was undefined, queue for later stamping? For now leave without rev
1492
- applyLifecycle(mergeEventTailItems(eventItems, seenEvents, [item], include));
1495
+ try {
1496
+ applyLifecycle(mergeEventTailItems(eventItems, seenEvents, liveRevisionBuffer.push(item), include));
1497
+ } catch (error) {
1498
+ malformed ??= new SdkSessionCliError(
1499
+ "protocol_error",
1500
+ error instanceof Error ? error.message : "A live tail item had no authoritative revision.",
1501
+ 1,
1502
+ { sessionId, kind: item.kind, generation: item.generation, seq: item.seq },
1503
+ );
1504
+ rejectLive?.(malformed);
1505
+ }
1493
1506
  };
1494
1507
 
1495
1508
  return await withRouter(
@@ -1511,6 +1524,10 @@ async function runLiveTail(
1511
1524
  const extraction = extractCheckpoint(checkpointResponse);
1512
1525
  checkpoint = extraction.record;
1513
1526
  gap = extraction.gap;
1527
+ if (checkpoint !== undefined)
1528
+ applyLifecycle(
1529
+ mergeEventTailItems(eventItems, seenEvents, liveRevisionBuffer.resolve(checkpoint.revision), include),
1530
+ );
1514
1531
  if (gap !== undefined && args.strict === true)
1515
1532
  throw new SdkSessionCliError(
1516
1533
  "retention_gap",
@@ -1595,7 +1612,11 @@ async function runLiveTail(
1595
1612
  applyLifecycle(mergeEventTailItems(eventItems, seenEvents, replayItems, include));
1596
1613
  if (malformed !== undefined) throw malformed;
1597
1614
  if (checkpoint !== undefined) {
1598
- const checkpointKey = { generation: checkpoint.generation, seq: checkpoint.seq };
1615
+ const checkpointKey = {
1616
+ revision: checkpoint.revision,
1617
+ generation: checkpoint.generation,
1618
+ seq: checkpoint.seq,
1619
+ };
1599
1620
  if (turnStateKey === undefined || compareTailSeqKeys(checkpointKey, turnStateKey) >= 0) {
1600
1621
  turnStateKey = checkpointKey;
1601
1622
  turnIdle = checkpoint.idle;
@@ -32,11 +32,20 @@ export class SessionEventStream {
32
32
  #frames: EventFrame[] = [];
33
33
  readonly #ringSize: number;
34
34
  readonly #resyncQueries: string[];
35
+ readonly #revisionProvider: (() => number | undefined) | undefined;
35
36
 
36
- constructor(options: { generation?: number; ringSize?: number; resyncQueryIds?: string[] } = {}) {
37
+ constructor(
38
+ options: {
39
+ generation?: number;
40
+ ringSize?: number;
41
+ resyncQueryIds?: string[];
42
+ revisionProvider?: () => number | undefined;
43
+ } = {},
44
+ ) {
37
45
  this.#generation = options.generation ?? 0;
38
46
  this.#ringSize = options.ringSize ?? 256;
39
47
  this.#resyncQueries = options.resyncQueryIds ?? ["Q01", "Q02", "Q03"];
48
+ this.#revisionProvider = options.revisionProvider;
40
49
  }
41
50
 
42
51
  get generation(): number {
@@ -54,7 +63,21 @@ export class SessionEventStream {
54
63
  }
55
64
 
56
65
  emit(frame: SdkFrame): EventFrame {
57
- const event: EventFrame = { ...frame, type: "event", generation: this.#generation, seq: ++this.#seq };
66
+ const suppliedRevision = frame.revision;
67
+ const providedRevision = this.#revisionProvider?.();
68
+ const revision =
69
+ typeof suppliedRevision === "number" && Number.isSafeInteger(suppliedRevision) && suppliedRevision >= 0
70
+ ? suppliedRevision
71
+ : typeof providedRevision === "number" && Number.isSafeInteger(providedRevision) && providedRevision >= 0
72
+ ? providedRevision
73
+ : undefined;
74
+ const event: EventFrame = {
75
+ ...frame,
76
+ type: "event",
77
+ ...(revision === undefined ? {} : { revision }),
78
+ generation: this.#generation,
79
+ seq: ++this.#seq,
80
+ };
58
81
  this.#frames.push(event);
59
82
  if (this.#frames.length > this.#ringSize) this.#frames.shift();
60
83
  return event;
@@ -47,6 +47,8 @@ export type SessionActivationGate = (input: { sessionId: string; generation: num
47
47
  export interface SessionSdkHostOptions extends HostEndpointAdapters {
48
48
  control?: (connectionId: string, frame: SdkFrame) => unknown | Promise<unknown>;
49
49
  query?: (connectionId: string, frame: SdkFrame) => unknown | Promise<unknown>;
50
+ /** Supplies the durable transcript revision stamped on ordinary emitted events. */
51
+ eventRevision?: () => number | undefined;
50
52
  /** Test/lifecycle seam invoked synchronously before fire-and-forget dispatch. */
51
53
  onFrameAdmitted?: (connectionId: string, frame: SdkFrame) => void;
52
54
  /** Synchronously rejects external input while a runtime is being replaced. */
@@ -199,7 +201,7 @@ function has(frame: SdkFrame, field: string): boolean {
199
201
 
200
202
  /** Adapter-based session host; bus wiring owns NotificationServer creation and transport framing. */
201
203
  export class SessionSdkHost {
202
- readonly events = new SessionEventStream();
204
+ readonly events: SessionEventStream;
203
205
  readonly reverse: ReverseLeaseRuntime;
204
206
  readonly #options: SessionSdkHostOptions;
205
207
  #started = false;
@@ -214,6 +216,7 @@ export class SessionSdkHost {
214
216
 
215
217
  constructor(options: SessionSdkHostOptions) {
216
218
  this.#options = options;
219
+ this.events = new SessionEventStream({ revisionProvider: options.eventRevision });
217
220
  this.reverse = new ReverseLeaseRuntime({
218
221
  sendFrame: options.sendFrame,
219
222
  installDefinitions: options.installProviderDefinitions,
@@ -1220,6 +1220,7 @@ describe("SessionSdkSessionRuntime", () => {
1220
1220
  const transport = memoryTransport();
1221
1221
  const runtime = new SessionSdkSessionRuntime({
1222
1222
  transport,
1223
+ eventRevision: () => 7,
1223
1224
  control: async (_connectionId, frame) => ({ id: frame.id, ok: true, result: { operation: frame.operation } }),
1224
1225
  query: async (_connectionId, frame) => ({ id: frame.id, ok: true, result: { query: frame.query } }),
1225
1226
  });
@@ -1239,7 +1240,7 @@ describe("SessionSdkSessionRuntime", () => {
1239
1240
  });
1240
1241
  transport.feed("client", { type: "query_request", id: "query", query: "Q18", input: {} });
1241
1242
  await Bun.sleep(0);
1242
- expect(transport.broadcasts.some(frame => frame.kind === "session_ready")).toBe(true);
1243
+ expect(transport.broadcasts).toContainEqual(expect.objectContaining({ kind: "session_ready", revision: 7 }));
1243
1244
  expect(transport.sent).toEqual(
1244
1245
  expect.arrayContaining([
1245
1246
  expect.objectContaining({ type: "event_replay_result", id: "replay", ok: true }),
@@ -2833,26 +2834,32 @@ describe("SessionSdkSessionRuntime", () => {
2833
2834
 
2834
2835
  // B's accepted receipt is redacted to {} once its submission rejects,
2835
2836
  // so select its record by the stable clientRef instead.
2836
- // Let the async rejection settle and retire B's pending entry.
2837
- await Bun.sleep(50);
2838
2837
  // The rejected submission must TERMINALIZE as failed (#4668 review
2839
2838
  // P1): agent_failed alone is diagnostic-only, so the settlement also
2840
2839
  // writes agent_end. Before that fix B stayed accepted forever.
2841
2840
  {
2842
2841
  const ids = { clientRef: "fail-b-ref" };
2843
- transport.feed("conn-b", {
2844
- type: "query_request",
2845
- id: "fail-b-status",
2846
- query: "turn.prompt_status",
2847
- input: { kind: "prompt", ...ids },
2848
- } as SdkFrame);
2849
- // waitResponse polls control_response only; queries get query_response.
2850
2842
  const statusDeadline = Date.now() + 15_000;
2851
- while (!transport.sent.some(frame => frame.id === "fail-b-status")) {
2843
+ let status: Record<string, unknown> | undefined;
2844
+ for (let attempt = 0; status === undefined; attempt += 1) {
2845
+ const id = `fail-b-status-${attempt}`;
2846
+ transport.feed("conn-b", {
2847
+ type: "query_request",
2848
+ id,
2849
+ query: "turn.prompt_status",
2850
+ input: { kind: "prompt", ...ids },
2851
+ } as SdkFrame);
2852
+ while (!transport.sent.some(frame => frame.id === id)) {
2853
+ if (Date.now() > statusDeadline) throw new Error("Timed out waiting for fail-b-status");
2854
+ await Bun.sleep(20);
2855
+ }
2856
+ const response = transport.sent.find(frame => frame.id === id);
2857
+ const result = response?.result as Record<string, unknown> | undefined;
2858
+ if (result?.status === "failed") status = response;
2859
+ else await Bun.sleep(20);
2852
2860
  if (Date.now() > statusDeadline) throw new Error("Timed out waiting for fail-b-status");
2853
- await Bun.sleep(20);
2854
2861
  }
2855
- expect(transport.sent.find(frame => frame.id === "fail-b-status")).toMatchObject({
2862
+ expect(status).toMatchObject({
2856
2863
  ok: true,
2857
2864
  result: expect.objectContaining({ status: "failed" }),
2858
2865
  });
@@ -4765,7 +4772,7 @@ describe("post-acceptance invocation terminalization", () => {
4765
4772
  const harness = await invocationHarness("successor-progress", cwd, {
4766
4773
  settings: {
4767
4774
  get: (key: string) =>
4768
- key === "sdk.promptDeadlineMs" ? 25 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
4775
+ key === "sdk.promptDeadlineMs" ? 2_000 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
4769
4776
  } as unknown as Settings,
4770
4777
  sendUserMessage: async (_content, options) => {
4771
4778
  prompts += 1;
@@ -4790,7 +4797,7 @@ describe("post-acceptance invocation terminalization", () => {
4790
4797
  await successorStarted.promise;
4791
4798
  for (let index = 0; index < 4; index += 1) {
4792
4799
  await harness.emit("tool_execution_start");
4793
- await Bun.sleep(15);
4800
+ await Bun.sleep(800);
4794
4801
  }
4795
4802
  expect(await harness.query("turn.prompt_status", successorIds)).toMatchObject({
4796
4803
  result: { status: expect.stringMatching(/accepted|in_flight/) },
@@ -5553,9 +5560,13 @@ describe("accepted-control zero-execution bound (#4668)", () => {
5553
5560
  // renewal wiring at the SDK boundary.
5554
5561
  const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-renew-attached-"));
5555
5562
  try {
5563
+ const renewalSettings = {
5564
+ get: (key: string) =>
5565
+ key === "sdk.promptDeadlineMs" ? 2_000 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
5566
+ } as unknown as Settings;
5556
5567
  let promoted: ((promotion: { startsOwnRun: boolean }) => void) | undefined;
5557
5568
  const harness = await invocationHarness("renew-attached", cwd, {
5558
- settings: zeroProgressSettings,
5569
+ settings: renewalSettings,
5559
5570
  sendUserMessage: async (content, options) => {
5560
5571
  await options?.onPreflightAcceptCommit?.();
5561
5572
  if (content === "consumed") {
@@ -5574,12 +5585,12 @@ describe("accepted-control zero-execution bound (#4668)", () => {
5574
5585
  expect(consumed.ok).toBe(true);
5575
5586
  promoted?.({ startsOwnRun: false });
5576
5587
  const ids = { commandId: consumed.result?.commandId, turnId: consumed.result?.turnId };
5577
- // Past the 25ms lease, repeated tool activity keeps the attached
5578
- // correlation alive: without renewal it would already be
5579
- // prompt_deadline_exceeded by the first sleep boundary.
5588
+ // Past the initial 2s lease, repeated tool activity keeps the
5589
+ // attached correlation alive. Each interval stays well within one
5590
+ // renewed lease while their cumulative duration exceeds the original.
5580
5591
  for (let i = 0; i < 3; i += 1) {
5581
5592
  await harness.emit("tool_execution_start");
5582
- await Bun.sleep(15);
5593
+ await Bun.sleep(800);
5583
5594
  }
5584
5595
  const midRun = await harness.query("turn.prompt_status", ids);
5585
5596
  expect(midRun.result?.status).not.toBe("failed");
@@ -5783,7 +5794,7 @@ describe("accepted-control zero-execution bound (#4668)", () => {
5783
5794
  const harness = await invocationHarness("dispatch-race-lease", cwd, {
5784
5795
  settings: {
5785
5796
  get: (key: string) =>
5786
- key === "sdk.promptDeadlineMs" ? 25 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
5797
+ key === "sdk.promptDeadlineMs" ? 2_000 : key === "sdk.promptMaxRuntimeMs" ? 60_000 : undefined,
5787
5798
  } as unknown as Settings,
5788
5799
  sendUserMessage: async (content, options) => {
5789
5800
  await options?.onPreflightAcceptCommit?.();
@@ -5808,9 +5819,9 @@ describe("accepted-control zero-execution bound (#4668)", () => {
5808
5819
  const raced = await harness.control("turn.prompt", { text: "raced" });
5809
5820
  expect(raced.ok).toBe(true);
5810
5821
  const idsRaced = { commandId: raced.result?.commandId, turnId: raced.result?.turnId };
5811
- // Far past the 25ms lease while queued: the acceptance-anchored lease
5822
+ // Far past the 2s lease while queued: the acceptance-anchored lease
5812
5823
  // was dropped at the divert disposition, so no false deadline fire.
5813
- await Bun.sleep(120);
5824
+ await Bun.sleep(2_200);
5814
5825
  const queued = await harness.query("turn.prompt_status", idsRaced);
5815
5826
  expect(queued.result?.status).not.toBe("failed");
5816
5827
  // Real consumption re-leases and attaches to the in-flight run.
@@ -5224,6 +5224,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
5224
5224
  };
5225
5225
  runtime = new SessionSdkSessionRuntime({
5226
5226
  transport,
5227
+ eventRevision: () =>
5228
+ typeof (ctx as Partial<ExtensionContext>).getTranscript === "function"
5229
+ ? ctx.getTranscript().length
5230
+ : undefined,
5227
5231
  masterCapabilityVerify: frame =>
5228
5232
  verifyMasterCapabilityFrame({
5229
5233
  frame,