@gajae-code/coding-agent 0.12.5 → 0.12.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.
Files changed (127) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +4 -1
  3. package/dist/types/eval/backend.d.ts +2 -2
  4. package/dist/types/exec/bash-executor.d.ts +10 -8
  5. package/dist/types/extensibility/extensions/runner.d.ts +9 -7
  6. package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
  7. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  8. package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
  9. package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
  10. package/dist/types/gjc-runtime/team-store.d.ts +0 -4
  11. package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
  12. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  13. package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
  14. package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
  15. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
  16. package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +83 -0
  17. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +57 -4
  18. package/dist/types/internal-urls/registry-helpers.d.ts +18 -0
  19. package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
  20. package/dist/types/modes/components/plugin-settings.d.ts +2 -0
  21. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  22. package/dist/types/sdk/broker/broker.d.ts +18 -0
  23. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
  25. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -3
  26. package/dist/types/sdk/bus/index.d.ts +4 -0
  27. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -2
  28. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
  29. package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
  30. package/dist/types/sdk/host/host.d.ts +23 -2
  31. package/dist/types/sdk/host/reverse-leases.d.ts +7 -4
  32. package/dist/types/session/agent-session.d.ts +5 -4
  33. package/dist/types/session/artifacts.d.ts +8 -5
  34. package/dist/types/session/attempt-record-store.d.ts +19 -0
  35. package/dist/types/session/client-bridge.d.ts +14 -0
  36. package/dist/types/session/internal/managed-session-storage.d.ts +5 -2
  37. package/dist/types/session/messages.d.ts +4 -1
  38. package/dist/types/session/session-manager.d.ts +6 -4
  39. package/dist/types/session/session-storage.d.ts +8 -0
  40. package/dist/types/session/streaming-output.d.ts +32 -0
  41. package/dist/types/task/receipt.d.ts +2 -0
  42. package/dist/types/task/types.d.ts +178 -0
  43. package/dist/types/tools/bash.d.ts +1 -1
  44. package/dist/types/tools/fetch.d.ts +3 -0
  45. package/dist/types/tools/output-meta.d.ts +37 -5
  46. package/dist/types/tools/tool-result.d.ts +1 -0
  47. package/dist/types/utils/artifact-id.d.ts +2 -0
  48. package/package.json +8 -8
  49. package/scripts/capture-sticky-viewport-showcase.ts +131 -5
  50. package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
  51. package/scripts/verify-sticky-viewport-showcase.ts +620 -93
  52. package/src/config/model-profiles.ts +8 -0
  53. package/src/config/settings-schema.ts +17 -3
  54. package/src/cursor.ts +20 -13
  55. package/src/defaults/gjc/skills/ultragoal/SKILL.md +5 -2
  56. package/src/eval/backend.ts +2 -2
  57. package/src/exec/bash-executor.ts +425 -151
  58. package/src/extensibility/extensions/runner.ts +80 -6
  59. package/src/extensibility/extensions/wrapper.ts +22 -15
  60. package/src/extensibility/runtime-skill-discovery.ts +4 -6
  61. package/src/gjc-runtime/launch-tmux.ts +309 -293
  62. package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
  63. package/src/gjc-runtime/psmux-detect.ts +52 -6
  64. package/src/gjc-runtime/team-launch.ts +50 -2
  65. package/src/gjc-runtime/team-runtime.ts +716 -251
  66. package/src/gjc-runtime/team-store.ts +25 -7
  67. package/src/gjc-runtime/team-workers.ts +161 -1
  68. package/src/gjc-runtime/tmux-common.ts +16 -4
  69. package/src/gjc-runtime/tmux-gc.ts +37 -5
  70. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
  71. package/src/gjc-runtime/tmux-provider-context.ts +451 -0
  72. package/src/gjc-runtime/tmux-sessions.ts +409 -156
  73. package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
  74. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
  75. package/src/gjc-runtime/ultragoal-review-source.ts +378 -0
  76. package/src/gjc-runtime/ultragoal-runtime.ts +640 -159
  77. package/src/gjc-runtime/workflow-manifest.generated.json +40 -1
  78. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  79. package/src/internal-urls/agent-protocol.ts +98 -38
  80. package/src/internal-urls/artifact-protocol.ts +28 -17
  81. package/src/internal-urls/docs-index.generated.ts +5 -5
  82. package/src/internal-urls/local-protocol.ts +39 -18
  83. package/src/internal-urls/registry-helpers.ts +62 -0
  84. package/src/modes/acp/acp-agent.ts +51 -13
  85. package/src/modes/acp/acp-client-bridge.ts +154 -11
  86. package/src/modes/components/gjc-bundle-settings.ts +2 -0
  87. package/src/modes/components/plugin-settings.ts +5 -0
  88. package/src/modes/components/settings-selector.ts +4 -0
  89. package/src/modes/controllers/selector-controller.ts +1 -0
  90. package/src/prompts/tools/bash.md +2 -2
  91. package/src/prompts/tools/monitor.md +4 -4
  92. package/src/sdk/acp/adapter.ts +200 -9
  93. package/src/sdk/broker/broker.ts +93 -10
  94. package/src/sdk/broker/lifecycle-ledger.ts +227 -11
  95. package/src/sdk/broker/lifecycle.ts +691 -92
  96. package/src/sdk/bus/chat-daemon-control.ts +32 -6
  97. package/src/sdk/bus/index.ts +347 -14
  98. package/src/sdk/bus/telegram-daemon-contract.ts +4 -2
  99. package/src/sdk/bus/telegram-daemon.ts +283 -44
  100. package/src/sdk/bus/topic-registry.ts +133 -10
  101. package/src/sdk/host/host.ts +187 -9
  102. package/src/sdk/host/reverse-leases.ts +249 -42
  103. package/src/sdk/session.ts +7 -6
  104. package/src/session/agent-session.ts +930 -354
  105. package/src/session/artifacts.ts +211 -74
  106. package/src/session/attempt-record-store.ts +106 -0
  107. package/src/session/client-bridge.ts +15 -0
  108. package/src/session/internal/managed-session-scope.ts +252 -67
  109. package/src/session/internal/managed-session-storage.ts +105 -5
  110. package/src/session/messages.ts +32 -7
  111. package/src/session/session-manager.ts +19 -21
  112. package/src/session/session-storage.ts +146 -24
  113. package/src/session/streaming-output.ts +198 -40
  114. package/src/task/index.ts +45 -0
  115. package/src/task/provider-retry-status.ts +4 -1
  116. package/src/task/receipt.ts +4 -0
  117. package/src/task/render.ts +30 -0
  118. package/src/task/types.ts +34 -0
  119. package/src/tools/bash.ts +818 -239
  120. package/src/tools/eval.ts +99 -4
  121. package/src/tools/fetch.ts +77 -15
  122. package/src/tools/find.ts +7 -3
  123. package/src/tools/monitor.ts +7 -1
  124. package/src/tools/output-meta.ts +706 -116
  125. package/src/tools/search.ts +9 -4
  126. package/src/tools/tool-result.ts +1 -0
  127. package/src/utils/artifact-id.ts +6 -0
@@ -94,6 +94,18 @@ export interface TopicDeleteAuthoritySnapshot {
94
94
  record?: TopicRecord;
95
95
  }
96
96
 
97
+ /**
98
+ * Proof that a definite delete was settled in memory and still owes its durable
99
+ * commit. Handed back only by a settlement that actually happened, so a refused
100
+ * settlement structurally cannot be followed by a rollback.
101
+ */
102
+ export interface TopicSettledDelete {
103
+ sessionId: string;
104
+ topicId: string;
105
+ /** Authority epoch proven current when the record was removed. */
106
+ settledEpoch: number;
107
+ }
108
+
97
109
  function isValidBindingString(value: unknown): value is string {
98
110
  return typeof value === "string" && value.trim().length > 0;
99
111
  }
@@ -136,6 +148,16 @@ function isValidTopicId(value: unknown): value is string {
136
148
  );
137
149
  }
138
150
 
151
+ /**
152
+ * Monotonic epoch successor that saturates instead of leaving the safe-integer
153
+ * range. Past `Number.MAX_SAFE_INTEGER` two distinct generations collapse onto
154
+ * the same IEEE-754 double, which would let a stale settlement clear a newer
155
+ * fence; settlement refuses the saturated value instead of overflowing past it.
156
+ */
157
+ function nextAuthorityEpoch(current: number): number {
158
+ return current >= Number.MAX_SAFE_INTEGER ? Number.MAX_SAFE_INTEGER : current + 1;
159
+ }
160
+
139
161
  export function emptyTopicRegistryState(): TopicRegistryState {
140
162
  return { topics: {} };
141
163
  }
@@ -161,6 +183,8 @@ export class TopicRegistry {
161
183
  private readonly creatingBindings = new Map<string, TopicEndpointBinding>();
162
184
  /** Monotonic authority epochs, including deletion fences for absent records. */
163
185
  private readonly epochs = new Map<string, number>();
186
+ /** Phase-1 settlements awaiting their durable clear; their topic ids stay quarantined. */
187
+ readonly #settling = new Map<string, { topicId: string; settledEpoch: number; record: TopicRecord }>();
164
188
 
165
189
  constructor(state: TopicRegistryState = emptyTopicRegistryState()) {
166
190
  this.topics = new Map();
@@ -173,6 +197,7 @@ export class TopicRegistry {
173
197
  this.byTopic.clear();
174
198
  this.#ambiguousTopicIds.clear();
175
199
  this.epochs.clear();
200
+ this.#settling.clear();
176
201
  this.load(state);
177
202
  }
178
203
 
@@ -259,6 +284,10 @@ export class TopicRegistry {
259
284
  private rebuildInboundRoutes(): void {
260
285
  this.byTopic.clear();
261
286
  this.#ambiguousTopicIds.clear();
287
+ // A settled-but-not-yet-durable clear keeps its topic id quarantined: the
288
+ // clear is not authoritative until persisted, so no colliding survivor may
289
+ // become routable and no settled id may become adoptable during that write.
290
+ for (const settling of this.#settling.values()) this.#ambiguousTopicIds.add(settling.topicId);
262
291
  const activeByTopic = new Map<string, string>();
263
292
 
264
293
  for (const [sessionId, record] of this.topics) {
@@ -278,6 +307,13 @@ export class TopicRegistry {
278
307
  }
279
308
  }
280
309
 
310
+ /** Advance and publish the session authority epoch, saturating at the safe-integer bound. */
311
+ #advanceAuthorityEpoch(sessionId: string, base: number): number {
312
+ const epoch = nextAuthorityEpoch(base);
313
+ this.epochs.set(sessionId, epoch);
314
+ return epoch;
315
+ }
316
+
281
317
  /** Resolve the owning session for a topic id (for fail-closed inbound routing). */
282
318
  sessionForTopic(topicId: string): string | undefined {
283
319
  return this.byTopic.get(topicId);
@@ -482,6 +518,7 @@ export class TopicRegistry {
482
518
  const pending = this.inflight.get(sessionId);
483
519
  if (pending) return pending;
484
520
  const epoch = this.epochs.get(sessionId) ?? 0;
521
+ if (epoch >= Number.MAX_SAFE_INTEGER) throw new Error("topic authority epoch exhausted");
485
522
  // Publish the compatible endpoint claim before invoking `create`: the callback
486
523
  // may immediately begin a remote create and identity-less recovery must never
487
524
  // observe a false absence during that await.
@@ -676,7 +713,9 @@ export class TopicRegistry {
676
713
  /** Restore a failed delete fence only while its exact authority mutation remains current. */
677
714
  restoreDeleteAuthority(snapshot: TopicDeleteAuthoritySnapshot): boolean {
678
715
  const record = this.topics.get(snapshot.sessionId);
679
- const deleteEpoch = Math.max(snapshot.fenceEpoch ?? 0, snapshot.authorityEpoch ?? 0) + 1;
716
+ const authorityBase = Math.max(snapshot.fenceEpoch ?? 0, snapshot.authorityEpoch ?? 0);
717
+ if (authorityBase >= Number.MAX_SAFE_INTEGER) return false;
718
+ const deleteEpoch = nextAuthorityEpoch(authorityBase);
680
719
  if (this.epochs.get(snapshot.sessionId) !== deleteEpoch) return false;
681
720
  if (snapshot.topicId === undefined) {
682
721
  if (record) return false;
@@ -697,10 +736,17 @@ export class TopicRegistry {
697
736
  return true;
698
737
  }
699
738
 
700
- /** Restore the exact delete fence after a failed compensation publication. */
739
+ /**
740
+ * Restore the exact delete fence after a failed compensation publication.
741
+ *
742
+ * Settlement rebuilds derived routes, which can make a surviving colliding
743
+ * record routable. Rebuild again on successful restoration so the reinstated
744
+ * fence re-quarantines the topic id instead of leaving inbound routing open to
745
+ * the collision partner despite the restored fence.
746
+ */
701
747
  restoreDeleteFence(snapshot: TopicDeleteAuthoritySnapshot): boolean {
702
748
  const record = this.topics.get(snapshot.sessionId);
703
- const deleteEpoch = Math.max(snapshot.fenceEpoch ?? 0, snapshot.authorityEpoch ?? 0) + 1;
749
+ const deleteEpoch = nextAuthorityEpoch(Math.max(snapshot.fenceEpoch ?? 0, snapshot.authorityEpoch ?? 0));
704
750
  if (snapshot.topicId === undefined) {
705
751
  if (record) return false;
706
752
  } else if (!record) {
@@ -715,17 +761,19 @@ export class TopicRegistry {
715
761
  } else {
716
762
  record.authorityEpoch = deleteEpoch;
717
763
  record.authorityState = "delete_pending";
718
- if (this.byTopic.get(record.topicId) === snapshot.sessionId) this.byTopic.delete(record.topicId);
719
764
  }
720
765
  this.epochs.set(snapshot.sessionId, deleteEpoch);
766
+ this.rebuildInboundRoutes();
721
767
  return true;
722
768
  }
723
769
 
724
770
  /** Fence new work before the remote delete starts, including an absent in-flight create. */
725
771
  beginDelete(sessionId: string): TopicRecord | undefined {
726
772
  const record = this.topics.get(sessionId);
727
- const epoch = Math.max(this.epochs.get(sessionId) ?? 0, record?.authorityEpoch ?? 0) + 1;
728
- this.epochs.set(sessionId, epoch);
773
+ const epoch = this.#advanceAuthorityEpoch(
774
+ sessionId,
775
+ Math.max(this.epochs.get(sessionId) ?? 0, record?.authorityEpoch ?? 0),
776
+ );
729
777
  if (!record) return undefined;
730
778
  record.authorityEpoch = epoch;
731
779
  record.authorityState = "delete_pending";
@@ -799,11 +847,86 @@ export class TopicRegistry {
799
847
  await this.inflight.get(sessionId)?.catch(() => undefined);
800
848
  }
801
849
 
802
- /** Remove only after a definite remote deletion; ambiguity deliberately retains its fence. */
803
- settleDelete(sessionId: string, topicId: string): boolean {
850
+ /**
851
+ * Phase 1 of settling a definite remote delete: remove the record while
852
+ * deliberately RETAINING its topic-id quarantine.
853
+ *
854
+ * `dispatchedAuthorityEpoch` is the authority epoch the caller held when it
855
+ * dispatched the remote delete. Settlement requires that epoch to still equal
856
+ * both the record's own authority epoch and the registry's current epoch for
857
+ * the session, so a held earlier delete can never settle a newer fence: if a
858
+ * scan or close-started delete re-fenced the same session/topic after this
859
+ * delete was dispatched, the stale definite result is refused and the newer
860
+ * `delete_pending` record plus its topic-id quarantine stay intact.
861
+ *
862
+ * An epoch outside the safe-integer range cannot identify a generation, and at
863
+ * `Number.MAX_SAFE_INTEGER` epoch advancement has saturated so distinct
864
+ * generations are no longer distinguishable. Both fail closed: settlement can
865
+ * no longer be proven fresh, so the fence is kept.
866
+ *
867
+ * Derived routing tables are deliberately NOT rebuilt here. Until the cleared
868
+ * snapshot is durably persisted the clear is not authoritative, so publishing
869
+ * routes now would make a colliding survivor routable (and the settled id
870
+ * adoptable) during the held write, and a failed persist would re-quarantine
871
+ * too late. Publish with {@link commitSettledDelete} once the persist
872
+ * succeeds, or undo with {@link rollbackSettledDelete} when it fails.
873
+ */
874
+ settleDelete(sessionId: string, topicId: string, dispatchedAuthorityEpoch: number): TopicSettledDelete | undefined {
875
+ if (!Number.isSafeInteger(dispatchedAuthorityEpoch) || dispatchedAuthorityEpoch < 0) return undefined;
876
+ if (this.#settling.has(sessionId)) return undefined;
804
877
  const record = this.topics.get(sessionId);
805
- if (!record || record.topicId !== topicId || record.authorityState !== "delete_pending") return false;
878
+ if (!record || record.topicId !== topicId || record.authorityState !== "delete_pending") return undefined;
879
+ if ((record.authorityEpoch ?? 0) !== dispatchedAuthorityEpoch) return undefined;
880
+ const currentEpoch = this.authorityEpoch(sessionId);
881
+ if (currentEpoch !== dispatchedAuthorityEpoch) return undefined;
882
+ if (currentEpoch >= Number.MAX_SAFE_INTEGER) return undefined;
806
883
  this.topics.delete(sessionId);
884
+ this.#settling.set(sessionId, { topicId, settledEpoch: dispatchedAuthorityEpoch, record: { ...record } });
885
+ this.#ambiguousTopicIds.add(topicId);
886
+ if (this.byTopic.get(topicId) === sessionId) this.byTopic.delete(topicId);
887
+ return { sessionId, topicId, settledEpoch: dispatchedAuthorityEpoch };
888
+ }
889
+
890
+ /**
891
+ * Phase 2: publish derived routing tables once the cleared state is durable.
892
+ *
893
+ * Only here does the settled topic id lose its quarantine, so a surviving
894
+ * colliding record becomes routable and a settled id becomes adoptable without
895
+ * waiting for a daemon restart.
896
+ */
897
+ commitSettledDelete(settled: TopicSettledDelete): boolean {
898
+ const pending = this.#settling.get(settled.sessionId);
899
+ if (!pending || pending.topicId !== settled.topicId || pending.settledEpoch !== settled.settledEpoch)
900
+ return false;
901
+ this.#settling.delete(settled.sessionId);
902
+ this.rebuildInboundRoutes();
903
+ return true;
904
+ }
905
+
906
+ /**
907
+ * Compare-and-swap undo for a settlement whose durable clear failed.
908
+ *
909
+ * Restoration applies only while the registry still holds exactly the state
910
+ * that this settlement produced: the settlement is still awaiting commit, the
911
+ * session still has no record, and the session epoch is still the settled
912
+ * epoch. Any mismatch means a newer generation intervened, so the restore is
913
+ * refused and the newer state is left untouched. A refused settlement produced
914
+ * no token, so it can never reach this path.
915
+ */
916
+ rollbackSettledDelete(settled: TopicSettledDelete): boolean {
917
+ const pending = this.#settling.get(settled.sessionId);
918
+ if (!pending || pending.topicId !== settled.topicId || pending.settledEpoch !== settled.settledEpoch)
919
+ return false;
920
+ if (this.topics.has(settled.sessionId)) return false;
921
+ if (this.authorityEpoch(settled.sessionId) !== settled.settledEpoch) return false;
922
+ this.#settling.delete(settled.sessionId);
923
+ this.topics.set(settled.sessionId, {
924
+ ...pending.record,
925
+ authorityEpoch: settled.settledEpoch,
926
+ authorityState: "delete_pending",
927
+ });
928
+ this.epochs.set(settled.sessionId, settled.settledEpoch);
929
+ this.rebuildInboundRoutes();
807
930
  return true;
808
931
  }
809
932
 
@@ -811,7 +934,7 @@ export class TopicRegistry {
811
934
  delete(sessionId: string): boolean {
812
935
  const record = this.topics.get(sessionId);
813
936
  if (!record) return false;
814
- this.epochs.set(sessionId, Math.max(this.epochs.get(sessionId) ?? 0, record.authorityEpoch ?? 0) + 1);
937
+ this.#advanceAuthorityEpoch(sessionId, Math.max(this.epochs.get(sessionId) ?? 0, record.authorityEpoch ?? 0));
815
938
  if (this.byTopic.get(record.topicId) === sessionId) this.byTopic.delete(record.topicId);
816
939
  return this.topics.delete(sessionId);
817
940
  }
@@ -4,11 +4,21 @@ import type { BrokerIndexWriter, HostEndpointAdapters, SdkFrame } from "./types"
4
4
 
5
5
  export type SdkRequestObserver = (kind: "control" | "query", connectionId: string, frame: SdkFrame) => void;
6
6
 
7
+ export interface ProviderLeaseIdentity {
8
+ leaseId: string;
9
+ connectionId: string;
10
+ connectionGeneration: number;
11
+ fence: string;
12
+ }
13
+
14
+ export type ProviderLeaseFence = (identity: ProviderLeaseIdentity, reason?: string) => boolean;
15
+
7
16
  export interface SessionSdkHostOptions extends HostEndpointAdapters {
8
17
  control?: (connectionId: string, frame: SdkFrame) => unknown | Promise<unknown>;
9
18
  query?: (connectionId: string, frame: SdkFrame) => unknown | Promise<unknown>;
10
19
  /** Best-effort diagnostic observation of accepted control/query frames. */
11
20
  onRequest?: SdkRequestObserver;
21
+ onFrameOverflow?: (connectionId: string) => void;
12
22
  /** Runs before a control response is sent; identity transitions use sendTerminal. */
13
23
  beforeControlResponse?: (
14
24
  connectionId: string,
@@ -18,13 +28,25 @@ export interface SessionSdkHostOptions extends HostEndpointAdapters {
18
28
  ) => void | Promise<void>;
19
29
  /** Runs only after a successful control response has been sent to the client. */
20
30
  afterControlResponse?: (connectionId: string, request: SdkFrame, response: SdkFrame) => void | Promise<void>;
21
- installProviderDefinitions?: (capability: string, definitions: unknown) => void;
31
+ onProviderLeaseRegistered?: (lease: ProviderLease, connectionGeneration: number) => void;
32
+ installProviderDefinitions?: (
33
+ capability: string,
34
+ definitions: unknown,
35
+ leaseId: string,
36
+ connectionId: string,
37
+ connectionGeneration: number,
38
+ ) => void;
22
39
  onProviderDefinitionsRemoved?: (capability: string) => void;
23
40
  onReverseCancel?: (requestId: string, reason: "provider_disconnected" | "lease_released") => void;
24
41
  /** Best-effort capabilities mirrored from the native transport for out-of-band consumers. */
25
42
  connectionCapabilities?: (connectionId: string) => ReadonlySet<string> | undefined;
26
43
  }
27
44
 
45
+ /** Bound asynchronous frame admission per transport connection. */
46
+ const MAX_PENDING_FRAMES_PER_CONNECTION = 32;
47
+ const MAX_QUARANTINED_PROVIDER_LEASES = 256;
48
+ const MAX_RETAINED_CLOSED_CONNECTIONS = 256;
49
+
28
50
  const TOOL_ACTIVITY_CAPABILITY = "tool_activity_v2";
29
51
  const CAP_GATED_FRAME_KINDS = new Set(["tool_activity", "reasoning_summary"]);
30
52
  const EMPTY_CAPABILITIES: ReadonlySet<string> = new Set();
@@ -120,6 +142,12 @@ export class SessionSdkHost {
120
142
  #stopPromise?: Promise<"stopped">;
121
143
  #unsubscribe?: () => void;
122
144
  #registration?: { writer: BrokerIndexWriter; generation: number };
145
+ #connectionGenerations = new Map<string, number>();
146
+ #openConnections = new Set<string>();
147
+ #pendingFrames = new Map<string, { generation: number; count: number }>();
148
+ #leaseRecords = new Map<string, { capability: string; connectionId: string; connectionGeneration: number }>();
149
+ #quarantinedLeases = new Map<string, ProviderLeaseIdentity>();
150
+ #closedConnectionOrder: string[] = [];
123
151
 
124
152
  constructor(options: SessionSdkHostOptions) {
125
153
  this.#options = options;
@@ -141,9 +169,120 @@ export class SessionSdkHost {
141
169
  getProviderDefinitions(capability: string): unknown | undefined {
142
170
  return this.reverse.getInstalledDefinitions(capability);
143
171
  }
172
+ /** Mark a transport connection live and return its stable generation. */
173
+ handleConnect(connectionId: string): number {
174
+ if (!this.#openConnections.has(connectionId)) {
175
+ const generation = this.#connectionGenerations.get(connectionId) ?? 1;
176
+ this.#connectionGenerations.set(connectionId, generation);
177
+ this.#openConnections.add(connectionId);
178
+ this.#pendingFrames.set(connectionId, { generation, count: 0 });
179
+ }
180
+ return this.#connectionGenerations.get(connectionId) ?? 0;
181
+ }
182
+
183
+ /** Current generation for a transport connection, if it has been observed. */
184
+ connectionGeneration(connectionId: string): number {
185
+ return this.#connectionGenerations.get(connectionId) ?? 0;
186
+ }
187
+
188
+ isConnectionOpen(connectionId: string, generation?: number): boolean {
189
+ return (
190
+ this.#openConnections.has(connectionId) &&
191
+ (generation === undefined || this.#connectionGenerations.get(connectionId) === generation)
192
+ );
193
+ }
194
+
144
195
  /** Release reverse leases after the transport reports a WebSocket disconnect. */
145
196
  handleDisconnect(connectionId: string): void {
197
+ if (!this.#openConnections.delete(connectionId)) return;
198
+ this.#connectionGenerations.set(connectionId, (this.#connectionGenerations.get(connectionId) ?? 0) + 1);
199
+ this.#pendingFrames.delete(connectionId);
200
+ for (const [leaseId, record] of this.#leaseRecords) {
201
+ if (record.connectionId === connectionId) this.#leaseRecords.delete(leaseId);
202
+ }
146
203
  this.reverse.disconnect(connectionId);
204
+ this.#closedConnectionOrder.push(connectionId);
205
+ while (this.#closedConnectionOrder.length > MAX_RETAINED_CLOSED_CONNECTIONS) {
206
+ const retired = this.#closedConnectionOrder.shift();
207
+ if (retired && !this.#openConnections.has(retired)) this.#connectionGenerations.delete(retired);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Fences one exact provider lease after uncertain terminal cleanup. The fence
213
+ * is idempotent for the same identity and never affects a newer generation.
214
+ */
215
+ quarantineProviderLease(identity: ProviderLeaseIdentity, _reason?: string): boolean {
216
+ if (
217
+ !identity ||
218
+ typeof identity.leaseId !== "string" ||
219
+ identity.leaseId.length === 0 ||
220
+ typeof identity.connectionId !== "string" ||
221
+ identity.connectionId.length === 0 ||
222
+ !Number.isSafeInteger(identity.connectionGeneration) ||
223
+ identity.connectionGeneration < 0 ||
224
+ typeof identity.fence !== "string" ||
225
+ identity.fence.length === 0
226
+ )
227
+ return false;
228
+ const previous = this.#quarantinedLeases.get(identity.leaseId);
229
+ if (
230
+ previous &&
231
+ previous.connectionId === identity.connectionId &&
232
+ previous.connectionGeneration === identity.connectionGeneration &&
233
+ previous.fence === identity.fence
234
+ )
235
+ return true;
236
+ if (this.#quarantinedLeases.size >= MAX_QUARANTINED_PROVIDER_LEASES) return false;
237
+ if (this.#connectionGenerations.get(identity.connectionId) !== identity.connectionGeneration) return false;
238
+ const record = this.#leaseRecords.get(identity.leaseId);
239
+ if (
240
+ !record ||
241
+ record.connectionId !== identity.connectionId ||
242
+ record.connectionGeneration !== identity.connectionGeneration
243
+ )
244
+ return false;
245
+ const lease = this.reverse.getLease(record.capability);
246
+ if (!lease || lease.leaseId !== identity.leaseId || lease.connectionId !== identity.connectionId || !lease.active)
247
+ return false;
248
+ try {
249
+ this.reverse.release(identity.connectionId, identity.leaseId);
250
+ } catch {
251
+ return false;
252
+ }
253
+ this.#quarantinedLeases.set(identity.leaseId, { ...identity });
254
+ return true;
255
+ }
256
+
257
+ /** Dispatch a frame through bounded per-connection asynchronous admission. */
258
+ dispatchFrame(connectionId: string, frame: SdkFrame): Promise<void> {
259
+ const generation = this.#connectionGenerations.has(connectionId)
260
+ ? (this.#connectionGenerations.get(connectionId) ?? 0)
261
+ : this.handleConnect(connectionId);
262
+ const pending = this.#pendingFrames.get(connectionId);
263
+ const pendingCount = pending?.generation === generation ? pending.count : 0;
264
+ if (pendingCount >= MAX_PENDING_FRAMES_PER_CONNECTION) {
265
+ this.#options.onFrameOverflow?.(connectionId);
266
+ return Promise.resolve();
267
+ }
268
+ this.#pendingFrames.set(connectionId, { generation, count: pendingCount + 1 });
269
+ const task = Promise.resolve().then(() => {
270
+ if (this.#connectionGenerations.get(connectionId) !== generation) return;
271
+ if (!this.#openConnections.has(connectionId)) return;
272
+ return this.#onFrame(connectionId, frame, generation);
273
+ });
274
+ void task.finally(() => {
275
+ const current = this.#pendingFrames.get(connectionId);
276
+ if (!current || current.generation !== generation) return;
277
+ this.#pendingFrames.set(connectionId, {
278
+ generation,
279
+ count: Math.max(0, current.count - 1),
280
+ });
281
+ });
282
+ return task.then(
283
+ () => {},
284
+ () => {},
285
+ );
147
286
  }
148
287
 
149
288
  /** Adds an event to the resumable event ring. Transport delivery is owned by bus wiring. */
@@ -156,7 +295,7 @@ export class SessionSdkHost {
156
295
  this.events.restart();
157
296
  this.emitEvent({ name: "session_ready", sessionId: this.#options.sessionId, generation: this.events.generation });
158
297
  const disposer = this.#options.onFrame((connectionId, frame) => {
159
- void this.#onFrame(connectionId, frame);
298
+ void this.dispatchFrame(connectionId, frame);
160
299
  });
161
300
  this.#unsubscribe = typeof disposer === "function" ? disposer : undefined;
162
301
  this.#started = true;
@@ -221,18 +360,30 @@ export class SessionSdkHost {
221
360
  }
222
361
  }
223
362
 
224
- async #onFrame(connectionId: string, frame: SdkFrame): Promise<void> {
363
+ async #onFrame(connectionId: string, frame: SdkFrame, generation: number): Promise<void> {
364
+ if (this.#connectionGenerations.has(connectionId) && this.#connectionGenerations.get(connectionId) !== generation)
365
+ return;
225
366
  try {
226
367
  switch (frame.type) {
227
368
  case "control_request": {
228
369
  this.#observeRequest("control", connectionId, frame);
229
370
  const result = await this.#options.control?.(connectionId, frame);
371
+ if (
372
+ this.#connectionGenerations.has(connectionId) &&
373
+ this.#connectionGenerations.get(connectionId) !== generation
374
+ )
375
+ return;
230
376
  if (result !== undefined) {
231
377
  const response = { type: "control_response", ...(result as SdkFrame) };
232
378
  let terminalSent = false;
233
379
  const sendTerminal = async (): Promise<void> => {
234
380
  if (terminalSent) return;
235
381
  terminalSent = true;
382
+ if (
383
+ this.#connectionGenerations.has(connectionId) &&
384
+ this.#connectionGenerations.get(connectionId) !== generation
385
+ )
386
+ return;
236
387
  await this.#send(connectionId, response);
237
388
  };
238
389
  await this.#options.beforeControlResponse?.(connectionId, frame, response, sendTerminal);
@@ -271,6 +422,11 @@ export class SessionSdkHost {
271
422
  case "query_request": {
272
423
  this.#observeRequest("query", connectionId, frame);
273
424
  const result = await this.#options.query?.(connectionId, frame);
425
+ if (
426
+ this.#connectionGenerations.has(connectionId) &&
427
+ this.#connectionGenerations.get(connectionId) !== generation
428
+ )
429
+ return;
274
430
  if (result !== undefined)
275
431
  await this.#send(connectionId, { type: "query_response", ...(result as SdkFrame) });
276
432
  break;
@@ -280,13 +436,30 @@ export class SessionSdkHost {
280
436
  requireConnection(connectionId, frame);
281
437
  const capability = requiredString(frame, "capability");
282
438
  if (!has(frame, "definitions")) throw invalidFrame("definitions is required.");
439
+ const expectedLeaseId = optionalString(frame, "expectedLeaseId");
440
+ if (expectedLeaseId && this.#quarantinedLeases.has(expectedLeaseId))
441
+ throw new ReverseLeaseError("lease_unavailable");
442
+ const connectionGeneration = this.#connectionGenerations.get(connectionId) ?? 0;
283
443
  const lease = this.reverse.registerProvider(
284
444
  connectionId,
285
445
  capability,
286
446
  frame.definitions,
287
- optionalString(frame, "expectedLeaseId"),
447
+ expectedLeaseId,
288
448
  optionalString(frame, "idempotencyKey"),
449
+ connectionGeneration,
289
450
  );
451
+ for (const [priorLeaseId, record] of this.#leaseRecords) {
452
+ if (record.capability === lease.capability && priorLeaseId !== lease.leaseId)
453
+ this.#leaseRecords.delete(priorLeaseId);
454
+ }
455
+ this.#leaseRecords.set(lease.leaseId, {
456
+ capability: lease.capability,
457
+ connectionId: lease.connectionId,
458
+ connectionGeneration,
459
+ });
460
+ try {
461
+ this.#options.onProviderLeaseRegistered?.(lease, connectionGeneration);
462
+ } catch {}
290
463
  await this.#send(connectionId, {
291
464
  id: frame.id,
292
465
  type: "register_provider_result",
@@ -306,6 +479,7 @@ export class SessionSdkHost {
306
479
  requireConnection(connectionId, frame);
307
480
  const handoffTo = optionalString(frame, "handoffTo");
308
481
  const lease = this.reverse.release(connectionId, requiredString(frame, "leaseId"), handoffTo);
482
+ this.#leaseRecords.delete(lease.leaseId);
309
483
  await this.#send(connectionId, leaseState(undefined, lease));
310
484
  break;
311
485
  }
@@ -318,7 +492,7 @@ export class SessionSdkHost {
318
492
  if (frame.ok) {
319
493
  if (!has(frame, "result") || has(frame, "error"))
320
494
  throw invalidFrame("Successful reverse responses require result and no error.");
321
- this.reverse.respond(connectionId, id, leaseId, frame.result);
495
+ this.reverse.respond(connectionId, id, leaseId, frame.result, undefined, frame);
322
496
  } else {
323
497
  if (
324
498
  has(frame, "result") ||
@@ -327,10 +501,14 @@ export class SessionSdkHost {
327
501
  typeof responseError.message !== "string"
328
502
  )
329
503
  throw invalidFrame("Failed reverse responses require a structured error and no result.");
330
- this.reverse.respond(connectionId, id, leaseId, undefined, {
331
- code: responseError.code,
332
- message: responseError.message,
333
- });
504
+ this.reverse.respond(
505
+ connectionId,
506
+ id,
507
+ leaseId,
508
+ undefined,
509
+ { code: responseError.code, message: responseError.message },
510
+ frame,
511
+ );
334
512
  }
335
513
  break;
336
514
  }