@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.
- package/CHANGELOG.md +32 -0
- package/README.md +4 -1
- package/dist/types/eval/backend.d.ts +2 -2
- package/dist/types/exec/bash-executor.d.ts +10 -8
- package/dist/types/extensibility/extensions/runner.d.ts +9 -7
- package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
- package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
- package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
- package/dist/types/gjc-runtime/team-store.d.ts +0 -4
- package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
- package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
- package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +83 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +57 -4
- package/dist/types/internal-urls/registry-helpers.d.ts +18 -0
- package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
- package/dist/types/modes/components/plugin-settings.d.ts +2 -0
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +18 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -3
- package/dist/types/sdk/bus/index.d.ts +4 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -2
- package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
- package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
- package/dist/types/sdk/host/host.d.ts +23 -2
- package/dist/types/sdk/host/reverse-leases.d.ts +7 -4
- package/dist/types/session/agent-session.d.ts +5 -4
- package/dist/types/session/artifacts.d.ts +8 -5
- package/dist/types/session/attempt-record-store.d.ts +19 -0
- package/dist/types/session/client-bridge.d.ts +14 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +5 -2
- package/dist/types/session/messages.d.ts +4 -1
- package/dist/types/session/session-manager.d.ts +6 -4
- package/dist/types/session/session-storage.d.ts +8 -0
- package/dist/types/session/streaming-output.d.ts +32 -0
- package/dist/types/task/receipt.d.ts +2 -0
- package/dist/types/task/types.d.ts +178 -0
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/fetch.d.ts +3 -0
- package/dist/types/tools/output-meta.d.ts +37 -5
- package/dist/types/tools/tool-result.d.ts +1 -0
- package/dist/types/utils/artifact-id.d.ts +2 -0
- package/package.json +8 -8
- package/scripts/capture-sticky-viewport-showcase.ts +131 -5
- package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
- package/scripts/verify-sticky-viewport-showcase.ts +620 -93
- package/src/config/model-profiles.ts +8 -0
- package/src/config/settings-schema.ts +17 -3
- package/src/cursor.ts +20 -13
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +5 -2
- package/src/eval/backend.ts +2 -2
- package/src/exec/bash-executor.ts +425 -151
- package/src/extensibility/extensions/runner.ts +80 -6
- package/src/extensibility/extensions/wrapper.ts +22 -15
- package/src/extensibility/runtime-skill-discovery.ts +4 -6
- package/src/gjc-runtime/launch-tmux.ts +309 -293
- package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
- package/src/gjc-runtime/psmux-detect.ts +52 -6
- package/src/gjc-runtime/team-launch.ts +50 -2
- package/src/gjc-runtime/team-runtime.ts +716 -251
- package/src/gjc-runtime/team-store.ts +25 -7
- package/src/gjc-runtime/team-workers.ts +161 -1
- package/src/gjc-runtime/tmux-common.ts +16 -4
- package/src/gjc-runtime/tmux-gc.ts +37 -5
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
- package/src/gjc-runtime/tmux-provider-context.ts +451 -0
- package/src/gjc-runtime/tmux-sessions.ts +409 -156
- package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
- package/src/gjc-runtime/ultragoal-review-source.ts +378 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +640 -159
- package/src/gjc-runtime/workflow-manifest.generated.json +40 -1
- package/src/gjc-runtime/workflow-manifest.ts +12 -0
- package/src/internal-urls/agent-protocol.ts +98 -38
- package/src/internal-urls/artifact-protocol.ts +28 -17
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/internal-urls/local-protocol.ts +39 -18
- package/src/internal-urls/registry-helpers.ts +62 -0
- package/src/modes/acp/acp-agent.ts +51 -13
- package/src/modes/acp/acp-client-bridge.ts +154 -11
- package/src/modes/components/gjc-bundle-settings.ts +2 -0
- package/src/modes/components/plugin-settings.ts +5 -0
- package/src/modes/components/settings-selector.ts +4 -0
- package/src/modes/controllers/selector-controller.ts +1 -0
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/monitor.md +4 -4
- package/src/sdk/acp/adapter.ts +200 -9
- package/src/sdk/broker/broker.ts +93 -10
- package/src/sdk/broker/lifecycle-ledger.ts +227 -11
- package/src/sdk/broker/lifecycle.ts +691 -92
- package/src/sdk/bus/chat-daemon-control.ts +32 -6
- package/src/sdk/bus/index.ts +347 -14
- package/src/sdk/bus/telegram-daemon-contract.ts +4 -2
- package/src/sdk/bus/telegram-daemon.ts +283 -44
- package/src/sdk/bus/topic-registry.ts +133 -10
- package/src/sdk/host/host.ts +187 -9
- package/src/sdk/host/reverse-leases.ts +249 -42
- package/src/sdk/session.ts +7 -6
- package/src/session/agent-session.ts +930 -354
- package/src/session/artifacts.ts +211 -74
- package/src/session/attempt-record-store.ts +106 -0
- package/src/session/client-bridge.ts +15 -0
- package/src/session/internal/managed-session-scope.ts +252 -67
- package/src/session/internal/managed-session-storage.ts +105 -5
- package/src/session/messages.ts +32 -7
- package/src/session/session-manager.ts +19 -21
- package/src/session/session-storage.ts +146 -24
- package/src/session/streaming-output.ts +198 -40
- package/src/task/index.ts +45 -0
- package/src/task/provider-retry-status.ts +4 -1
- package/src/task/receipt.ts +4 -0
- package/src/task/render.ts +30 -0
- package/src/task/types.ts +34 -0
- package/src/tools/bash.ts +818 -239
- package/src/tools/eval.ts +99 -4
- package/src/tools/fetch.ts +77 -15
- package/src/tools/find.ts +7 -3
- package/src/tools/monitor.ts +7 -1
- package/src/tools/output-meta.ts +706 -116
- package/src/tools/search.ts +9 -4
- package/src/tools/tool-result.ts +1 -0
- 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
|
|
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
|
-
/**
|
|
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)
|
|
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 =
|
|
728
|
-
|
|
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
|
-
/**
|
|
803
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/src/sdk/host/host.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
331
|
-
|
|
332
|
-
|
|
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
|
}
|