@gajae-code/coding-agent 0.16.0 → 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.
Files changed (164) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/dist/types/async/job-manager.d.ts +32 -1
  3. package/dist/types/browser-backend.d.ts +12 -0
  4. package/dist/types/cli/update-cli.d.ts +1 -0
  5. package/dist/types/commands/sdk.d.ts +2 -0
  6. package/dist/types/config/billing-path.d.ts +25 -0
  7. package/dist/types/config/config-schema-version.d.ts +13 -1
  8. package/dist/types/config/model-registry.d.ts +16 -0
  9. package/dist/types/config/settings-schema.d.ts +17 -7
  10. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  11. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  12. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  13. package/dist/types/main.d.ts +1 -0
  14. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  15. package/dist/types/modes/components/model-selector.d.ts +4 -0
  16. package/dist/types/modes/components/session-selector.d.ts +1 -1
  17. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  18. package/dist/types/modes/interactive-mode.d.ts +16 -2
  19. package/dist/types/modes/theme/theme.d.ts +2 -1
  20. package/dist/types/modes/types.d.ts +2 -0
  21. package/dist/types/sdk/broker/broker.d.ts +2 -1
  22. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  23. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +14 -0
  25. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  26. package/dist/types/sdk/broker/state-version.d.ts +17 -0
  27. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  28. package/dist/types/sdk/bus/index.d.ts +3 -1
  29. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  30. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  31. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  32. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  33. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  34. package/dist/types/sdk/cli/rows.d.ts +15 -2
  35. package/dist/types/sdk/cli/session-cli.d.ts +2 -1
  36. package/dist/types/sdk/host/events.d.ts +2 -0
  37. package/dist/types/sdk/host/host.d.ts +2 -0
  38. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  39. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  40. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  41. package/dist/types/sdk/host/session-runtime.d.ts +18 -17
  42. package/dist/types/sdk/lifecycle-session.d.ts +1 -1
  43. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  44. package/dist/types/sdk/receipt-state.d.ts +2 -0
  45. package/dist/types/sdk/router/session-router.d.ts +4 -0
  46. package/dist/types/sdk/turn-result.d.ts +6 -0
  47. package/dist/types/session/agent-session.d.ts +47 -7
  48. package/dist/types/session/fold-coordinator.d.ts +29 -2
  49. package/dist/types/session/session-manager.d.ts +14 -2
  50. package/dist/types/tools/ask.d.ts +1 -0
  51. package/dist/types/tools/bash.d.ts +12 -0
  52. package/dist/types/tools/browser/launch.d.ts +10 -0
  53. package/dist/types/tools/browser/registry.d.ts +8 -0
  54. package/dist/types/tools/index.d.ts +4 -10
  55. package/dist/types/tools/job.d.ts +3 -1
  56. package/dist/types/utils/external-editor.d.ts +12 -0
  57. package/examples/extensions/README.md +1 -1
  58. package/examples/extensions/pirate.ts +7 -4
  59. package/package.json +7 -7
  60. package/scripts/generate-sdk-operation-inventory.ts +7 -1
  61. package/src/async/job-manager.ts +107 -9
  62. package/src/browser-backend.ts +29 -0
  63. package/src/cli/plugin-cli.ts +29 -0
  64. package/src/cli/update-cli.ts +63 -12
  65. package/src/commands/sdk.ts +22 -7
  66. package/src/config/billing-path.ts +42 -0
  67. package/src/config/config-schema-version.ts +18 -1
  68. package/src/config/model-preset-registry.ts +177 -18
  69. package/src/config/model-registry.ts +268 -42
  70. package/src/config/settings-schema.ts +19 -7
  71. package/src/config/settings.ts +46 -15
  72. package/src/coordinator-mcp/projection-scan.ts +6 -6
  73. package/src/coordinator-mcp/server.ts +21 -1
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  75. package/src/extensibility/extensions/runner.ts +27 -0
  76. package/src/extensibility/extensions/types.ts +8 -3
  77. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  78. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  79. package/src/extensibility/skills.ts +22 -12
  80. package/src/gjc-runtime/launch-worktree.ts +285 -4
  81. package/src/gjc-runtime/session-state-lock.ts +156 -32
  82. package/src/gjc-runtime/session-state-sidecar.ts +10 -17
  83. package/src/gjc-runtime/tmux-owner-isolation.ts +16 -1
  84. package/src/internal-urls/docs-index.generated.ts +11 -11
  85. package/src/main.ts +4 -1
  86. package/src/modes/acp/acp-agent.ts +34 -19
  87. package/src/modes/components/model-selector.ts +76 -8
  88. package/src/modes/components/session-selector.ts +23 -15
  89. package/src/modes/components/sessions-dashboard.ts +8 -5
  90. package/src/modes/controllers/command-controller.ts +1 -1
  91. package/src/modes/controllers/event-controller.ts +30 -4
  92. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  93. package/src/modes/controllers/input-controller.ts +2 -2
  94. package/src/modes/controllers/selector-controller.ts +10 -3
  95. package/src/modes/interactive-mode.ts +36 -12
  96. package/src/modes/prompt-action-autocomplete.ts +30 -4
  97. package/src/modes/runtime-init.ts +12 -9
  98. package/src/modes/theme/theme.ts +5 -0
  99. package/src/modes/types.ts +2 -0
  100. package/src/modes/utils/ui-helpers.ts +22 -3
  101. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  102. package/src/prompts/tools/ask.md +1 -1
  103. package/src/prompts/tools/bash.md +1 -0
  104. package/src/prompts/tools/job.md +1 -1
  105. package/src/sdk/acp/adapter.ts +6 -1
  106. package/src/sdk/broker/broker.ts +10 -1
  107. package/src/sdk/broker/ensure.ts +76 -3
  108. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  109. package/src/sdk/broker/lifecycle.ts +254 -34
  110. package/src/sdk/broker/spawn-substrate.ts +45 -2
  111. package/src/sdk/broker/startup-budget.ts +144 -2
  112. package/src/sdk/broker/state-version.ts +17 -0
  113. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  114. package/src/sdk/bus/chat-daemon-runtime.ts +118 -24
  115. package/src/sdk/bus/discord-daemon.ts +50 -10
  116. package/src/sdk/bus/index.ts +33 -3
  117. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  118. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  119. package/src/sdk/bus/notification-service.ts +22 -5
  120. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  121. package/src/sdk/bus/reconciliation-store.ts +55 -31
  122. package/src/sdk/bus/slack-conversation.ts +1 -1
  123. package/src/sdk/bus/slack-daemon.ts +72 -38
  124. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  125. package/src/sdk/cli/rows.ts +75 -7
  126. package/src/sdk/cli/session-cli.ts +68 -35
  127. package/src/sdk/host/control/dispatch.ts +2 -2
  128. package/src/sdk/host/events.ts +26 -2
  129. package/src/sdk/host/host.ts +6 -1
  130. package/src/sdk/host/query/cursor.ts +52 -7
  131. package/src/sdk/host/query/handlers.ts +33 -12
  132. package/src/sdk/host/query/revision-store.ts +12 -0
  133. package/src/sdk/host/session-runtime.test.ts +497 -31
  134. package/src/sdk/host/session-runtime.ts +387 -54
  135. package/src/sdk/host/surface-policy.ts +3 -3
  136. package/src/sdk/lifecycle-session.ts +7 -2
  137. package/src/sdk/prompt-deadline-manager.ts +2 -1
  138. package/src/sdk/protocol/operation-inventory.generated.json +54 -3
  139. package/src/sdk/protocol/operation-registry.ts +1 -1
  140. package/src/sdk/receipt-state.ts +10 -0
  141. package/src/sdk/router/session-router.ts +210 -94
  142. package/src/sdk/session.ts +59 -41
  143. package/src/sdk/turn-result.ts +22 -0
  144. package/src/session/agent-session.ts +1033 -260
  145. package/src/session/fold-coordinator.ts +40 -3
  146. package/src/session/session-manager.ts +517 -77
  147. package/src/slash-commands/builtin-registry.ts +41 -0
  148. package/src/task/executor.ts +9 -4
  149. package/src/tools/ask-contract.test.ts +64 -0
  150. package/src/tools/ask-contract.ts +36 -1
  151. package/src/tools/ask.ts +10 -8
  152. package/src/tools/bash-interactive.ts +11 -2
  153. package/src/tools/bash.ts +158 -33
  154. package/src/tools/browser/launch.ts +37 -0
  155. package/src/tools/browser/registry.ts +99 -9
  156. package/src/tools/cron.ts +6 -4
  157. package/src/tools/descriptors.test.ts +15 -0
  158. package/src/tools/descriptors.ts +5 -1
  159. package/src/tools/index.ts +4 -6
  160. package/src/tools/job.ts +4 -1
  161. package/src/tools/monitor.ts +12 -9
  162. package/src/tools/resolve.ts +30 -10
  163. package/src/tools/tool-catalog.generated.ts +2 -2
  164. package/src/utils/external-editor.ts +86 -1
@@ -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,
@@ -613,14 +616,15 @@ async function workspaceIdentity(target: string): Promise<WorkspaceIdentity> {
613
616
  /**
614
617
  * Resolves the list selection from `--repo` (default: process cwd) under the
615
618
  * effective scope. Outside Git, `repo`/`worktree` fail typed and actionable —
616
- * they never broaden to `all`; `cwd` remains an exact canonical match.
619
+ * they never broaden to `all`; `cwd` remains an exact canonical match, and
620
+ * `all` ignores the selection entirely so it works outside any checkout.
617
621
  */
618
622
  export async function resolveSessionListSelection(
619
623
  scope: SdkSessionListScope,
620
624
  repoArg: string | undefined,
621
625
  ): Promise<SdkSessionListSelection> {
622
626
  const selection = await workspaceIdentity(repoArg ?? process.cwd());
623
- if (scope !== "cwd" && !selection.repoRoot)
627
+ if (scope !== "cwd" && scope !== "all" && !selection.repoRoot)
624
628
  throw new SdkSessionCliError(
625
629
  "not_a_repository",
626
630
  `--scope ${scope} requires a Git repository, but "${selection.canonicalPath}" is outside any Git checkout. ` +
@@ -969,11 +973,7 @@ function extractCheckpoint(response: unknown): CheckpointExtraction {
969
973
  if (gap !== undefined) return { gap };
970
974
  const record = toCheckpointRecordV1(result.checkpoint ?? result);
971
975
  const cursor =
972
- typeof result.cursor === "string" && result.cursor
973
- ? result.cursor
974
- : typeof result.checkpointToken === "string" && result.checkpointToken
975
- ? result.checkpointToken
976
- : undefined;
976
+ typeof result.checkpointToken === "string" && result.checkpointToken ? result.checkpointToken : undefined;
977
977
  return { record, cursor };
978
978
  }
979
979
 
@@ -988,13 +988,6 @@ function extractTranscriptPage(response: unknown): { items: unknown[]; complete:
988
988
  };
989
989
  }
990
990
 
991
- function tailItemKey(item: SdkTailItemV1): string {
992
- if (item.generation !== undefined && item.seq !== undefined)
993
- return `${item.kind}\u0000${item.generation}\u0000${item.seq}`;
994
- if (item.id !== undefined) return `${item.kind}\u0000${item.id}`;
995
- return `${item.kind}\u0000${JSON.stringify(item.payload)}`;
996
- }
997
-
998
991
  function mergeTailItems(
999
992
  target: SdkTailItemV1[],
1000
993
  seen: Set<string>,
@@ -1009,8 +1002,9 @@ function mergeTailItems(
1009
1002
  }
1010
1003
  }
1011
1004
 
1012
- /** Canonical ring position of an event item, when the host stated one. */
1013
- 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">;
1014
1008
 
1015
1009
  function isPositionCoordinate(value: unknown): value is number {
1016
1010
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
@@ -1060,7 +1054,7 @@ function classifyRawEventPosition(event: unknown): "unpositioned" | "malformed"
1060
1054
  * it is never silently downgraded to unpositioned. Claiming neither coordinate
1061
1055
  * is a genuine unpositioned event.
1062
1056
  */
1063
- function classifyTailPosition(item: SdkTailItemV1): TailSeqKey | "unpositioned" | "malformed" {
1057
+ function classifyTailPosition(item: SdkTailItemV1): TailLocalPosition | "unpositioned" | "malformed" {
1064
1058
  if (item.generation === undefined && item.seq === undefined) return "unpositioned";
1065
1059
  if (!isPositionCoordinate(item.generation) || !isPositionCoordinate(item.seq)) return "malformed";
1066
1060
  return { generation: item.generation, seq: item.seq };
@@ -1068,10 +1062,13 @@ function classifyTailPosition(item: SdkTailItemV1): TailSeqKey | "unpositioned"
1068
1062
 
1069
1063
  function tailSeqKey(item: SdkTailItemV1): TailSeqKey | undefined {
1070
1064
  const position = classifyTailPosition(item);
1071
- return typeof position === "string" ? undefined : position;
1065
+ return typeof position === "string" || !isPositionCoordinate(item.revision)
1066
+ ? undefined
1067
+ : { revision: item.revision, ...position };
1072
1068
  }
1073
1069
 
1074
1070
  function compareTailSeqKeys(left: TailSeqKey, right: TailSeqKey): number {
1071
+ if (left.revision !== right.revision) return left.revision - right.revision;
1075
1072
  return left.generation !== right.generation ? left.generation - right.generation : left.seq - right.seq;
1076
1073
  }
1077
1074
 
@@ -1115,10 +1112,12 @@ function mergeEventTailItems(
1115
1112
  target.unpositioned.push(item);
1116
1113
  continue;
1117
1114
  }
1115
+ const orderedPosition = tailSeqKey(item);
1116
+ if (orderedPosition === undefined) throw new Error("A positioned tail item has no authoritative revision.");
1118
1117
  let index = target.positioned.length;
1119
1118
  for (; index > 0; index--) {
1120
1119
  const previous = tailSeqKey(target.positioned[index - 1]!);
1121
- if (previous === undefined || compareTailSeqKeys(previous, position) <= 0) break;
1120
+ if (previous === undefined || compareTailSeqKeys(previous, orderedPosition) <= 0) break;
1122
1121
  }
1123
1122
  target.positioned.splice(index, 0, item);
1124
1123
  }
@@ -1150,12 +1149,6 @@ function eventGapToRetentionGap(
1150
1149
  return undefined;
1151
1150
  }
1152
1151
 
1153
- function publicationSequence(publicationId: string): number | undefined {
1154
- const parts = publicationId.split(":");
1155
- const value = Number(parts.at(-1));
1156
- return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
1157
- }
1158
-
1159
1152
  function tailItemFromRouterFrame(frame: SessionRouterFrame): SdkTailItemV1 | undefined {
1160
1153
  if (frame.name === undefined) return undefined;
1161
1154
  // The replay response is an out-of-band control frame, not an event-ring
@@ -1163,18 +1156,18 @@ function tailItemFromRouterFrame(frame: SessionRouterFrame): SdkTailItemV1 | und
1163
1156
  // intentionally has no event `seq`; projecting it as a tail item would make
1164
1157
  // the strict event-position validator reject every otherwise valid tail.
1165
1158
  if (frame.name === "event_replay_result") return undefined;
1166
- const seq = frame.publicationId === undefined ? undefined : publicationSequence(frame.publicationId);
1159
+ const seq = frame.seq;
1167
1160
  return toTailItemV1(
1168
1161
  {
1169
1162
  kind: frame.name,
1170
- ...(frame.generation === undefined ? {} : { generation: frame.generation }),
1171
- ...(seq === undefined ? {} : { seq }),
1163
+ ...(frame.revision === undefined ? {} : { revision: frame.revision }),
1164
+ ...(seq === undefined ? {} : { generation: frame.generation, seq }),
1172
1165
  payload: frame.body,
1173
1166
  },
1174
1167
  {
1175
1168
  kind: frame.name,
1176
- ...(frame.generation === undefined ? {} : { generation: frame.generation }),
1177
- ...(seq === undefined ? {} : { seq }),
1169
+ ...(frame.revision === undefined ? {} : { revision: frame.revision }),
1170
+ ...(seq === undefined ? {} : { generation: frame.generation, seq }),
1178
1171
  },
1179
1172
  );
1180
1173
  }
@@ -1388,7 +1381,9 @@ async function offlineTailReplay(
1388
1381
  version: SESSION_ROWS_VERSION,
1389
1382
  source: "offline",
1390
1383
  session: row,
1391
- items: entries.map((entry, index) => toTailItemV1(entry, { kind: "transcript", seq: index })),
1384
+ items: entries.map((entry, index) =>
1385
+ toTailItemV1(entry, { kind: "transcript", revision: index + 1, seq: index }),
1386
+ ),
1392
1387
  terminal: true,
1393
1388
  },
1394
1389
  };
@@ -1415,6 +1410,7 @@ async function runLiveTail(
1415
1410
  // evidence.
1416
1411
  const seenTranscript = new Set<string>();
1417
1412
  const seenEvents = new Set<string>();
1413
+ const liveRevisionBuffer = new TailRevisionBuffer();
1418
1414
  let checkpoint: SdkCheckpointRecordV1 | undefined;
1419
1415
  let gap: SdkRetentionGapV1 | undefined;
1420
1416
  let liveReason: TailExitReason | undefined;
@@ -1454,7 +1450,7 @@ async function runLiveTail(
1454
1450
  // close at a contested position win instead of failing closed, and
1455
1451
  // checking it after the monotonic guard would silently skip a
1456
1452
  // conflicting kind at an already-decided position.
1457
- const positionKey = `${position.generation}\u0000${position.seq}`;
1453
+ const positionKey = `${position.revision}\u0000${position.generation}\u0000${position.seq}`;
1458
1454
  const claimed = claimedLifecycleKinds.get(positionKey);
1459
1455
  if (claimed !== undefined && claimed !== item.kind) {
1460
1456
  malformed ??= new SdkSessionCliError(
@@ -1496,7 +1492,17 @@ async function runLiveTail(
1496
1492
  if (attachment.sessionId !== sessionId) return;
1497
1493
  const item = tailItemFromRouterFrame(frame);
1498
1494
  if (!item) return;
1499
- 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
+ }
1500
1506
  };
1501
1507
 
1502
1508
  return await withRouter(
@@ -1518,6 +1524,10 @@ async function runLiveTail(
1518
1524
  const extraction = extractCheckpoint(checkpointResponse);
1519
1525
  checkpoint = extraction.record;
1520
1526
  gap = extraction.gap;
1527
+ if (checkpoint !== undefined)
1528
+ applyLifecycle(
1529
+ mergeEventTailItems(eventItems, seenEvents, liveRevisionBuffer.resolve(checkpoint.revision), include),
1530
+ );
1521
1531
  if (gap !== undefined && args.strict === true)
1522
1532
  throw new SdkSessionCliError(
1523
1533
  "retention_gap",
@@ -1537,10 +1547,18 @@ async function runLiveTail(
1537
1547
  );
1538
1548
  throwResponseFailure(response);
1539
1549
  const page = extractTranscriptPage(response);
1550
+ const rev = checkpoint?.revision;
1551
+ let nextSeq = transcriptItems.length;
1540
1552
  mergeTailItems(
1541
1553
  transcriptItems,
1542
1554
  seenTranscript,
1543
- page.items.map(item => toTailItemV1(item, { kind: "transcript" })),
1555
+ page.items.map(item => {
1556
+ const it = toTailItemV1(item, { kind: "transcript" });
1557
+ if (it.revision === undefined && rev !== undefined) it.revision = rev;
1558
+ if (it.generation === undefined) it.generation = checkpoint?.generation ?? 0;
1559
+ if (it.seq === undefined) it.seq = nextSeq++;
1560
+ return it;
1561
+ }),
1544
1562
  include,
1545
1563
  );
1546
1564
  if (page.complete || page.cursor === undefined) break;
@@ -1586,9 +1604,24 @@ async function runLiveTail(
1586
1604
  raw.seq,
1587
1605
  );
1588
1606
  }
1589
- const replayItems = rawEvents.map(event => toTailItemV1(event, { kind: "event" }));
1607
+ const replayItems = rawEvents.map(event => {
1608
+ const it = toTailItemV1(event, { kind: "event" });
1609
+ if (it.revision === undefined && checkpoint?.revision !== undefined) it.revision = checkpoint.revision;
1610
+ return it;
1611
+ });
1590
1612
  applyLifecycle(mergeEventTailItems(eventItems, seenEvents, replayItems, include));
1591
1613
  if (malformed !== undefined) throw malformed;
1614
+ if (checkpoint !== undefined) {
1615
+ const checkpointKey = {
1616
+ revision: checkpoint.revision,
1617
+ generation: checkpoint.generation,
1618
+ seq: checkpoint.seq,
1619
+ };
1620
+ if (turnStateKey === undefined || compareTailSeqKeys(checkpointKey, turnStateKey) >= 0) {
1621
+ turnStateKey = checkpointKey;
1622
+ turnIdle = checkpoint.idle;
1623
+ }
1624
+ }
1592
1625
  if (liveReason === undefined && args.untilIdle === true && turnIdle) liveReason = "idle";
1593
1626
  if (liveReason === undefined) {
1594
1627
  const completion = Promise.withResolvers<TailExitReason>();
@@ -291,8 +291,8 @@ function invoke(
291
291
  return surface.setQueueMode("steering", input.mode);
292
292
  case "queue.follow_up_mode.set":
293
293
  return surface.setQueueMode("follow_up", input.mode);
294
- case "queue.interrupt_mode.set":
295
- return surface.setQueueMode("interrupt", input.mode);
294
+ case "queue.tool_interrupt_policy.set":
295
+ return surface.setQueueMode("tool_interrupt", input.mode);
296
296
  case "compaction.run":
297
297
  return surface.runCompaction();
298
298
  case "compaction.auto.set":
@@ -2,6 +2,7 @@ import type { SdkFrame } from "./types";
2
2
 
3
3
  export interface EventFrame extends SdkFrame {
4
4
  type: "event";
5
+ revision?: number;
5
6
  generation: number;
6
7
  seq: number;
7
8
  }
@@ -31,11 +32,20 @@ export class SessionEventStream {
31
32
  #frames: EventFrame[] = [];
32
33
  readonly #ringSize: number;
33
34
  readonly #resyncQueries: string[];
35
+ readonly #revisionProvider: (() => number | undefined) | undefined;
34
36
 
35
- 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
+ ) {
36
45
  this.#generation = options.generation ?? 0;
37
46
  this.#ringSize = options.ringSize ?? 256;
38
47
  this.#resyncQueries = options.resyncQueryIds ?? ["Q01", "Q02", "Q03"];
48
+ this.#revisionProvider = options.revisionProvider;
39
49
  }
40
50
 
41
51
  get generation(): number {
@@ -53,7 +63,21 @@ export class SessionEventStream {
53
63
  }
54
64
 
55
65
  emit(frame: SdkFrame): EventFrame {
56
- 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
+ };
57
81
  this.#frames.push(event);
58
82
  if (this.#frames.length > this.#ringSize) this.#frames.shift();
59
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,
@@ -354,6 +357,8 @@ export class SessionSdkHost {
354
357
  if (this.events.generation !== generation) return "generation_changed";
355
358
  if (this.#readyGeneration === generation) return "already";
356
359
  }
360
+ if (!this.#started || this.#stopping) return "not_prepared";
361
+ if (this.events.generation !== generation) return "generation_changed";
357
362
  this.#publishReadiness();
358
363
  return "activated";
359
364
  }
@@ -18,6 +18,7 @@ export interface CursorEnvelope {
18
18
  issuedAt?: number;
19
19
  /** TTL metadata minted by `CursorRegistry.grant`: wall-clock ms when the cursor expires. */
20
20
  expiresAt?: number;
21
+ purpose?: "checkpoint";
21
22
  position: unknown;
22
23
  direction: string;
23
24
  pageShape: unknown;
@@ -68,6 +69,7 @@ export interface CursorQueryContext {
68
69
  resourceId?: string;
69
70
  direction: string;
70
71
  pageShape: unknown;
72
+ purpose?: "checkpoint";
71
73
  }
72
74
 
73
75
  export class CursorError extends Error {
@@ -170,26 +172,68 @@ export class CursorRegistry {
170
172
  cursor: string,
171
173
  connectionId: string,
172
174
  expected: CursorQueryContext,
173
- resourceKind: string,
175
+ _resourceKind: string,
174
176
  resourceId: string,
175
177
  ): Promise<{ cursor: string; envelope: CursorEnvelope }> {
176
178
  const envelope = verifyCursor(cursor, this.sessionToken);
177
- if (!envelope) throw new CursorError("invalid_cursor", false);
178
- if (envelope.expiresAt === undefined || envelope.expiresAt <= this.now()) throw new CursorError("cursor_expired");
179
+ if (!envelope) {
180
+ this.release(cursor);
181
+ throw new CursorError("invalid_cursor", true);
182
+ }
183
+ const source = this.#active.get(cursor);
184
+ if (!source || envelope.expiresAt === undefined) {
185
+ this.release(cursor);
186
+ throw new CursorError("invalid_cursor", true);
187
+ }
188
+ const validatedAt = this.now();
189
+ if (source.expiresAt <= validatedAt || envelope.expiresAt <= validatedAt) {
190
+ this.release(cursor);
191
+ throw new CursorError("cursor_expired");
192
+ }
179
193
  if (
180
194
  envelope.sessionId !== expected.sessionId ||
181
195
  (expected.resource !== undefined && envelope.resource !== expected.resource) ||
182
196
  envelope.direction !== expected.direction ||
183
- canonicalJson(envelope.pageShape) !== canonicalJson(expected.pageShape)
197
+ canonicalJson(envelope.pageShape) !== canonicalJson(expected.pageShape) ||
198
+ envelope.purpose !== expected.purpose ||
199
+ _resourceKind !== envelope.resource ||
200
+ source.resourceId !== resourceId
184
201
  )
185
202
  throw new CursorError("invalid_input", false, "cursor does not match query");
203
+ // Preserve the already-validated source while removing expired target
204
+ // grants before capacity accounting. The shared timestamp prevents the
205
+ // source from changing classification at a TTL boundary mid-exchange.
206
+ this.sweep(cursor, validatedAt);
207
+ const targetConnectionCount = [...this.#active.values()].filter(
208
+ item => item.connectionId === connectionId,
209
+ ).length;
210
+ if (
211
+ this.#active.size > MAX_CURSORS_PER_SESSION ||
212
+ targetConnectionCount - (source.connectionId === connectionId ? 1 : 0) >= MAX_CURSORS_PER_CONNECTION
213
+ )
214
+ throw new CursorError("snapshot_capacity_exceeded", false);
186
215
  const exchanged: CursorEnvelope = {
187
216
  ...envelope,
188
217
  nonce: randomUUID(),
189
218
  issuedAt: undefined,
190
219
  expiresAt: undefined,
191
220
  };
192
- const exchangedCursor = await this.grant(connectionId, exchanged, resourceKind, resourceId);
221
+ const issuedAt = this.now();
222
+ exchanged.issuedAt = issuedAt;
223
+ exchanged.expiresAt = issuedAt + CURSOR_TTL_MS;
224
+ const exchangedCursor = signCursor(exchanged, this.sessionToken);
225
+ try {
226
+ this.revisions.transferPin(cursor, exchangedCursor);
227
+ } catch (error) {
228
+ if (error instanceof RevisionStoreError) throw new CursorError(error.code, false, error.message);
229
+ throw error;
230
+ }
231
+ this.#active.delete(cursor);
232
+ this.#active.set(exchangedCursor, {
233
+ connectionId,
234
+ resourceId,
235
+ expiresAt: exchanged.expiresAt,
236
+ });
193
237
  return { cursor: exchangedCursor, envelope: exchanged };
194
238
  }
195
239
 
@@ -227,8 +271,9 @@ export class CursorRegistry {
227
271
  close(): void {
228
272
  for (const cursor of this.#active.keys()) this.release(cursor);
229
273
  }
230
- sweep(): void {
231
- for (const [cursor, active] of this.#active) if (active.expiresAt <= this.now()) this.release(cursor);
274
+ sweep(exceptCursor?: string, at = this.now()): void {
275
+ for (const [cursor, active] of this.#active)
276
+ if (cursor !== exceptCursor && active.expiresAt <= at) this.release(cursor);
232
277
  this.revisions.sweep();
233
278
  }
234
279
  get size(): number {
@@ -108,6 +108,7 @@ export interface SdkCheckpointRecord {
108
108
  revision: number;
109
109
  generation: number;
110
110
  seq: number;
111
+ idle: boolean;
111
112
  }
112
113
 
113
114
  function isNonNegativeSafeInteger(value: unknown): value is number {
@@ -119,10 +120,11 @@ export function isCheckpointRecord(value: unknown): value is SdkCheckpointRecord
119
120
  const record = value as Record<string, unknown>;
120
121
  const keys = Object.keys(record);
121
122
  return (
122
- keys.length === 3 &&
123
+ keys.length === 4 &&
123
124
  isNonNegativeSafeInteger(record.revision) &&
124
125
  isNonNegativeSafeInteger(record.generation) &&
125
- isNonNegativeSafeInteger(record.seq)
126
+ isNonNegativeSafeInteger(record.seq) &&
127
+ typeof record.idle === "boolean"
126
128
  );
127
129
  }
128
130
  const sources: Record<string, { resource: string; method: keyof SessionSurface; mvcc: boolean }> = {
@@ -296,9 +298,10 @@ export class QueryHandlers {
296
298
  let snapshot: unknown;
297
299
  // The cursor may arrive either as the top-level continuation cursor or,
298
300
  // on Q01 only (validated in `dispatch`), as the `checkpointToken` resume
299
- // seed. Both are signed checkpoint cursors consumed through the same
300
- // CursorRegistry authority; neither mints a fresh snapshot.
301
- const rawCursorToken = request.cursor ?? (queryId === "Q01" ? request.input?.checkpointToken : undefined);
301
+ // seed. Both are consumed through the same CursorRegistry authority, but
302
+ // only the named input token is required to carry checkpoint purpose.
303
+ const checkpointToken = queryId === "Q01" ? request.input?.checkpointToken : undefined;
304
+ const rawCursorToken = request.cursor ?? checkpointToken;
302
305
  if (rawCursorToken !== undefined && typeof rawCursorToken !== "string")
303
306
  return this.#error(request, "invalid_request", false, "checkpointToken must be a non-empty string.");
304
307
  const cursorToken = rawCursorToken;
@@ -309,6 +312,8 @@ export class QueryHandlers {
309
312
  direction: "forward",
310
313
  pageShape: { targetBytes: TARGET_PAGE_BYTES },
311
314
  });
315
+ if (checkpointToken !== undefined && cursor.purpose !== "checkpoint")
316
+ throw new CursorError("invalid_input", false, "checkpointToken does not carry checkpoint purpose");
312
317
  selector = assertCursorSelector(cursorSelector(cursor.position), selector);
313
318
  resourceId = selector.resourceId ?? "default";
314
319
  revision = cursor.revision;
@@ -467,10 +472,20 @@ export class QueryHandlers {
467
472
  resource: "transcript",
468
473
  direction: "forward",
469
474
  pageShape: { targetBytes: TARGET_PAGE_BYTES },
475
+ purpose: "checkpoint",
470
476
  },
471
477
  "transcript",
472
478
  "default",
473
479
  );
480
+ if (!isCheckpointRecord(exchanged.envelope.highWatermark)) {
481
+ this.cursors.release(exchanged.cursor);
482
+ return this.#error(
483
+ request,
484
+ "invalid_input",
485
+ false,
486
+ "checkpointToken is not an authoritative checkpoint.",
487
+ );
488
+ }
474
489
  return {
475
490
  id: request.id,
476
491
  ok: true,
@@ -494,13 +509,18 @@ export class QueryHandlers {
494
509
  // Atomic synchronous capture (C9): entries and event-ring watermark come
495
510
  // from the same host-owned call, so the pinned snapshot revision and the
496
511
  // subscribe position can never straddle a concurrent append.
497
- const captured =
498
- typeof this.surface.getCheckpointSnapshot === "function" ? this.surface.getCheckpointSnapshot() : undefined;
499
- const entries = captured !== undefined ? captured.entries : await this.surface.getTranscriptEntries();
500
- const head: SdkCheckpointRecord =
501
- captured !== undefined
502
- ? captured.watermark
503
- : { revision: Array.isArray(entries) ? entries.length : 0, generation: 0, seq: 0 };
512
+ if (typeof this.surface.getCheckpointSnapshot !== "function")
513
+ return this.#error(request, "unavailable", false, "Atomic session checkpoint capture is unavailable.");
514
+ const captured = this.surface.getCheckpointSnapshot();
515
+ const entries = captured.entries;
516
+ const head = captured.watermark;
517
+ if (!isCheckpointRecord(head))
518
+ return this.#error(
519
+ request,
520
+ "unavailable",
521
+ false,
522
+ "Atomic session checkpoint capture returned an invalid watermark.",
523
+ );
504
524
  // Mint the snapshot from the exact entries captured with the watermark;
505
525
  // entries appended after this point are excluded from replay by
506
526
  // construction (append-during-checkpoint), and the returned token is the
@@ -517,6 +537,7 @@ export class QueryHandlers {
517
537
  highWatermark: head,
518
538
  nonce,
519
539
  position: { offset: 0, selector: { queryId: "Q01" } },
540
+ purpose: "checkpoint",
520
541
  direction: "forward",
521
542
  pageShape: { targetBytes: TARGET_PAGE_BYTES },
522
543
  };
@@ -524,6 +524,18 @@ export class RevisionStore {
524
524
  await this.#enforceMemory();
525
525
  }
526
526
 
527
+ transferPin(sourceCursorId: string, replacementCursorId: string): void {
528
+ if (sourceCursorId === replacementCursorId) return;
529
+ if (this.#pinIndex.has(replacementCursorId))
530
+ throw new RevisionStoreError("snapshot_capacity_exceeded", "replacement cursor is already pinned");
531
+ const revision = this.#pinIndex.get(sourceCursorId);
532
+ if (!revision) throw new RevisionStoreError("snapshot_capacity_exceeded", "snapshot pin is unavailable");
533
+ revision.pins.delete(sourceCursorId);
534
+ revision.pins.add(replacementCursorId);
535
+ this.#pinIndex.delete(sourceCursorId);
536
+ this.#pinIndex.set(replacementCursorId, revision);
537
+ }
538
+
527
539
  unpin(cursorId: string): void {
528
540
  const revision = this.#pinIndex.get(cursorId);
529
541
  if (!revision) return;