@gajae-code/coding-agent 0.11.5 → 0.11.7
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 +37 -0
- package/dist/types/capability/index.d.ts +10 -20
- package/dist/types/capability/types.d.ts +3 -0
- package/dist/types/commands/daemon.d.ts +2 -2
- package/dist/types/commands/sdk.d.ts +20 -2
- package/dist/types/config/keybindings.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +67 -7
- package/dist/types/eval/py/runner-artifact.d.ts +8 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +5 -5
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +19 -5
- package/dist/types/main.d.ts +24 -1
- package/dist/types/modes/components/btw-panel.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-status-header.d.ts +1 -0
- package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -0
- package/dist/types/sdk/bus/index.d.ts +1 -1
- package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
- package/dist/types/sdk/bus/notification-service.d.ts +34 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +13 -3
- package/dist/types/sdk/bus/telegram-daemon.d.ts +61 -3
- package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
- package/dist/types/sdk/client/discovery.d.ts +2 -0
- package/dist/types/sdk/client/liveness.d.ts +7 -0
- package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
- package/dist/types/sdk/transport/index.d.ts +12 -0
- package/dist/types/sdk/transport/relay.d.ts +43 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
- package/dist/types/sdk/transport/socket.d.ts +7 -0
- package/dist/types/sdk/transport/stdio.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +28 -5
- package/dist/types/session/btw-contract.d.ts +16 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +21 -7
- package/dist/types/session/internal/managed-session-storage.d.ts +10 -8
- package/dist/types/session/session-manager.d.ts +35 -4
- package/dist/types/session/yield-queue.d.ts +8 -0
- package/dist/types/setup/credential-import.d.ts +5 -1
- package/dist/types/setup/provider-onboarding.d.ts +2 -0
- package/dist/types/ssh/utils.d.ts +1 -0
- package/dist/types/task/discovery.d.ts +3 -1
- package/dist/types/tools/fetch.d.ts +5 -8
- package/dist/types/tools/image-gen.d.ts +16 -0
- package/dist/types/tools/skill-discovery.d.ts +7 -0
- package/dist/types/tools/sqlite-reader.d.ts +7 -0
- package/dist/types/utils/shell-snapshot.d.ts +12 -0
- package/dist/types/web/insane/bridge.d.ts +1 -0
- package/dist/types/web/insane/url-guard.d.ts +17 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +3 -0
- package/src/capability/index.ts +50 -42
- package/src/capability/types.ts +3 -0
- package/src/cli/setup-cli.ts +10 -4
- package/src/cli.ts +34 -9
- package/src/commands/daemon.ts +22 -5
- package/src/commands/sdk.ts +42 -9
- package/src/config/keybindings.ts +1 -1
- package/src/config/model-profiles.ts +18 -0
- package/src/config/settings-schema.ts +71 -7
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
- package/src/discovery/ssh.ts +12 -1
- package/src/eval/py/kernel.ts +1 -21
- package/src/eval/py/runner-artifact.ts +84 -0
- package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
- package/src/gjc-runtime/ultragoal-guard.ts +88 -10
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
- package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
- package/src/gjc-runtime/workflow-manifest.ts +23 -0
- package/src/internal-urls/docs-index.generated.ts +9 -8
- package/src/internal-urls/local-protocol.ts +32 -6
- package/src/main.ts +87 -28
- package/src/modes/action-registry.ts +1 -1
- package/src/modes/components/btw-panel.ts +82 -16
- package/src/modes/components/model-selector.ts +31 -3
- package/src/modes/components/tool-status-header.ts +6 -1
- package/src/modes/controllers/btw-controller.ts +119 -36
- package/src/modes/controllers/command-controller.ts +15 -0
- package/src/modes/controllers/extension-ui-controller.ts +14 -2
- package/src/modes/controllers/input-controller.ts +31 -0
- package/src/modes/controllers/selector-controller.ts +106 -12
- package/src/modes/interactive-mode.ts +16 -7
- package/src/modes/runtime-init.ts +16 -2
- package/src/modes/types.ts +1 -0
- package/src/prompts/system/btw-user.md +3 -8
- package/src/prompts/tools/skill-discovery.md +1 -0
- package/src/runtime-mcp/client.ts +41 -55
- package/src/runtime-mcp/content-limits.ts +71 -0
- package/src/runtime-mcp/json-rpc.ts +23 -9
- package/src/runtime-mcp/transports/http.ts +50 -16
- package/src/sdk/bus/chat-daemon-control.ts +20 -7
- package/src/sdk/bus/index.ts +8 -7
- package/src/sdk/bus/lifecycle-commands.ts +16 -7
- package/src/sdk/bus/notification-service.ts +41 -18
- package/src/sdk/bus/telegram-daemon-contract.ts +13 -3
- package/src/sdk/bus/telegram-daemon-control.ts +26 -8
- package/src/sdk/bus/telegram-daemon.ts +1736 -338
- package/src/sdk/bus/topic-registry.ts +451 -12
- package/src/sdk/client/discovery.ts +19 -3
- package/src/sdk/client/liveness.ts +49 -0
- package/src/sdk/host/control/dispatch.ts +14 -5
- package/src/sdk/protocol/operation-inventory.generated.json +29 -0
- package/src/sdk/session.ts +29 -5
- package/src/sdk/transport/auth-preface.ts +82 -0
- package/src/sdk/transport/index.ts +18 -0
- package/src/sdk/transport/relay.ts +252 -0
- package/src/sdk/transport/serve-cli.ts +105 -0
- package/src/sdk/transport/socket.ts +197 -0
- package/src/sdk/transport/stdio.ts +17 -0
- package/src/session/agent-session.ts +1212 -768
- package/src/session/blob-store.ts +19 -6
- package/src/session/btw-contract.ts +59 -0
- package/src/session/internal/managed-session-scope.ts +313 -57
- package/src/session/internal/managed-session-storage.ts +146 -35
- package/src/session/session-manager.ts +385 -85
- package/src/session/yield-queue.ts +20 -0
- package/src/setup/credential-import.ts +28 -3
- package/src/setup/provider-onboarding.ts +30 -1
- package/src/setup/provider-presets.json +2 -1
- package/src/slash-commands/builtin-registry.ts +37 -1
- package/src/ssh/utils.ts +14 -0
- package/src/task/discovery.ts +8 -2
- package/src/tools/ask-answer-registry.ts +15 -1
- package/src/tools/ask.ts +1 -1
- package/src/tools/fetch.ts +13 -131
- package/src/tools/image-gen.ts +301 -20
- package/src/tools/read.ts +2 -0
- package/src/tools/skill-discovery.ts +39 -3
- package/src/tools/sqlite-reader.ts +63 -5
- package/src/utils/pasted-image-path.ts +15 -6
- package/src/utils/shell-snapshot.ts +173 -63
- package/src/web/insane/bridge.ts +5 -1
- package/src/web/insane/url-guard.ts +138 -27
- package/src/web/scrapers/types.ts +19 -49
- package/src/web/scrapers/utils.ts +12 -26
|
@@ -105,6 +105,16 @@ import {
|
|
|
105
105
|
type FallbackAttemptToken,
|
|
106
106
|
type FallbackTriggerClass,
|
|
107
107
|
} from "@gajae-code/ai/utils/fallback-transport";
|
|
108
|
+
import {
|
|
109
|
+
BTW_MAX_ANSWER_UTF8_BYTES,
|
|
110
|
+
BTW_MAX_QUESTION_UTF8_BYTES,
|
|
111
|
+
BTW_STREAM_IDLE_TIMEOUT_MS,
|
|
112
|
+
BTW_STREAM_TOTAL_TIMEOUT_MS,
|
|
113
|
+
type BtwTextExchange,
|
|
114
|
+
boundBtwExchanges,
|
|
115
|
+
truncateUtf8,
|
|
116
|
+
utf8ByteLength,
|
|
117
|
+
} from "./btw-contract";
|
|
108
118
|
|
|
109
119
|
export interface ForkContextSeedMetadata {
|
|
110
120
|
sourceSessionId: string;
|
|
@@ -827,7 +837,6 @@ function isLocalModelEndpoint(model: Model | undefined): boolean {
|
|
|
827
837
|
}
|
|
828
838
|
|
|
829
839
|
const IRC_REPLY_MAX_BYTES = 4096;
|
|
830
|
-
const BTW_ESTABLISHMENT_TIMEOUT_MS = 15_000;
|
|
831
840
|
|
|
832
841
|
export type EphemeralTurnPurpose = "btw" | "background";
|
|
833
842
|
|
|
@@ -837,8 +846,34 @@ interface EphemeralTurnBaseArgs {
|
|
|
837
846
|
signal?: AbortSignal;
|
|
838
847
|
}
|
|
839
848
|
|
|
849
|
+
export interface BtwRoleTextMessage {
|
|
850
|
+
role: "user" | "assistant";
|
|
851
|
+
text: string;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
export interface BtwConversationScope {
|
|
855
|
+
model: Model;
|
|
856
|
+
systemPrompt: string[];
|
|
857
|
+
messages: BtwRoleTextMessage[];
|
|
858
|
+
thinkingLevel: ThinkingLevel;
|
|
859
|
+
hideThinkingSummary: boolean;
|
|
860
|
+
serviceTier: ServiceTier | undefined;
|
|
861
|
+
credentialSessionId: string;
|
|
862
|
+
providerAffinitySessionId: string;
|
|
863
|
+
sideSessionId: string;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export interface BtwTurnCapture {
|
|
867
|
+
question: string;
|
|
868
|
+
scope: BtwConversationScope | undefined;
|
|
869
|
+
}
|
|
870
|
+
|
|
840
871
|
export type EphemeralTurnArgs =
|
|
841
|
-
| (EphemeralTurnBaseArgs & {
|
|
872
|
+
| (Omit<EphemeralTurnBaseArgs, "promptText"> & {
|
|
873
|
+
purpose: "btw";
|
|
874
|
+
turn: BtwTurnCapture;
|
|
875
|
+
contextExchanges?: readonly BtwTextExchange[];
|
|
876
|
+
})
|
|
842
877
|
| (EphemeralTurnBaseArgs & {
|
|
843
878
|
purpose?: "background";
|
|
844
879
|
/** Internal caller-supplied, non-persistent context such as the IRC roster. */
|
|
@@ -1420,6 +1455,11 @@ export class AgentSession {
|
|
|
1420
1455
|
readonly notificationSessionController: NotificationSessionController | undefined;
|
|
1421
1456
|
readonly taskDepth: number;
|
|
1422
1457
|
readonly yieldQueue: YieldQueue;
|
|
1458
|
+
// True from the start of a handoff transition through commit/rollback. While
|
|
1459
|
+
// set, the yield queue treats the session as busy so background async-job
|
|
1460
|
+
// completions cannot start a new idle turn against the session being handed
|
|
1461
|
+
// off (or, on rollback, the restored predecessor mid-transition).
|
|
1462
|
+
#handoffTransitionActive = false;
|
|
1423
1463
|
|
|
1424
1464
|
#powerAssertion: MacOSPowerAssertion | undefined;
|
|
1425
1465
|
|
|
@@ -1839,6 +1879,46 @@ export class AgentSession {
|
|
|
1839
1879
|
});
|
|
1840
1880
|
}
|
|
1841
1881
|
|
|
1882
|
+
/**
|
|
1883
|
+
* Reject a turn start while a handoff transition owns the session. Handoff never
|
|
1884
|
+
* routes its own generation/injection through these turn-start chokepoints, and
|
|
1885
|
+
* auto-maintenance runs before the fence, so this fences only external entrants
|
|
1886
|
+
* (steer/follow-up/sendUserMessage/custom/hidden-next-turn/continuation) that
|
|
1887
|
+
* bypass prompt admission.
|
|
1888
|
+
*/
|
|
1889
|
+
#assertNoHandoffTransition(): void {
|
|
1890
|
+
if (this.#handoffTransitionActive) {
|
|
1891
|
+
throw Object.assign(new AgentBusyError("Cannot start a turn while a handoff is in progress."), {
|
|
1892
|
+
code: "busy",
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* Single, synchronously-acquired mutex for session-identity transitions
|
|
1899
|
+
* (handoff, compact, new/switch/branch/clear, fork, tree navigation). Acquired
|
|
1900
|
+
* BEFORE any await at each transition's entry and released in its finally, so
|
|
1901
|
+
* exclusion is symmetric regardless of which transition starts first — an
|
|
1902
|
+
* operation that began earlier and yielded still owns the lease when a peer
|
|
1903
|
+
* tries to start. Auto-handoff acquires it via handoff() (the maintenance
|
|
1904
|
+
* orchestrator does not hold it), so there is no self-deadlock.
|
|
1905
|
+
*/
|
|
1906
|
+
#sessionTransitionKind: string | undefined;
|
|
1907
|
+
|
|
1908
|
+
#beginSessionTransition(kind: string): void {
|
|
1909
|
+
if (this.#sessionTransitionKind !== undefined) {
|
|
1910
|
+
throw Object.assign(
|
|
1911
|
+
new Error(`Cannot start ${kind} while a ${this.#sessionTransitionKind} transition is in progress.`),
|
|
1912
|
+
{ code: "busy" },
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
this.#sessionTransitionKind = kind;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
#endSessionTransition(): void {
|
|
1919
|
+
this.#sessionTransitionKind = undefined;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1842
1922
|
#activateNextSessionAdmission(): void {
|
|
1843
1923
|
if (this.#activeSessionAdmission || this.#sessionAdmissionClosed) return;
|
|
1844
1924
|
const next = this.#sessionAdmissionQueue.shift();
|
|
@@ -1854,6 +1934,16 @@ export class AgentSession {
|
|
|
1854
1934
|
const owner = this.#sessionAdmissionContext.getStore();
|
|
1855
1935
|
if (owner && !owner.released) throw this.#sessionAdmissionBusyError();
|
|
1856
1936
|
if (this.#sessionAdmissionClosed || this.#isDisposed) throw this.#sessionAdmissionBusyError();
|
|
1937
|
+
// Reject new external turns for the whole handoff transition. The handoff
|
|
1938
|
+
// itself never acquires prompt admission (it generates via generateHandoff and
|
|
1939
|
+
// injects via appendCustomMessageEntry), so this fences external entrants —
|
|
1940
|
+
// prompt/sendUserMessage/steer/follow-up/triggerTurn all funnel here — without
|
|
1941
|
+
// blocking the handoff's own work or the exempt auto-maintenance owner.
|
|
1942
|
+
if (kind === "prompt" && this.#handoffTransitionActive) {
|
|
1943
|
+
throw Object.assign(new AgentBusyError("Cannot start a turn while a handoff is in progress."), {
|
|
1944
|
+
code: "busy",
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1857
1947
|
|
|
1858
1948
|
const entry: SessionAdmissionEntry = {
|
|
1859
1949
|
kind,
|
|
@@ -1871,6 +1961,17 @@ export class AgentSession {
|
|
|
1871
1961
|
this.#activateNextSessionAdmission();
|
|
1872
1962
|
throw this.#sessionAdmissionBusyError();
|
|
1873
1963
|
}
|
|
1964
|
+
// Re-check the handoff fence after activation: a prompt queued before the
|
|
1965
|
+
// transition began must not start once the fence is up.
|
|
1966
|
+
if (kind === "prompt" && this.#handoffTransitionActive) {
|
|
1967
|
+
entry.released = true;
|
|
1968
|
+
entry.settled.resolve();
|
|
1969
|
+
if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
|
|
1970
|
+
this.#activateNextSessionAdmission();
|
|
1971
|
+
throw Object.assign(new AgentBusyError("Cannot start a turn while a handoff is in progress."), {
|
|
1972
|
+
code: "busy",
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1874
1975
|
|
|
1875
1976
|
const release = () => {
|
|
1876
1977
|
if (entry.released) return;
|
|
@@ -2136,7 +2237,7 @@ export class AgentSession {
|
|
|
2136
2237
|
this.#setGuardedAgentTools(this.agent.state.tools);
|
|
2137
2238
|
this.#bindWorkflowGateEmitter();
|
|
2138
2239
|
this.yieldQueue = new YieldQueue({
|
|
2139
|
-
isStreaming: () => this.isStreaming,
|
|
2240
|
+
isStreaming: () => this.isStreaming || this.#handoffTransitionActive,
|
|
2140
2241
|
injectStreaming: message => this.agent.followUp(message),
|
|
2141
2242
|
injectIdle: async messages => {
|
|
2142
2243
|
const first = messages[0];
|
|
@@ -3772,7 +3873,7 @@ export class AgentSession {
|
|
|
3772
3873
|
skipCompactionCheck?: boolean;
|
|
3773
3874
|
suppressPredecessorAgentEnd?: boolean;
|
|
3774
3875
|
shouldContinue?: () => boolean;
|
|
3775
|
-
onSkip?: (reason: "generation_changed" | "aborted_signal" | "queue_drained") => void;
|
|
3876
|
+
onSkip?: (reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress") => void;
|
|
3776
3877
|
allowDuringCancelAndSubmit?: boolean;
|
|
3777
3878
|
onError?: (error: unknown) => void;
|
|
3778
3879
|
}): Promise<void> {
|
|
@@ -3780,7 +3881,7 @@ export class AgentSession {
|
|
|
3780
3881
|
? this.#reserveDeferredAgentEndForContinuation()
|
|
3781
3882
|
: undefined;
|
|
3782
3883
|
let terminalized = false;
|
|
3783
|
-
const skip = (reason: "generation_changed" | "aborted_signal" | "queue_drained") => {
|
|
3884
|
+
const skip = (reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress") => {
|
|
3784
3885
|
if (terminalized) return;
|
|
3785
3886
|
terminalized = true;
|
|
3786
3887
|
this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
|
|
@@ -3836,6 +3937,13 @@ export class AgentSession {
|
|
|
3836
3937
|
skip("queue_drained");
|
|
3837
3938
|
return;
|
|
3838
3939
|
}
|
|
3940
|
+
// A continuation scheduled before a handoff engaged must not start a
|
|
3941
|
+
// turn against the session being handed off (or the restored
|
|
3942
|
+
// predecessor). rearmIdle / normal delivery resumes after the fence.
|
|
3943
|
+
if (this.#handoffTransitionActive) {
|
|
3944
|
+
skip("handoff_in_progress");
|
|
3945
|
+
return;
|
|
3946
|
+
}
|
|
3839
3947
|
const predecessorAgentEnd = this.#claimDeferredAgentEndForContinuation(predecessorAgentEndHold);
|
|
3840
3948
|
try {
|
|
3841
3949
|
await this.agent.continue(this.#managedFallbackPromptOptions());
|
|
@@ -6239,6 +6347,7 @@ export class AgentSession {
|
|
|
6239
6347
|
|
|
6240
6348
|
/** Main startup calls this exactly once, after a strict open returned `kind: "opened"`. */
|
|
6241
6349
|
async continuePersistedHistory(): Promise<void> {
|
|
6350
|
+
this.#assertNoHandoffTransition();
|
|
6242
6351
|
this.#assertRecoveryHydrationPromoted();
|
|
6243
6352
|
this.#removeEphemeralCustomMessages();
|
|
6244
6353
|
|
|
@@ -6275,6 +6384,10 @@ export class AgentSession {
|
|
|
6275
6384
|
hindsightRecall = undefined;
|
|
6276
6385
|
}
|
|
6277
6386
|
}
|
|
6387
|
+
// Re-check after the awaited preparation: a handoff can engage during the
|
|
6388
|
+
// volatile-context/hindsight awaits above and this would otherwise start a
|
|
6389
|
+
// turn against the session being handed off.
|
|
6390
|
+
this.#assertNoHandoffTransition();
|
|
6278
6391
|
await this.agent.continue({
|
|
6279
6392
|
...this.#managedFallbackPromptOptions(),
|
|
6280
6393
|
onRunAccepted: () => {
|
|
@@ -6561,27 +6674,61 @@ export class AgentSession {
|
|
|
6561
6674
|
return getAskAnswerSourceFromRegistry(this.sessionId);
|
|
6562
6675
|
}
|
|
6563
6676
|
|
|
6564
|
-
#
|
|
6677
|
+
#constructWorkflowGateEmitter(): WorkflowGateEmitter {
|
|
6565
6678
|
const sessionId = this.sessionManager.getSessionId();
|
|
6566
6679
|
assertNonEmptyGjcSessionId(sessionId, "AgentSession workflow-gate session");
|
|
6567
6680
|
const gateStore = this.sessionManager.isPersisted()
|
|
6568
6681
|
? new FileGateStore(path.join(sessionStateDir(this.sessionManager.getCwd(), sessionId), "workflow-gates.json"))
|
|
6569
6682
|
: new MemoryGateStore();
|
|
6570
|
-
|
|
6571
|
-
|
|
6572
|
-
|
|
6573
|
-
|
|
6683
|
+
return new BrokerWorkflowGateEmitter(sessionId, gateStore);
|
|
6684
|
+
}
|
|
6685
|
+
|
|
6686
|
+
/**
|
|
6687
|
+
* Publish an already-constructed successor emitter. This is no-throw from the
|
|
6688
|
+
* caller's perspective: predecessor fencing/quarantine is best-effort (a failure
|
|
6689
|
+
* must never leave the session with no emitter after a committed switch), and
|
|
6690
|
+
* listener notification is isolated in the registry.
|
|
6691
|
+
*/
|
|
6692
|
+
#publishWorkflowGateEmitter(
|
|
6693
|
+
successorEmitter: WorkflowGateEmitter,
|
|
6694
|
+
previousSessionId?: string,
|
|
6695
|
+
previousEmitter = this.#workflowGateEmitter,
|
|
6696
|
+
): void {
|
|
6697
|
+
try {
|
|
6698
|
+
previousEmitter?.fence?.();
|
|
6699
|
+
if (previousEmitter && !previousEmitter.fence) {
|
|
6700
|
+
for (const gate of previousEmitter.listPendingGates?.() ?? [])
|
|
6701
|
+
previousEmitter.quarantineGate?.(gate.gate_id);
|
|
6702
|
+
}
|
|
6703
|
+
} catch (error) {
|
|
6704
|
+
logger.warn("Workflow-gate predecessor fence failed during publish", {
|
|
6705
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6706
|
+
});
|
|
6574
6707
|
}
|
|
6575
6708
|
if (previousSessionId) notifyWorkflowGateEmitterChanged(previousSessionId, undefined);
|
|
6576
6709
|
this.setWorkflowGateEmitter(successorEmitter);
|
|
6577
6710
|
}
|
|
6578
6711
|
|
|
6712
|
+
#bindWorkflowGateEmitter(previousSessionId?: string, previousEmitter = this.#workflowGateEmitter): void {
|
|
6713
|
+
this.#publishWorkflowGateEmitter(this.#constructWorkflowGateEmitter(), previousSessionId, previousEmitter);
|
|
6714
|
+
}
|
|
6715
|
+
|
|
6579
6716
|
#suspendWorkflowGateEmitter(sessionId: string): WorkflowGateEmitter | undefined {
|
|
6580
6717
|
const emitter = this.#workflowGateEmitter;
|
|
6581
6718
|
if (!emitter) return undefined;
|
|
6582
|
-
|
|
6719
|
+
// Clear the field first, then run the (throwable) suspend + listener
|
|
6720
|
+
// notification inside a guard. This guarantees the caller always receives the
|
|
6721
|
+
// emitter token so a rollback can restore it, even if a registry listener
|
|
6722
|
+
// throws during notification.
|
|
6583
6723
|
this.#workflowGateEmitter = undefined;
|
|
6584
|
-
|
|
6724
|
+
try {
|
|
6725
|
+
emitter.suspend?.();
|
|
6726
|
+
notifyWorkflowGateEmitterChanged(sessionId, undefined);
|
|
6727
|
+
} catch (error) {
|
|
6728
|
+
logger.warn("Workflow-gate emitter suspension notification failed", {
|
|
6729
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6730
|
+
});
|
|
6731
|
+
}
|
|
6585
6732
|
return emitter;
|
|
6586
6733
|
}
|
|
6587
6734
|
|
|
@@ -7290,7 +7437,12 @@ export class AgentSession {
|
|
|
7290
7437
|
resetRetryReplaySafety?: boolean;
|
|
7291
7438
|
},
|
|
7292
7439
|
): Promise<void> {
|
|
7440
|
+
this.#assertNoHandoffTransition();
|
|
7293
7441
|
await this.#agentEndPublicationPromise;
|
|
7442
|
+
// Re-check after the publication await: a handoff can engage during that
|
|
7443
|
+
// window, and #beginInFlight below would otherwise start a turn against the
|
|
7444
|
+
// session being handed off.
|
|
7445
|
+
this.#assertNoHandoffTransition();
|
|
7294
7446
|
this.#beginInFlight();
|
|
7295
7447
|
const predecessorAgentEndHold =
|
|
7296
7448
|
options?.predecessorAgentEndHold ?? this.#reserveDeferredAgentEndForContinuation();
|
|
@@ -7776,6 +7928,7 @@ export class AgentSession {
|
|
|
7776
7928
|
images?: ImageContent[],
|
|
7777
7929
|
options?: { claimsGenuineUserIntent?: boolean },
|
|
7778
7930
|
): Promise<void> {
|
|
7931
|
+
this.#assertNoHandoffTransition();
|
|
7779
7932
|
assertImagePlaceholdersHavePayload(text, images);
|
|
7780
7933
|
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
|
|
7781
7934
|
this.#steeringMessages.push(this.#createQueuedDisplayEntry(displayText));
|
|
@@ -7810,6 +7963,7 @@ export class AgentSession {
|
|
|
7810
7963
|
images?: ImageContent[],
|
|
7811
7964
|
options?: { forceOneAtATime?: boolean; claimsGenuineUserIntent?: boolean },
|
|
7812
7965
|
): Promise<void> {
|
|
7966
|
+
this.#assertNoHandoffTransition();
|
|
7813
7967
|
assertImagePlaceholdersHavePayload(text, images);
|
|
7814
7968
|
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
|
|
7815
7969
|
this.#followUpMessages.push(this.#createQueuedDisplayEntry(displayText));
|
|
@@ -7881,6 +8035,10 @@ export class AgentSession {
|
|
|
7881
8035
|
}
|
|
7882
8036
|
|
|
7883
8037
|
#queueHiddenNextTurnMessage(message: CustomMessage, triggerTurn: boolean): void {
|
|
8038
|
+
// A hidden next-turn message queued during a handoff transition would be
|
|
8039
|
+
// dropped when the successor clears predecessor queues; reject it as busy so
|
|
8040
|
+
// the caller can retry against the settled session.
|
|
8041
|
+
this.#assertNoHandoffTransition();
|
|
7884
8042
|
this.#pendingNextTurnMessages.push(message);
|
|
7885
8043
|
if (!triggerTurn) return;
|
|
7886
8044
|
const generation = this.#promptGeneration;
|
|
@@ -8000,6 +8158,10 @@ export class AgentSession {
|
|
|
8000
8158
|
this.#deepInterviewGenuineUserMessageEpochs.set(appMessage, epoch);
|
|
8001
8159
|
}
|
|
8002
8160
|
if (this.isStreaming) {
|
|
8161
|
+
// A handoff transition owns the session; a background/custom trigger (cron,
|
|
8162
|
+
// monitor, skill) must not steer/follow-up/queue against the outgoing turn
|
|
8163
|
+
// while an (auto-)handoff is unwinding it.
|
|
8164
|
+
this.#assertNoHandoffTransition();
|
|
8003
8165
|
if (options?.deliverAs === "nextTurn") {
|
|
8004
8166
|
this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false);
|
|
8005
8167
|
return;
|
|
@@ -8725,12 +8887,15 @@ export class AgentSession {
|
|
|
8725
8887
|
*/
|
|
8726
8888
|
newSession(options?: NewSessionOptions): Promise<boolean> {
|
|
8727
8889
|
if (this.#newSessionTransition) return this.#newSessionTransition;
|
|
8728
|
-
|
|
8890
|
+
// Acquire the shared transition lease only when starting a fresh transition
|
|
8891
|
+
// (the dedup above returns the in-flight promise without re-acquiring).
|
|
8892
|
+
this.#beginSessionTransition("new-session");
|
|
8729
8893
|
const transition = this.#runNewSessionTransition(options);
|
|
8730
8894
|
this.#newSessionTransition = transition;
|
|
8731
8895
|
void transition
|
|
8732
8896
|
.finally(() => {
|
|
8733
8897
|
if (this.#newSessionTransition === transition) this.#newSessionTransition = undefined;
|
|
8898
|
+
this.#endSessionTransition();
|
|
8734
8899
|
})
|
|
8735
8900
|
.catch(() => {});
|
|
8736
8901
|
return transition;
|
|
@@ -8925,34 +9090,39 @@ export class AgentSession {
|
|
|
8925
9090
|
* session identity and durable history trail.
|
|
8926
9091
|
*/
|
|
8927
9092
|
async clearContext(): Promise<boolean> {
|
|
8928
|
-
|
|
8929
|
-
|
|
8930
|
-
|
|
8931
|
-
|
|
8932
|
-
|
|
8933
|
-
|
|
8934
|
-
|
|
8935
|
-
|
|
8936
|
-
|
|
8937
|
-
|
|
8938
|
-
|
|
8939
|
-
|
|
8940
|
-
|
|
8941
|
-
|
|
8942
|
-
|
|
8943
|
-
|
|
8944
|
-
|
|
9093
|
+
this.#beginSessionTransition("clear-context");
|
|
9094
|
+
try {
|
|
9095
|
+
const sessionId = this.sessionId;
|
|
9096
|
+
this.#disconnectFromAgent();
|
|
9097
|
+
await this.abort();
|
|
9098
|
+
this.#cancelOwnAsyncJobs();
|
|
9099
|
+
this.#suppressOwnAsyncJobDeliveries();
|
|
9100
|
+
this.yieldQueue.clear();
|
|
9101
|
+
this.#pendingBackgroundExchanges = [];
|
|
9102
|
+
this.#closeAllProviderSessions("context clear");
|
|
9103
|
+
this.agent.reset();
|
|
9104
|
+
await this.sessionManager.flush();
|
|
9105
|
+
this.sessionManager.appendContextClearEntry({ sessionId });
|
|
9106
|
+
this.setTodoPhases([]);
|
|
9107
|
+
this.#syncAgentSessionId(sessionId);
|
|
9108
|
+
this.#steeringMessages = [];
|
|
9109
|
+
this.#followUpMessages = [];
|
|
9110
|
+
this.#pendingNextTurnMessages = [];
|
|
9111
|
+
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
8945
9112
|
|
|
8946
|
-
|
|
8947
|
-
|
|
8948
|
-
|
|
9113
|
+
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
|
|
9114
|
+
if (this.model) {
|
|
9115
|
+
this.sessionManager.appendModelChange(`${this.model.provider}/${this.model.id}`);
|
|
9116
|
+
}
|
|
9117
|
+
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
|
|
9118
|
+
this.#todoReminderCount = 0;
|
|
9119
|
+
this.#planReferenceSent = false;
|
|
9120
|
+
this.#planReferencePath = "local://PLAN.md";
|
|
9121
|
+
this.#reconnectToAgent();
|
|
9122
|
+
return true;
|
|
9123
|
+
} finally {
|
|
9124
|
+
this.#endSessionTransition();
|
|
8949
9125
|
}
|
|
8950
|
-
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
|
|
8951
|
-
this.#todoReminderCount = 0;
|
|
8952
|
-
this.#planReferenceSent = false;
|
|
8953
|
-
this.#planReferencePath = "local://PLAN.md";
|
|
8954
|
-
this.#reconnectToAgent();
|
|
8955
|
-
return true;
|
|
8956
9126
|
}
|
|
8957
9127
|
|
|
8958
9128
|
/**
|
|
@@ -8969,47 +9139,54 @@ export class AgentSession {
|
|
|
8969
9139
|
* @returns true if completed, false if cancelled by hook or not persisting
|
|
8970
9140
|
*/
|
|
8971
9141
|
async fork(): Promise<boolean> {
|
|
8972
|
-
|
|
8973
|
-
|
|
9142
|
+
// Fork replaces session identity/file and publishes session_switch; serialize
|
|
9143
|
+
// it with handoff and the other transitions via the shared lease.
|
|
9144
|
+
this.#beginSessionTransition("fork");
|
|
9145
|
+
try {
|
|
9146
|
+
const previousSessionFile = this.sessionFile;
|
|
9147
|
+
const previousWorkflowGateSessionId = this.sessionId;
|
|
8974
9148
|
|
|
8975
|
-
|
|
8976
|
-
|
|
8977
|
-
|
|
8978
|
-
|
|
8979
|
-
|
|
8980
|
-
|
|
9149
|
+
// Emit session_before_switch event with reason "fork" (can be cancelled)
|
|
9150
|
+
if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
|
|
9151
|
+
const result = (await this.#extensionRunner.emit({
|
|
9152
|
+
type: "session_before_switch",
|
|
9153
|
+
reason: "fork",
|
|
9154
|
+
})) as SessionBeforeSwitchResult | undefined;
|
|
8981
9155
|
|
|
8982
|
-
|
|
8983
|
-
|
|
9156
|
+
if (result?.cancel) {
|
|
9157
|
+
return false;
|
|
9158
|
+
}
|
|
8984
9159
|
}
|
|
8985
|
-
}
|
|
8986
9160
|
|
|
8987
|
-
|
|
8988
|
-
|
|
9161
|
+
// Flush current session to ensure all entries are written
|
|
9162
|
+
await this.sessionManager.flush();
|
|
8989
9163
|
|
|
8990
|
-
|
|
8991
|
-
|
|
8992
|
-
|
|
8993
|
-
|
|
8994
|
-
|
|
9164
|
+
// Fork the session (creates new session file with same entries)
|
|
9165
|
+
const forkResult = await this.sessionManager.fork();
|
|
9166
|
+
if (!forkResult) {
|
|
9167
|
+
return false;
|
|
9168
|
+
}
|
|
8995
9169
|
|
|
8996
|
-
|
|
8997
|
-
|
|
8998
|
-
|
|
8999
|
-
|
|
9170
|
+
// Update agent session ID
|
|
9171
|
+
this.#syncAgentSessionId();
|
|
9172
|
+
this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
|
|
9173
|
+
this.#rekeyHindsightMemoryForCurrentSessionId();
|
|
9000
9174
|
|
|
9001
|
-
|
|
9175
|
+
this.#resetIrcRosterDeliveryState();
|
|
9002
9176
|
|
|
9003
|
-
|
|
9004
|
-
|
|
9005
|
-
|
|
9006
|
-
|
|
9007
|
-
|
|
9008
|
-
|
|
9009
|
-
|
|
9010
|
-
|
|
9177
|
+
// Emit session_switch event with reason "fork" to hooks
|
|
9178
|
+
if (this.#extensionRunner) {
|
|
9179
|
+
await this.#extensionRunner.emit({
|
|
9180
|
+
type: "session_switch",
|
|
9181
|
+
reason: "fork",
|
|
9182
|
+
previousSessionFile,
|
|
9183
|
+
});
|
|
9184
|
+
}
|
|
9011
9185
|
|
|
9012
|
-
|
|
9186
|
+
return true;
|
|
9187
|
+
} finally {
|
|
9188
|
+
this.#endSessionTransition();
|
|
9189
|
+
}
|
|
9013
9190
|
}
|
|
9014
9191
|
|
|
9015
9192
|
// =========================================================================
|
|
@@ -10009,148 +10186,156 @@ export class AgentSession {
|
|
|
10009
10186
|
* @param options Optional callbacks for completion/error handling
|
|
10010
10187
|
*/
|
|
10011
10188
|
async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
|
|
10012
|
-
|
|
10013
|
-
|
|
10014
|
-
|
|
10015
|
-
this.#
|
|
10016
|
-
await this.abort();
|
|
10017
|
-
const compactionAbortController = new AbortController();
|
|
10018
|
-
this.#compactionAbortController = compactionAbortController;
|
|
10019
|
-
|
|
10189
|
+
// Serialize with every other session-identity transition via the shared lease
|
|
10190
|
+
// (bidirectional mutual exclusion with handoff/new/switch/branch/clear/fork/
|
|
10191
|
+
// navigateTree). Released in the outer finally below.
|
|
10192
|
+
this.#beginSessionTransition("compact");
|
|
10020
10193
|
try {
|
|
10021
|
-
if (
|
|
10022
|
-
throw new Error("
|
|
10194
|
+
if (this.#compactionAbortController) {
|
|
10195
|
+
throw new Error("Compaction already in progress");
|
|
10023
10196
|
}
|
|
10197
|
+
this.#disconnectFromAgent();
|
|
10198
|
+
await this.abort();
|
|
10199
|
+
const compactionAbortController = new AbortController();
|
|
10200
|
+
this.#compactionAbortController = compactionAbortController;
|
|
10024
10201
|
|
|
10025
|
-
|
|
10026
|
-
|
|
10027
|
-
|
|
10028
|
-
const preparation = prepareCompaction(pathEntries, compactionSettings, {
|
|
10029
|
-
contextWindow: this.model.contextWindow,
|
|
10030
|
-
tokenCorrectionRatio: this.#computeCompactionTokenCorrectionRatio(),
|
|
10031
|
-
});
|
|
10032
|
-
if (!preparation) {
|
|
10033
|
-
// Check why we can't compact
|
|
10034
|
-
const lastEntry = pathEntries[pathEntries.length - 1];
|
|
10035
|
-
if (lastEntry?.type === "compaction") {
|
|
10036
|
-
throw new Error("Already compacted");
|
|
10202
|
+
try {
|
|
10203
|
+
if (!this.model) {
|
|
10204
|
+
throw new Error("No model selected");
|
|
10037
10205
|
}
|
|
10038
|
-
throw new Error("Nothing to compact (session too small)");
|
|
10039
|
-
}
|
|
10040
|
-
|
|
10041
|
-
let hookCompaction: CompactionResult | undefined;
|
|
10042
|
-
let fromExtension = false;
|
|
10043
|
-
let preserveData: Record<string, unknown> | undefined;
|
|
10044
|
-
|
|
10045
|
-
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
|
|
10046
|
-
const result = (await this.#extensionRunner.emit({
|
|
10047
|
-
type: "session_before_compact",
|
|
10048
|
-
preparation,
|
|
10049
|
-
branchEntries: pathEntries,
|
|
10050
|
-
customInstructions,
|
|
10051
|
-
signal: compactionAbortController.signal,
|
|
10052
|
-
})) as SessionBeforeCompactResult | undefined;
|
|
10053
10206
|
|
|
10054
|
-
|
|
10055
|
-
|
|
10056
|
-
}
|
|
10207
|
+
const compactionSettings = this.settings.getGroup("compaction");
|
|
10208
|
+
const pathEntries = this.#withoutEphemeralCustomMessageEntries(this.sessionManager.getBranch());
|
|
10057
10209
|
|
|
10058
|
-
|
|
10059
|
-
|
|
10060
|
-
|
|
10210
|
+
const preparation = prepareCompaction(pathEntries, compactionSettings, {
|
|
10211
|
+
contextWindow: this.model.contextWindow,
|
|
10212
|
+
tokenCorrectionRatio: this.#computeCompactionTokenCorrectionRatio(),
|
|
10213
|
+
});
|
|
10214
|
+
if (!preparation) {
|
|
10215
|
+
// Check why we can't compact
|
|
10216
|
+
const lastEntry = pathEntries[pathEntries.length - 1];
|
|
10217
|
+
if (lastEntry?.type === "compaction") {
|
|
10218
|
+
throw new Error("Already compacted");
|
|
10219
|
+
}
|
|
10220
|
+
throw new Error("Nothing to compact (session too small)");
|
|
10061
10221
|
}
|
|
10062
|
-
}
|
|
10063
10222
|
|
|
10064
|
-
|
|
10065
|
-
|
|
10066
|
-
|
|
10067
|
-
let shortSummary: string | undefined;
|
|
10068
|
-
let firstKeptEntryId: string;
|
|
10069
|
-
let tokensBefore: number;
|
|
10070
|
-
let details: unknown;
|
|
10223
|
+
let hookCompaction: CompactionResult | undefined;
|
|
10224
|
+
let fromExtension = false;
|
|
10225
|
+
let preserveData: Record<string, unknown> | undefined;
|
|
10071
10226
|
|
|
10072
|
-
|
|
10073
|
-
|
|
10074
|
-
|
|
10075
|
-
firstKeptEntryId = compactionPrep.firstKeptEntryId;
|
|
10076
|
-
tokensBefore = compactionPrep.tokensBefore;
|
|
10077
|
-
details = compactionPrep.details;
|
|
10078
|
-
preserveData = compactionPrep.preserveData;
|
|
10079
|
-
} else {
|
|
10080
|
-
// Generate compaction result. Only convert known abort-shaped
|
|
10081
|
-
// rejections (AbortError raised while the abort signal is set,
|
|
10082
|
-
// or an already-typed sentinel) into `CompactionCancelledError`
|
|
10083
|
-
// so downstream callers can discriminate cancel from generic
|
|
10084
|
-
// failure via `instanceof` without inspecting message strings.
|
|
10085
|
-
// Real compaction bugs (network, server, parsing, etc.) keep
|
|
10086
|
-
// their original shape — they must not be silently relabeled
|
|
10087
|
-
// as cancellations even if the signal happens to be aborted
|
|
10088
|
-
// for an unrelated reason. Assignments live inside the try
|
|
10089
|
-
// block because every catch path throws — the post-try reads
|
|
10090
|
-
// of the result-derived locals are reachable only on success.
|
|
10091
|
-
try {
|
|
10092
|
-
const result = await this.#compactWithFallbackModel(
|
|
10227
|
+
if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
|
|
10228
|
+
const result = (await this.#extensionRunner.emit({
|
|
10229
|
+
type: "session_before_compact",
|
|
10093
10230
|
preparation,
|
|
10231
|
+
branchEntries: pathEntries,
|
|
10094
10232
|
customInstructions,
|
|
10095
|
-
compactionAbortController.signal,
|
|
10096
|
-
|
|
10097
|
-
|
|
10098
|
-
|
|
10099
|
-
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
|
|
10100
|
-
convertToLlm,
|
|
10101
|
-
},
|
|
10102
|
-
);
|
|
10103
|
-
summary = result.summary;
|
|
10104
|
-
shortSummary = result.shortSummary;
|
|
10105
|
-
firstKeptEntryId = result.firstKeptEntryId;
|
|
10106
|
-
tokensBefore = result.tokensBefore;
|
|
10107
|
-
details = result.details;
|
|
10108
|
-
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) };
|
|
10109
|
-
} catch (err) {
|
|
10110
|
-
if (err instanceof CompactionCancelledError) {
|
|
10111
|
-
throw err;
|
|
10112
|
-
}
|
|
10113
|
-
if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") {
|
|
10233
|
+
signal: compactionAbortController.signal,
|
|
10234
|
+
})) as SessionBeforeCompactResult | undefined;
|
|
10235
|
+
|
|
10236
|
+
if (result?.cancel) {
|
|
10114
10237
|
throw new CompactionCancelledError();
|
|
10115
10238
|
}
|
|
10116
|
-
|
|
10239
|
+
|
|
10240
|
+
if (result?.compaction) {
|
|
10241
|
+
hookCompaction = result.compaction;
|
|
10242
|
+
fromExtension = true;
|
|
10243
|
+
}
|
|
10117
10244
|
}
|
|
10118
|
-
}
|
|
10119
10245
|
|
|
10120
|
-
|
|
10121
|
-
|
|
10122
|
-
|
|
10246
|
+
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
|
|
10247
|
+
|
|
10248
|
+
let summary: string;
|
|
10249
|
+
let shortSummary: string | undefined;
|
|
10250
|
+
let firstKeptEntryId: string;
|
|
10251
|
+
let tokensBefore: number;
|
|
10252
|
+
let details: unknown;
|
|
10253
|
+
|
|
10254
|
+
if (compactionPrep.kind === "fromHook") {
|
|
10255
|
+
summary = compactionPrep.summary;
|
|
10256
|
+
shortSummary = compactionPrep.shortSummary;
|
|
10257
|
+
firstKeptEntryId = compactionPrep.firstKeptEntryId;
|
|
10258
|
+
tokensBefore = compactionPrep.tokensBefore;
|
|
10259
|
+
details = compactionPrep.details;
|
|
10260
|
+
preserveData = compactionPrep.preserveData;
|
|
10261
|
+
} else {
|
|
10262
|
+
// Generate compaction result. Only convert known abort-shaped
|
|
10263
|
+
// rejections (AbortError raised while the abort signal is set,
|
|
10264
|
+
// or an already-typed sentinel) into `CompactionCancelledError`
|
|
10265
|
+
// so downstream callers can discriminate cancel from generic
|
|
10266
|
+
// failure via `instanceof` without inspecting message strings.
|
|
10267
|
+
// Real compaction bugs (network, server, parsing, etc.) keep
|
|
10268
|
+
// their original shape — they must not be silently relabeled
|
|
10269
|
+
// as cancellations even if the signal happens to be aborted
|
|
10270
|
+
// for an unrelated reason. Assignments live inside the try
|
|
10271
|
+
// block because every catch path throws — the post-try reads
|
|
10272
|
+
// of the result-derived locals are reachable only on success.
|
|
10273
|
+
try {
|
|
10274
|
+
const result = await this.#compactWithFallbackModel(
|
|
10275
|
+
preparation,
|
|
10276
|
+
customInstructions,
|
|
10277
|
+
compactionAbortController.signal,
|
|
10278
|
+
{
|
|
10279
|
+
promptOverride: compactionPrep.hookPrompt,
|
|
10280
|
+
extraContext: compactionPrep.hookContext,
|
|
10281
|
+
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
|
|
10282
|
+
convertToLlm,
|
|
10283
|
+
},
|
|
10284
|
+
);
|
|
10285
|
+
summary = result.summary;
|
|
10286
|
+
shortSummary = result.shortSummary;
|
|
10287
|
+
firstKeptEntryId = result.firstKeptEntryId;
|
|
10288
|
+
tokensBefore = result.tokensBefore;
|
|
10289
|
+
details = result.details;
|
|
10290
|
+
preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) };
|
|
10291
|
+
} catch (err) {
|
|
10292
|
+
if (err instanceof CompactionCancelledError) {
|
|
10293
|
+
throw err;
|
|
10294
|
+
}
|
|
10295
|
+
if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") {
|
|
10296
|
+
throw new CompactionCancelledError();
|
|
10297
|
+
}
|
|
10298
|
+
throw err;
|
|
10299
|
+
}
|
|
10300
|
+
}
|
|
10123
10301
|
|
|
10124
|
-
|
|
10125
|
-
|
|
10126
|
-
|
|
10127
|
-
firstKeptEntryId,
|
|
10128
|
-
tokensBefore,
|
|
10129
|
-
details,
|
|
10130
|
-
fromExtension,
|
|
10131
|
-
preserveData,
|
|
10132
|
-
);
|
|
10133
|
-
await this.#applyCompactionPostAppend(compactionEntryId, firstKeptEntryId, fromExtension);
|
|
10302
|
+
if (compactionAbortController.signal.aborted) {
|
|
10303
|
+
throw new CompactionCancelledError();
|
|
10304
|
+
}
|
|
10134
10305
|
|
|
10135
|
-
|
|
10136
|
-
|
|
10137
|
-
|
|
10138
|
-
|
|
10139
|
-
|
|
10140
|
-
|
|
10141
|
-
|
|
10142
|
-
|
|
10143
|
-
|
|
10144
|
-
|
|
10145
|
-
|
|
10146
|
-
|
|
10147
|
-
|
|
10148
|
-
|
|
10149
|
-
|
|
10150
|
-
|
|
10151
|
-
|
|
10306
|
+
const compactionEntryId = this.sessionManager.appendCompaction(
|
|
10307
|
+
summary,
|
|
10308
|
+
shortSummary,
|
|
10309
|
+
firstKeptEntryId,
|
|
10310
|
+
tokensBefore,
|
|
10311
|
+
details,
|
|
10312
|
+
fromExtension,
|
|
10313
|
+
preserveData,
|
|
10314
|
+
);
|
|
10315
|
+
await this.#applyCompactionPostAppend(compactionEntryId, firstKeptEntryId, fromExtension);
|
|
10316
|
+
|
|
10317
|
+
const compactionResult: CompactionResult = {
|
|
10318
|
+
summary,
|
|
10319
|
+
shortSummary,
|
|
10320
|
+
firstKeptEntryId,
|
|
10321
|
+
tokensBefore,
|
|
10322
|
+
details,
|
|
10323
|
+
preserveData,
|
|
10324
|
+
};
|
|
10325
|
+
options?.onComplete?.(compactionResult);
|
|
10326
|
+
return compactionResult;
|
|
10327
|
+
} catch (error) {
|
|
10328
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
10329
|
+
options?.onError?.(err);
|
|
10330
|
+
throw error;
|
|
10331
|
+
} finally {
|
|
10332
|
+
if (this.#compactionAbortController === compactionAbortController) {
|
|
10333
|
+
this.#compactionAbortController = undefined;
|
|
10334
|
+
}
|
|
10335
|
+
this.#reconnectToAgent();
|
|
10152
10336
|
}
|
|
10153
|
-
|
|
10337
|
+
} finally {
|
|
10338
|
+
this.#endSessionTransition();
|
|
10154
10339
|
}
|
|
10155
10340
|
}
|
|
10156
10341
|
|
|
@@ -10214,6 +10399,8 @@ export class AgentSession {
|
|
|
10214
10399
|
/** Trigger idle compaction through the auto-compaction flow (with UI events). */
|
|
10215
10400
|
async runIdleCompaction(): Promise<void> {
|
|
10216
10401
|
if (this.isStreaming || this.isCompacting) return;
|
|
10402
|
+
// Do not start idle compaction while a handoff transition owns the session.
|
|
10403
|
+
if (this.isGeneratingHandoff || this.#handoffTransitionActive) return;
|
|
10217
10404
|
await this.#runAutoCompaction("idle", false, true);
|
|
10218
10405
|
}
|
|
10219
10406
|
|
|
@@ -10252,8 +10439,37 @@ export class AgentSession {
|
|
|
10252
10439
|
if (messageCount < 2) {
|
|
10253
10440
|
throw new Error("Nothing to hand off (no messages yet)");
|
|
10254
10441
|
}
|
|
10442
|
+
// Single-flight: a concurrent handoff would overwrite the abort controller
|
|
10443
|
+
// and the transition fence, letting one invocation's finally clear the
|
|
10444
|
+
// other's ownership and allowing overlapping newSession/restore transactions.
|
|
10445
|
+
if (this.isGeneratingHandoff || this.#handoffTransitionActive) {
|
|
10446
|
+
throw Object.assign(new Error("A handoff is already in progress."), { code: "busy" });
|
|
10447
|
+
}
|
|
10448
|
+
// A manual/external handoff must not race an active turn: replacing the
|
|
10449
|
+
// session and resetting the agent mid-stream can strand old-turn events
|
|
10450
|
+
// onto the successor session. Auto-triggered handoffs run during
|
|
10451
|
+
// post-turn maintenance (never mid-stream) and are exempt.
|
|
10452
|
+
if (this.isStreaming && !options?.autoTriggered) {
|
|
10453
|
+
throw Object.assign(
|
|
10454
|
+
new Error("Cannot hand off while a response is streaming; wait for it to finish or abort it first."),
|
|
10455
|
+
{ code: "busy" },
|
|
10456
|
+
);
|
|
10457
|
+
}
|
|
10458
|
+
// Acquire the shared session-transition lease so handoff is mutually exclusive
|
|
10459
|
+
// with compact/new/switch/branch/clear/fork/navigateTree in BOTH directions —
|
|
10460
|
+
// a transition that started first and yielded still owns the lease here, and a
|
|
10461
|
+
// peer that starts after us is rejected at its own entry. Auto-triggered
|
|
10462
|
+
// handoff still runs while auto-compaction owns its abort controller, but the
|
|
10463
|
+
// maintenance orchestrator does not hold this lease, so acquiring it here does
|
|
10464
|
+
// not self-deadlock. Released in the outer finally below.
|
|
10465
|
+
this.#beginSessionTransition("handoff");
|
|
10255
10466
|
|
|
10256
10467
|
this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
|
|
10468
|
+
// Fence background async-job delivery for the whole transition (generation
|
|
10469
|
+
// through commit/rollback): a completion that lands mid-handoff must not
|
|
10470
|
+
// start an idle turn against the session being replaced or the restored
|
|
10471
|
+
// predecessor. Cleared in the finally below.
|
|
10472
|
+
this.#handoffTransitionActive = true;
|
|
10257
10473
|
|
|
10258
10474
|
this.#handoffAbortController = new AbortController();
|
|
10259
10475
|
const handoffAbortController = this.#handoffAbortController;
|
|
@@ -10294,6 +10510,7 @@ export class AgentSession {
|
|
|
10294
10510
|
systemPrompt: this.#baseSystemPrompt,
|
|
10295
10511
|
tools: this.agent.state.tools,
|
|
10296
10512
|
customInstructions,
|
|
10513
|
+
promptExtension: this.settings.get("compaction.handoffPromptExtension") || undefined,
|
|
10297
10514
|
convertToLlm,
|
|
10298
10515
|
initiatorOverride: "agent",
|
|
10299
10516
|
metadata: this.agent.metadataForProvider(model.provider),
|
|
@@ -10309,68 +10526,207 @@ export class AgentSession {
|
|
|
10309
10526
|
return undefined;
|
|
10310
10527
|
}
|
|
10311
10528
|
|
|
10312
|
-
//
|
|
10313
|
-
|
|
10314
|
-
|
|
10315
|
-
|
|
10316
|
-
|
|
10317
|
-
|
|
10318
|
-
this
|
|
10319
|
-
|
|
10320
|
-
|
|
10321
|
-
|
|
10322
|
-
|
|
10323
|
-
|
|
10324
|
-
|
|
10325
|
-
this.#todoReminderCount = 0;
|
|
10326
|
-
if (model) {
|
|
10327
|
-
this.sessionManager.appendModelChange(`${model.provider}/${model.id}`);
|
|
10529
|
+
// Revalidate immediately before mutating: generation can take seconds,
|
|
10530
|
+
// during which a turn (e.g. a background completion that started before
|
|
10531
|
+
// the delivery fence) may have begun. A manual/external handoff must not
|
|
10532
|
+
// mutate under an active turn; auto-triggered handoff runs at post-turn
|
|
10533
|
+
// maintenance and stays exempt. Nothing has been mutated yet, so throwing
|
|
10534
|
+
// here is fully non-destructive.
|
|
10535
|
+
if (this.isStreaming && !options?.autoTriggered) {
|
|
10536
|
+
throw Object.assign(
|
|
10537
|
+
new Error("Cannot hand off while a response is streaming; wait for it to finish or abort it first."),
|
|
10538
|
+
// The document was already generated; retain it so public callers can
|
|
10539
|
+
// copy/retry even though this late race declines to mutate.
|
|
10540
|
+
{ code: "busy", handoffDocument: handoffText },
|
|
10541
|
+
);
|
|
10328
10542
|
}
|
|
10329
|
-
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
|
|
10330
|
-
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
|
|
10331
10543
|
|
|
10332
|
-
//
|
|
10333
|
-
|
|
10334
|
-
|
|
10335
|
-
|
|
10544
|
+
// Start a new session transactionally. Capture restore state before the
|
|
10545
|
+
// switch so a persistence, injection, display, or extension failure
|
|
10546
|
+
// after the switch is non-destructive: the current session stays active
|
|
10547
|
+
// and the generated handoff document is preserved for copy/retry.
|
|
10548
|
+
const previousSessionFile = this.sessionFile;
|
|
10549
|
+
await this.sessionManager.flush();
|
|
10550
|
+
const rollbackSessionState = this.sessionManager.captureState();
|
|
10551
|
+
const rollbackAgentMessages = [...this.agent.state.messages];
|
|
10552
|
+
const rollbackSteeringMessages = [...this.#steeringMessages];
|
|
10553
|
+
const rollbackFollowUpMessages = [...this.#followUpMessages];
|
|
10554
|
+
const rollbackPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
|
|
10555
|
+
const rollbackScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
|
|
10556
|
+
const rollbackTodoReminderCount = this.#todoReminderCount;
|
|
10557
|
+
// Snapshot the agent's executable queues so a rollback restores queued
|
|
10558
|
+
// user work that agent.reset() would otherwise clear.
|
|
10559
|
+
const rollbackAgentSteeringQueue = this.agent.snapshotSteering();
|
|
10560
|
+
const rollbackAgentFollowUpQueue = this.agent.snapshotFollowUp();
|
|
10561
|
+
let successorSessionFile: string | undefined;
|
|
10336
10562
|
let savedPath: string | undefined;
|
|
10337
|
-
|
|
10338
|
-
|
|
10339
|
-
|
|
10340
|
-
|
|
10341
|
-
|
|
10342
|
-
|
|
10343
|
-
|
|
10344
|
-
|
|
10563
|
+
let committed = false;
|
|
10564
|
+
// Suspend (do not destroy) the workflow-gate emitter so it can be fenced on
|
|
10565
|
+
// commit or resumed on rollback. Suspension runs inside the transaction so a
|
|
10566
|
+
// listener fault during suspension is rolled back like any other prepare step.
|
|
10567
|
+
let suspendedWorkflowGateEmitter: WorkflowGateEmitter | undefined;
|
|
10568
|
+
try {
|
|
10569
|
+
suspendedWorkflowGateEmitter = this.#suspendWorkflowGateEmitter(rollbackSessionState.sessionId);
|
|
10570
|
+
// --- Prepare (reversible): build and persist the successor session
|
|
10571
|
+
// without touching predecessor authority. No irreversible predecessor
|
|
10572
|
+
// teardown or identity publication happens until the commit boundary.
|
|
10573
|
+
await this.sessionManager.newSession(
|
|
10574
|
+
previousSessionFile ? { parentSession: previousSessionFile } : undefined,
|
|
10575
|
+
);
|
|
10576
|
+
successorSessionFile = this.sessionFile;
|
|
10577
|
+
this.agent.reset();
|
|
10578
|
+
this.#syncAgentSessionId();
|
|
10579
|
+
this.#rekeyHindsightMemoryForCurrentSessionId();
|
|
10580
|
+
this.#steeringMessages = [];
|
|
10581
|
+
this.#followUpMessages = [];
|
|
10582
|
+
this.#pendingNextTurnMessages = [];
|
|
10583
|
+
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
10584
|
+
this.#todoReminderCount = 0;
|
|
10585
|
+
if (model) {
|
|
10586
|
+
this.sessionManager.appendModelChange(`${model.provider}/${model.id}`);
|
|
10587
|
+
}
|
|
10588
|
+
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
|
|
10589
|
+
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
|
|
10590
|
+
|
|
10591
|
+
// Inject the handoff document as a custom message
|
|
10592
|
+
const handoffContent = createHandoffContext(handoffText);
|
|
10593
|
+
this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true, undefined, "agent");
|
|
10594
|
+
await this.sessionManager.ensureOnDisk();
|
|
10595
|
+
if (options?.autoTriggered && this.settings.get("compaction.handoffSaveToDisk")) {
|
|
10596
|
+
try {
|
|
10597
|
+
const artifactId = await this.sessionManager.saveArtifact(`${handoffText}\n`, "handoff");
|
|
10598
|
+
savedPath = `artifact://${artifactId}`;
|
|
10599
|
+
} catch (error) {
|
|
10600
|
+
logger.warn("Failed to save handoff document", {
|
|
10601
|
+
error: error instanceof Error ? error.message : String(error),
|
|
10602
|
+
});
|
|
10603
|
+
}
|
|
10345
10604
|
}
|
|
10346
|
-
}
|
|
10347
10605
|
|
|
10348
|
-
|
|
10349
|
-
|
|
10350
|
-
|
|
10351
|
-
|
|
10352
|
-
|
|
10353
|
-
|
|
10354
|
-
|
|
10606
|
+
// Rebuild agent messages from session
|
|
10607
|
+
const sessionContext = this.buildDisplaySessionContext();
|
|
10608
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
10609
|
+
this.#syncTodoPhasesFromBranch();
|
|
10610
|
+
|
|
10611
|
+
// Construct the successor workflow-gate emitter in the reversible window:
|
|
10612
|
+
// FileGateStore load / broker init can throw, and a failure here must roll
|
|
10613
|
+
// back rather than corrupt an already-committed switch. Publication is
|
|
10614
|
+
// deferred to the (no-throw) commit step below.
|
|
10615
|
+
const successorGateEmitter = this.#constructWorkflowGateEmitter();
|
|
10616
|
+
|
|
10617
|
+
// --- Commit boundary: the successor is durably persisted and every
|
|
10618
|
+
// reversible, fallible step above has succeeded. Cross the irreversible
|
|
10619
|
+
// commit BEFORE any identity rotation. Fencing the predecessor gate
|
|
10620
|
+
// emitter and rotating provider sessions cannot be undone, so once we
|
|
10621
|
+
// begin them a failure must RETAIN the committed successor rather than
|
|
10622
|
+
// attempt a (now-impossible) rollback that would resume a fenced emitter.
|
|
10623
|
+
committed = true;
|
|
10624
|
+
this.#resetInjectedContextSignatures();
|
|
10625
|
+
this.#publishWorkflowGateEmitter(
|
|
10626
|
+
successorGateEmitter,
|
|
10627
|
+
rollbackSessionState.sessionId,
|
|
10628
|
+
suspendedWorkflowGateEmitter,
|
|
10629
|
+
);
|
|
10630
|
+
this.#closeAllProviderSessions("session handoff");
|
|
10631
|
+
this.#rebindProviderSessionState(new Map());
|
|
10632
|
+
this.#resetHindsightConversationTrackingIfHindsight();
|
|
10633
|
+
this.#resetIrcRosterDeliveryState();
|
|
10634
|
+
this.#planReferenceSent = false;
|
|
10635
|
+
this.#planReferencePath = "local://PLAN.md";
|
|
10636
|
+
this.#cancelOwnAsyncJobs();
|
|
10637
|
+
// The predecessor's fenced async-job results (queued while the
|
|
10638
|
+
// transition held the delivery fence) belong to the handed-off session,
|
|
10639
|
+
// not the successor. Suppress and drop ONLY the async-result kind so they
|
|
10640
|
+
// never flush into the new session; MCP resource notifications are
|
|
10641
|
+
// server-scoped and are preserved for the successor.
|
|
10642
|
+
this.#suppressOwnAsyncJobDeliveries();
|
|
10643
|
+
this.yieldQueue.clearKind("async-result");
|
|
10644
|
+
|
|
10645
|
+
// The successor identity/emitter/provider state is now fully live and
|
|
10646
|
+
// predecessor deliveries are suppressed, so release the turn-admission
|
|
10647
|
+
// fence BEFORE publishing session_switch. Successor turns (e.g. a
|
|
10648
|
+
// session_switch hook queuing steering) are legitimate and must not be
|
|
10649
|
+
// rejected; the finally below is only a backstop for early-exit paths.
|
|
10650
|
+
this.#handoffTransitionActive = false;
|
|
10651
|
+
// session_switch is a post-commit identity signal. Extension handler
|
|
10652
|
+
// errors are isolated by ExtensionRunner and must not roll back the
|
|
10653
|
+
// already-committed switch.
|
|
10654
|
+
if (this.#extensionRunner) {
|
|
10655
|
+
await this.#extensionRunner.emit({
|
|
10656
|
+
type: "session_switch",
|
|
10657
|
+
reason: "new",
|
|
10658
|
+
previousSessionFile,
|
|
10659
|
+
});
|
|
10660
|
+
}
|
|
10355
10661
|
|
|
10356
|
-
|
|
10357
|
-
|
|
10358
|
-
|
|
10359
|
-
|
|
10360
|
-
|
|
10361
|
-
|
|
10662
|
+
return { document: handoffText, savedPath };
|
|
10663
|
+
} catch (switchError) {
|
|
10664
|
+
if (committed) {
|
|
10665
|
+
// The switch already committed; a post-commit step failed. Do not
|
|
10666
|
+
// tear down the new session — surface success (the handoff exists).
|
|
10667
|
+
logger.warn("Handoff post-commit step failed after the session switch committed", {
|
|
10668
|
+
error: switchError instanceof Error ? switchError.message : String(switchError),
|
|
10669
|
+
});
|
|
10670
|
+
return { document: handoffText, savedPath };
|
|
10671
|
+
}
|
|
10672
|
+
// Reversible window: roll back to the pre-handoff session so the
|
|
10673
|
+
// failure is non-destructive. Predecessor gate emitter, provider
|
|
10674
|
+
// sessions, async jobs, IRC/plan bookkeeping, and injection signatures
|
|
10675
|
+
// were never mutated before commit, so they survive intact.
|
|
10676
|
+
this.sessionManager.restoreState(rollbackSessionState);
|
|
10677
|
+
this.#syncAgentSessionId(rollbackSessionState.sessionId);
|
|
10678
|
+
this.#restoreWorkflowGateEmitter(suspendedWorkflowGateEmitter);
|
|
10679
|
+
this.#rekeyHindsightMemoryForCurrentSessionId();
|
|
10680
|
+
this.agent.replaceMessages(rollbackAgentMessages);
|
|
10681
|
+
this.agent.clearAllQueues();
|
|
10682
|
+
this.agent.restoreSteering(rollbackAgentSteeringQueue);
|
|
10683
|
+
this.agent.restoreFollowUp(rollbackAgentFollowUpQueue);
|
|
10684
|
+
this.#steeringMessages = rollbackSteeringMessages;
|
|
10685
|
+
this.#followUpMessages = rollbackFollowUpMessages;
|
|
10686
|
+
this.#pendingNextTurnMessages = rollbackPendingNextTurnMessages;
|
|
10687
|
+
this.#scheduledHiddenNextTurnGeneration = rollbackScheduledHiddenNextTurnGeneration;
|
|
10688
|
+
this.#todoReminderCount = rollbackTodoReminderCount;
|
|
10689
|
+
this.#syncTodoPhasesFromBranch();
|
|
10690
|
+
// Remove the orphaned successor transcript/artifacts written before the
|
|
10691
|
+
// failure so a rolled-back handoff leaves nothing behind.
|
|
10692
|
+
if (successorSessionFile && successorSessionFile !== previousSessionFile) {
|
|
10693
|
+
try {
|
|
10694
|
+
await this.sessionManager.discardUncommittedSession(successorSessionFile);
|
|
10695
|
+
} catch (cleanupError) {
|
|
10696
|
+
logger.warn("Failed to remove orphaned handoff session after rollback", {
|
|
10697
|
+
path: successorSessionFile,
|
|
10698
|
+
error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
|
|
10699
|
+
});
|
|
10700
|
+
}
|
|
10701
|
+
}
|
|
10702
|
+
// Map to cancellation only for a genuine handoff-signal abort; a
|
|
10703
|
+
// downstream error keeps its cause and the generated document.
|
|
10704
|
+
if (handoffSignal.aborted) {
|
|
10705
|
+
throw new Error("Handoff cancelled");
|
|
10706
|
+
}
|
|
10707
|
+
// Preserve the generated handoff document for copy/retry.
|
|
10708
|
+
throw Object.assign(switchError instanceof Error ? switchError : new Error(String(switchError)), {
|
|
10709
|
+
handoffDocument: handoffText,
|
|
10362
10710
|
});
|
|
10363
10711
|
}
|
|
10364
|
-
|
|
10365
|
-
return { document: handoffText, savedPath };
|
|
10366
10712
|
} catch (error) {
|
|
10367
|
-
|
|
10713
|
+
// Genuine handoff-signal cancellation maps to a cancellation error.
|
|
10714
|
+
// Errors surfaced by the inner transaction (which already rolled back
|
|
10715
|
+
// and, for non-aborts, attached the generated document) pass through.
|
|
10716
|
+
if (handoffSignal.aborted) {
|
|
10368
10717
|
throw new Error("Handoff cancelled");
|
|
10369
10718
|
}
|
|
10370
10719
|
throw error;
|
|
10371
10720
|
} finally {
|
|
10372
10721
|
sourceSignal?.removeEventListener("abort", onSourceAbort);
|
|
10373
10722
|
this.#handoffAbortController = undefined;
|
|
10723
|
+
this.#handoffTransitionActive = false;
|
|
10724
|
+
// Releasing the fence: re-arm any idle delivery queued while the transition
|
|
10725
|
+
// held it (e.g. a predecessor async result retained through a rollback, or a
|
|
10726
|
+
// preserved MCP notification) so it is not stranded until an unrelated
|
|
10727
|
+
// enqueue or the next agent yield.
|
|
10728
|
+
this.yieldQueue.rearmIdle();
|
|
10729
|
+
this.#endSessionTransition();
|
|
10374
10730
|
}
|
|
10375
10731
|
}
|
|
10376
10732
|
|
|
@@ -13046,6 +13402,9 @@ export class AgentSession {
|
|
|
13046
13402
|
*/
|
|
13047
13403
|
async retry(): Promise<boolean> {
|
|
13048
13404
|
if (this.isStreaming || this.isCompacting || this.isRetrying) return false;
|
|
13405
|
+
// A handoff transition owns the session; retrying would mutate the tail and
|
|
13406
|
+
// schedule a continuation against the session being handed off.
|
|
13407
|
+
if (this.isGeneratingHandoff || this.#handoffTransitionActive) return false;
|
|
13049
13408
|
|
|
13050
13409
|
const messages = this.agent.state.messages;
|
|
13051
13410
|
const lastMsg = messages[messages.length - 1];
|
|
@@ -13669,167 +14028,127 @@ export class AgentSession {
|
|
|
13669
14028
|
this.#ircRosterClaim = null;
|
|
13670
14029
|
}
|
|
13671
14030
|
|
|
14031
|
+
createBtwConversationScope(instruction: string): BtwConversationScope {
|
|
14032
|
+
const model = this.model;
|
|
14033
|
+
if (!model) throw new Error("No active model on session");
|
|
14034
|
+
const providerAffinitySessionId = this.agent.providerSessionId ?? this.agent.sessionId ?? this.sessionId;
|
|
14035
|
+
return {
|
|
14036
|
+
model,
|
|
14037
|
+
systemPrompt: [...this.systemPrompt, instruction],
|
|
14038
|
+
messages: this.#projectBtwVisibleText(this.buildDisplaySessionContext().messages),
|
|
14039
|
+
thinkingLevel: this.thinkingLevel ?? ThinkingLevel.Off,
|
|
14040
|
+
hideThinkingSummary: this.agent.hideThinkingSummary ?? false,
|
|
14041
|
+
serviceTier: this.serviceTier,
|
|
14042
|
+
credentialSessionId: providerAffinitySessionId,
|
|
14043
|
+
providerAffinitySessionId,
|
|
14044
|
+
sideSessionId: `${providerAffinitySessionId}:btw:${crypto.randomUUID()}`,
|
|
14045
|
+
};
|
|
14046
|
+
}
|
|
14047
|
+
|
|
14048
|
+
#projectBtwVisibleText(messages: readonly AgentMessage[]): BtwRoleTextMessage[] {
|
|
14049
|
+
const projected: BtwRoleTextMessage[] = [];
|
|
14050
|
+
for (const message of messages) {
|
|
14051
|
+
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
14052
|
+
const text = (
|
|
14053
|
+
typeof message.content === "string"
|
|
14054
|
+
? message.content
|
|
14055
|
+
: message.content
|
|
14056
|
+
.filter((block): block is TextContent => block.type === "text")
|
|
14057
|
+
.map(block => block.text)
|
|
14058
|
+
.join("")
|
|
14059
|
+
).trim();
|
|
14060
|
+
if (!text) continue;
|
|
14061
|
+
projected.push({ role: message.role, text });
|
|
14062
|
+
}
|
|
14063
|
+
return projected;
|
|
14064
|
+
}
|
|
14065
|
+
|
|
14066
|
+
#buildBtwAssistantMessage(text: string): AssistantMessage {
|
|
14067
|
+
return {
|
|
14068
|
+
role: "assistant",
|
|
14069
|
+
content: [{ type: "text", text }],
|
|
14070
|
+
api: "btw",
|
|
14071
|
+
provider: "btw",
|
|
14072
|
+
model: "btw",
|
|
14073
|
+
usage: {
|
|
14074
|
+
input: 0,
|
|
14075
|
+
output: 0,
|
|
14076
|
+
cacheRead: 0,
|
|
14077
|
+
cacheWrite: 0,
|
|
14078
|
+
totalTokens: 0,
|
|
14079
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
14080
|
+
},
|
|
14081
|
+
stopReason: "stop",
|
|
14082
|
+
timestamp: 0,
|
|
14083
|
+
};
|
|
14084
|
+
}
|
|
13672
14085
|
/**
|
|
13673
|
-
* Run a single ephemeral side-channel turn
|
|
13674
|
-
*
|
|
13675
|
-
*
|
|
13676
|
-
*
|
|
14086
|
+
* Run a single ephemeral side-channel turn without modifying session history.
|
|
14087
|
+
* Background turns retain IRC/session behavior. `/btw` turns require a
|
|
14088
|
+
* pre-frozen, visible-text-only scope and bypass extension context transforms,
|
|
14089
|
+
* provider observability hooks, and session persistence surfaces.
|
|
13677
14090
|
*/
|
|
13678
14091
|
async runEphemeralTurn(args: EphemeralTurnArgs): Promise<EphemeralTurnResult> {
|
|
13679
14092
|
args.signal?.throwIfAborted();
|
|
13680
|
-
|
|
13681
|
-
|
|
13682
|
-
|
|
13683
|
-
const
|
|
13684
|
-
? this.#buildBtwEphemeralSnapshot(
|
|
13685
|
-
cloneJsonValueForForkSeed(this.buildDisplaySessionContext().messages),
|
|
13686
|
-
args.promptText,
|
|
13687
|
-
)
|
|
13688
|
-
: undefined;
|
|
13689
|
-
const model = this.model;
|
|
13690
|
-
const systemPrompt = isBtw ? [...this.systemPrompt] : undefined;
|
|
13691
|
-
const thinkingLevel = this.thinkingLevel;
|
|
13692
|
-
const hideThinkingSummary = this.agent.hideThinkingSummary;
|
|
13693
|
-
const serviceTier = this.serviceTier;
|
|
13694
|
-
const providerAffinitySessionId = this.agent.providerSessionId ?? this.agent.sessionId ?? this.sessionId;
|
|
13695
|
-
const sideSessionId = isBtw ? `${providerAffinitySessionId}:btw:${crypto.randomUUID()}` : undefined;
|
|
13696
|
-
const backgroundArgs = isBtw ? undefined : args;
|
|
13697
|
-
const callerOwnsRosterClaim = backgroundArgs?.ircRosterClaim !== undefined;
|
|
13698
|
-
const rosterClaim = isBtw
|
|
13699
|
-
? null
|
|
13700
|
-
: callerOwnsRosterClaim
|
|
13701
|
-
? backgroundArgs?.ircRosterClaim
|
|
13702
|
-
: this.#claimIrcRosterCandidate();
|
|
14093
|
+
if (args.purpose === "btw") return await this.#runBtwTurn(args);
|
|
14094
|
+
|
|
14095
|
+
const callerOwnsRosterClaim = args.ircRosterClaim !== undefined;
|
|
14096
|
+
const rosterClaim = callerOwnsRosterClaim ? args.ircRosterClaim : this.#claimIrcRosterCandidate();
|
|
13703
14097
|
const rosterClaimIsCurrent = () =>
|
|
13704
14098
|
!rosterClaim || this.#isCurrentIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
|
|
13705
|
-
const prependMessagesValid = () => rosterClaimIsCurrent() &&
|
|
14099
|
+
const prependMessagesValid = () => rosterClaimIsCurrent() && args.prependMessagesValid?.() !== false;
|
|
13706
14100
|
const rosterMessage = !callerOwnsRosterClaim && rosterClaimIsCurrent() ? rosterClaim?.message : undefined;
|
|
13707
14101
|
try {
|
|
14102
|
+
const model = this.model;
|
|
13708
14103
|
if (!model) throw new Error("No active model on session");
|
|
13709
|
-
const
|
|
13710
|
-
const apiKey = await awaitEphemeralAbort(
|
|
13711
|
-
this.#modelRegistry.getApiKey(model, credentialSessionId),
|
|
13712
|
-
args.signal,
|
|
13713
|
-
);
|
|
14104
|
+
const apiKey = await awaitEphemeralAbort(this.#modelRegistry.getApiKey(model, this.sessionId), args.signal);
|
|
13714
14105
|
if (!apiKey) throw new Error(`No API key for ${model.provider}/${model.id}`);
|
|
13715
|
-
|
|
13716
14106
|
const prependMessages = prependMessagesValid()
|
|
13717
|
-
? [...(rosterMessage ? [rosterMessage] : []), ...(
|
|
14107
|
+
? [...(rosterMessage ? [rosterMessage] : []), ...(args.prependMessages ?? [])]
|
|
13718
14108
|
: undefined;
|
|
13719
|
-
let snapshot =
|
|
14109
|
+
let snapshot = this.#buildEphemeralSnapshot(args.promptText, prependMessages);
|
|
13720
14110
|
let llmMessages = await awaitEphemeralAbort(this.convertMessagesToLlm(snapshot, args.signal), args.signal);
|
|
13721
|
-
if (
|
|
14111
|
+
if (prependMessages && !prependMessagesValid()) {
|
|
13722
14112
|
snapshot = this.#buildEphemeralSnapshot(args.promptText);
|
|
13723
14113
|
llmMessages = await awaitEphemeralAbort(this.convertMessagesToLlm(snapshot, args.signal), args.signal);
|
|
13724
14114
|
}
|
|
13725
|
-
const context: Context = {
|
|
13726
|
-
|
|
13727
|
-
|
|
13728
|
-
|
|
13729
|
-
|
|
13730
|
-
|
|
14115
|
+
const context: Context = { systemPrompt: this.systemPrompt, messages: llmMessages, tools: [] };
|
|
14116
|
+
const ephemeralSessionId = crypto.randomUUID();
|
|
14117
|
+
const options = this.prepareSimpleStreamOptions(
|
|
14118
|
+
{
|
|
14119
|
+
apiKey,
|
|
14120
|
+
sessionId: ephemeralSessionId,
|
|
14121
|
+
metadata: buildSessionMetadata(
|
|
14122
|
+
ephemeralSessionId,
|
|
14123
|
+
model.provider,
|
|
14124
|
+
this.#modelRegistry.authStorage,
|
|
14125
|
+
this.sessionId,
|
|
14126
|
+
),
|
|
14127
|
+
reasoning: toReasoningEffort(this.thinkingLevel),
|
|
14128
|
+
hideThinkingSummary: this.agent.hideThinkingSummary,
|
|
14129
|
+
serviceTier: this.serviceTier,
|
|
14130
|
+
signal: args.signal,
|
|
14131
|
+
toolChoice: "none",
|
|
14132
|
+
},
|
|
14133
|
+
model.provider,
|
|
14134
|
+
);
|
|
14135
|
+
args.signal?.throwIfAborted();
|
|
13731
14136
|
let replyText = "";
|
|
13732
14137
|
let assistantMessage: AssistantMessage | undefined;
|
|
13733
|
-
|
|
13734
|
-
|
|
13735
|
-
|
|
13736
|
-
|
|
13737
|
-
|
|
13738
|
-
|
|
13739
|
-
|
|
13740
|
-
)
|
|
13741
|
-
|
|
13742
|
-
let establishmentTimer: NodeJS.Timeout | undefined;
|
|
13743
|
-
try {
|
|
13744
|
-
requestSignal.throwIfAborted();
|
|
13745
|
-
const options: SimpleStreamOptions = {
|
|
13746
|
-
apiKey,
|
|
13747
|
-
sessionId: sideSessionId!,
|
|
13748
|
-
metadata: buildSessionMetadata(
|
|
13749
|
-
sideSessionId!,
|
|
13750
|
-
model.provider,
|
|
13751
|
-
this.#modelRegistry.authStorage,
|
|
13752
|
-
providerAffinitySessionId,
|
|
13753
|
-
),
|
|
13754
|
-
reasoning: toReasoningEffort(thinkingLevel),
|
|
13755
|
-
hideThinkingSummary,
|
|
13756
|
-
serviceTier,
|
|
13757
|
-
onPayload: this.#onPayload,
|
|
13758
|
-
signal: requestSignal,
|
|
13759
|
-
toolChoice: "none",
|
|
13760
|
-
requestMaxRetries: 0,
|
|
13761
|
-
streamMaxRetries: 0,
|
|
13762
|
-
streamFirstEventTimeoutMs: 0,
|
|
13763
|
-
};
|
|
13764
|
-
establishmentTimer = setTimeout(
|
|
13765
|
-
() => establishmentAbort.abort(timeoutError),
|
|
13766
|
-
BTW_ESTABLISHMENT_TIMEOUT_MS,
|
|
13767
|
-
);
|
|
13768
|
-
establishmentTimer.unref?.();
|
|
13769
|
-
const stream = streamSimple(model, context, options);
|
|
13770
|
-
let receivedProviderEvent = false;
|
|
13771
|
-
for await (const event of stream) {
|
|
13772
|
-
if (!receivedProviderEvent && event.type !== "start") {
|
|
13773
|
-
receivedProviderEvent = true;
|
|
13774
|
-
clearTimeout(establishmentTimer);
|
|
13775
|
-
establishmentTimer = undefined;
|
|
13776
|
-
}
|
|
13777
|
-
if (event.type === "text_delta") {
|
|
13778
|
-
replyText += event.delta;
|
|
13779
|
-
args.onTextDelta?.(event.delta);
|
|
13780
|
-
} else if (event.type === "done") {
|
|
13781
|
-
assistantMessage = event.message;
|
|
13782
|
-
break;
|
|
13783
|
-
} else if (event.type === "error") {
|
|
13784
|
-
throw new Error(event.error.errorMessage || "Ephemeral turn failed");
|
|
13785
|
-
}
|
|
13786
|
-
}
|
|
13787
|
-
requestSignal.throwIfAborted();
|
|
13788
|
-
} catch (error) {
|
|
13789
|
-
if (establishmentAbort.signal.aborted && !args.signal?.aborted) throw timeoutError;
|
|
13790
|
-
throw error;
|
|
13791
|
-
} finally {
|
|
13792
|
-
if (establishmentTimer) clearTimeout(establishmentTimer);
|
|
13793
|
-
}
|
|
13794
|
-
} else {
|
|
13795
|
-
const ephemeralSessionId = crypto.randomUUID();
|
|
13796
|
-
const options = this.prepareSimpleStreamOptions(
|
|
13797
|
-
{
|
|
13798
|
-
apiKey,
|
|
13799
|
-
sessionId: ephemeralSessionId,
|
|
13800
|
-
metadata: buildSessionMetadata(
|
|
13801
|
-
ephemeralSessionId,
|
|
13802
|
-
model.provider,
|
|
13803
|
-
this.#modelRegistry.authStorage,
|
|
13804
|
-
this.sessionId,
|
|
13805
|
-
),
|
|
13806
|
-
reasoning: toReasoningEffort(thinkingLevel),
|
|
13807
|
-
hideThinkingSummary,
|
|
13808
|
-
serviceTier,
|
|
13809
|
-
signal: args.signal,
|
|
13810
|
-
toolChoice: "none",
|
|
13811
|
-
},
|
|
13812
|
-
model.provider,
|
|
13813
|
-
);
|
|
13814
|
-
args.signal?.throwIfAborted();
|
|
13815
|
-
const stream = streamSimple(model, context, options);
|
|
13816
|
-
for await (const event of stream) {
|
|
13817
|
-
if (event.type === "text_delta") {
|
|
13818
|
-
replyText += event.delta;
|
|
13819
|
-
args.onTextDelta?.(event.delta);
|
|
13820
|
-
} else if (event.type === "done") {
|
|
13821
|
-
assistantMessage = event.message;
|
|
13822
|
-
break;
|
|
13823
|
-
} else if (event.type === "error") {
|
|
13824
|
-
throw new Error(event.error.errorMessage || "Ephemeral turn failed");
|
|
13825
|
-
}
|
|
14138
|
+
for await (const event of streamSimple(model, context, options)) {
|
|
14139
|
+
if (event.type === "text_delta") {
|
|
14140
|
+
replyText += event.delta;
|
|
14141
|
+
args.onTextDelta?.(event.delta);
|
|
14142
|
+
} else if (event.type === "done") {
|
|
14143
|
+
assistantMessage = event.message;
|
|
14144
|
+
break;
|
|
14145
|
+
} else if (event.type === "error") {
|
|
14146
|
+
throw new Error(event.error.errorMessage || "Ephemeral turn failed");
|
|
13826
14147
|
}
|
|
13827
14148
|
}
|
|
13828
|
-
|
|
13829
14149
|
if (!assistantMessage) throw new Error("Ephemeral turn ended without a final message");
|
|
13830
14150
|
args.signal?.throwIfAborted();
|
|
13831
14151
|
if (
|
|
13832
|
-
!isBtw &&
|
|
13833
14152
|
!callerOwnsRosterClaim &&
|
|
13834
14153
|
rosterClaim &&
|
|
13835
14154
|
assistantMessage.stopReason !== "error" &&
|
|
@@ -13837,21 +14156,117 @@ export class AgentSession {
|
|
|
13837
14156
|
) {
|
|
13838
14157
|
this.#commitIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
|
|
13839
14158
|
}
|
|
13840
|
-
args.signal?.throwIfAborted();
|
|
13841
14159
|
return { replyText: replyText.trim(), assistantMessage };
|
|
13842
14160
|
} finally {
|
|
13843
|
-
if (!
|
|
13844
|
-
|
|
14161
|
+
if (!callerOwnsRosterClaim && rosterClaim) this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
|
|
14162
|
+
}
|
|
14163
|
+
}
|
|
14164
|
+
|
|
14165
|
+
async #runBtwTurn(args: Extract<EphemeralTurnArgs, { purpose: "btw" }>): Promise<EphemeralTurnResult> {
|
|
14166
|
+
const model = args.turn.scope?.model;
|
|
14167
|
+
const credentialSessionId = args.turn.scope?.credentialSessionId;
|
|
14168
|
+
if (!model || !credentialSessionId) throw new Error("The /btw conversation scope was scrubbed.");
|
|
14169
|
+
if (utf8ByteLength(args.turn.question) > BTW_MAX_QUESTION_UTF8_BYTES) {
|
|
14170
|
+
throw new RangeError(`/btw questions are limited to ${BTW_MAX_QUESTION_UTF8_BYTES} UTF-8 bytes.`);
|
|
14171
|
+
}
|
|
14172
|
+
const apiKey = await awaitEphemeralAbort(this.#modelRegistry.getApiKey(model, credentialSessionId), args.signal);
|
|
14173
|
+
if (!apiKey) throw new Error(`No API key for ${model.provider}/${model.id}`);
|
|
14174
|
+
const scope = args.turn.scope;
|
|
14175
|
+
if (!scope) throw new Error("The /btw conversation scope was scrubbed.");
|
|
14176
|
+
const messages = scope.messages.map(message => ({
|
|
14177
|
+
role: message.role,
|
|
14178
|
+
content: [{ type: "text" as const, text: message.text }],
|
|
14179
|
+
})) as Message[];
|
|
14180
|
+
for (const exchange of boundBtwExchanges(args.contextExchanges ?? [])) {
|
|
14181
|
+
messages.push({
|
|
14182
|
+
role: "user",
|
|
14183
|
+
content: [{ type: "text", text: exchange.question }],
|
|
14184
|
+
} as Message);
|
|
14185
|
+
messages.push({
|
|
14186
|
+
role: "assistant",
|
|
14187
|
+
content: [{ type: "text", text: exchange.answer }],
|
|
14188
|
+
} as Message);
|
|
14189
|
+
}
|
|
14190
|
+
messages.push({
|
|
14191
|
+
role: "user",
|
|
14192
|
+
content: [{ type: "text", text: args.turn.question }],
|
|
14193
|
+
} as Message);
|
|
14194
|
+
const context: Context = { systemPrompt: scope.systemPrompt, messages, tools: [] };
|
|
14195
|
+
const timeoutAbort = new AbortController();
|
|
14196
|
+
const requestSignal = args.signal ? AbortSignal.any([args.signal, timeoutAbort.signal]) : timeoutAbort.signal;
|
|
14197
|
+
const options: SimpleStreamOptions = {
|
|
14198
|
+
apiKey,
|
|
14199
|
+
sessionId: scope.sideSessionId,
|
|
14200
|
+
reasoning: toReasoningEffort(scope.thinkingLevel),
|
|
14201
|
+
hideThinkingSummary: scope.hideThinkingSummary,
|
|
14202
|
+
serviceTier: scope.serviceTier,
|
|
14203
|
+
signal: requestSignal,
|
|
14204
|
+
toolChoice: "none",
|
|
14205
|
+
requestMaxRetries: 0,
|
|
14206
|
+
streamMaxRetries: 0,
|
|
14207
|
+
streamFirstEventTimeoutMs: 0,
|
|
14208
|
+
};
|
|
14209
|
+
const iterator = streamSimple(scope.model, context, options)[Symbol.asyncIterator]();
|
|
14210
|
+
let replyText = "";
|
|
14211
|
+
let completed = false;
|
|
14212
|
+
let active = true;
|
|
14213
|
+
let onTextDelta = args.onTextDelta;
|
|
14214
|
+
let idleTimer: NodeJS.Timeout | undefined;
|
|
14215
|
+
const timeout = (message: string) => {
|
|
14216
|
+
const error = new Error(message);
|
|
14217
|
+
error.name = "TimeoutError";
|
|
14218
|
+
timeoutAbort.abort(error);
|
|
14219
|
+
};
|
|
14220
|
+
const resetIdleTimer = () => {
|
|
14221
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
14222
|
+
idleTimer = setTimeout(
|
|
14223
|
+
() => timeout(`/btw provider was idle for ${BTW_STREAM_IDLE_TIMEOUT_MS / 1000} seconds`),
|
|
14224
|
+
BTW_STREAM_IDLE_TIMEOUT_MS,
|
|
14225
|
+
);
|
|
14226
|
+
idleTimer.unref?.();
|
|
14227
|
+
};
|
|
14228
|
+
const totalTimer = setTimeout(
|
|
14229
|
+
() => timeout(`/btw provider exceeded ${BTW_STREAM_TOTAL_TIMEOUT_MS / 1000} seconds`),
|
|
14230
|
+
BTW_STREAM_TOTAL_TIMEOUT_MS,
|
|
14231
|
+
);
|
|
14232
|
+
totalTimer.unref?.();
|
|
14233
|
+
resetIdleTimer();
|
|
14234
|
+
const consume = async () => {
|
|
14235
|
+
while (active) {
|
|
14236
|
+
const result = await iterator.next();
|
|
14237
|
+
if (result.done || !active) break;
|
|
14238
|
+
resetIdleTimer();
|
|
14239
|
+
const event = result.value;
|
|
14240
|
+
if (event.type === "text_delta") {
|
|
14241
|
+
const bounded = truncateUtf8(replyText + event.delta, BTW_MAX_ANSWER_UTF8_BYTES);
|
|
14242
|
+
const delta = bounded.slice(replyText.length);
|
|
14243
|
+
replyText = bounded;
|
|
14244
|
+
if (delta) onTextDelta?.(delta);
|
|
14245
|
+
} else if (event.type === "done") {
|
|
14246
|
+
completed = true;
|
|
14247
|
+
break;
|
|
14248
|
+
} else if (event.type === "error") {
|
|
14249
|
+
throw new Error(event.error.errorMessage || "Ephemeral turn failed");
|
|
14250
|
+
}
|
|
13845
14251
|
}
|
|
14252
|
+
};
|
|
14253
|
+
try {
|
|
14254
|
+
await awaitEphemeralAbort(consume(), requestSignal);
|
|
14255
|
+
requestSignal.throwIfAborted();
|
|
14256
|
+
if (!completed) throw new Error("Ephemeral turn ended without a final message");
|
|
14257
|
+
const finalText = replyText.trim();
|
|
14258
|
+
return { replyText: finalText, assistantMessage: this.#buildBtwAssistantMessage(finalText) };
|
|
14259
|
+
} finally {
|
|
14260
|
+
active = false;
|
|
14261
|
+
onTextDelta = undefined;
|
|
14262
|
+
replyText = "";
|
|
14263
|
+
context.messages = [];
|
|
14264
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
14265
|
+
clearTimeout(totalTimer);
|
|
14266
|
+
void iterator.return?.().catch(() => undefined);
|
|
13846
14267
|
}
|
|
13847
14268
|
}
|
|
13848
14269
|
|
|
13849
|
-
/** Build a `/btw` snapshot from an already-cloned committed context. */
|
|
13850
|
-
#buildBtwEphemeralSnapshot(messages: AgentMessage[], promptText: string): AgentMessage[] {
|
|
13851
|
-
messages.push(this.#buildEphemeralPromptMessage(promptText));
|
|
13852
|
-
return messages;
|
|
13853
|
-
}
|
|
13854
|
-
|
|
13855
14270
|
/** Build a background snapshot with in-flight assistant and optional context. */
|
|
13856
14271
|
#buildEphemeralSnapshot(promptText: string, prependMessages?: AgentMessage[]): AgentMessage[] {
|
|
13857
14272
|
const messages = [...this.messages];
|
|
@@ -13961,201 +14376,212 @@ export class AgentSession {
|
|
|
13961
14376
|
* @returns true if switch completed, false if cancelled by hook
|
|
13962
14377
|
*/
|
|
13963
14378
|
async switchSession(sessionPath: string): Promise<boolean> {
|
|
13964
|
-
|
|
13965
|
-
|
|
13966
|
-
|
|
13967
|
-
|
|
13968
|
-
|
|
13969
|
-
|
|
13970
|
-
|
|
13971
|
-
|
|
13972
|
-
|
|
13973
|
-
|
|
13974
|
-
|
|
14379
|
+
this.#beginSessionTransition("switch-session");
|
|
14380
|
+
try {
|
|
14381
|
+
const previousSessionFile = this.sessionManager.getSessionFile();
|
|
14382
|
+
const switchingToDifferentSession = previousSessionFile
|
|
14383
|
+
? path.resolve(previousSessionFile) !== path.resolve(sessionPath)
|
|
14384
|
+
: true;
|
|
14385
|
+
// Emit session_before_switch event (can be cancelled)
|
|
14386
|
+
if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
|
|
14387
|
+
const result = (await this.#extensionRunner.emit({
|
|
14388
|
+
type: "session_before_switch",
|
|
14389
|
+
reason: "resume",
|
|
14390
|
+
targetSessionFile: sessionPath,
|
|
14391
|
+
})) as SessionBeforeSwitchResult | undefined;
|
|
13975
14392
|
|
|
13976
|
-
|
|
13977
|
-
|
|
14393
|
+
if (result?.cancel) {
|
|
14394
|
+
return false;
|
|
14395
|
+
}
|
|
13978
14396
|
}
|
|
13979
|
-
}
|
|
13980
14397
|
|
|
13981
|
-
|
|
13982
|
-
|
|
13983
|
-
|
|
13984
|
-
// Flush pending writes before switching so restore snapshots reflect committed state.
|
|
13985
|
-
await this.sessionManager.flush();
|
|
13986
|
-
const previousSessionState = this.sessionManager.captureState();
|
|
13987
|
-
const previousSessionContext = this.buildDisplaySessionContext();
|
|
13988
|
-
// switchSession replaces these arrays wholesale during load/rollback, so retaining
|
|
13989
|
-
// the existing message objects is sufficient and avoids structured-clone failures for
|
|
13990
|
-
// extension/custom metadata that is valid to persist but not cloneable.
|
|
13991
|
-
const previousAgentMessages = [...this.agent.state.messages];
|
|
13992
|
-
const previousSteeringMessages = [...this.#steeringMessages];
|
|
13993
|
-
const previousFollowUpMessages = [...this.#followUpMessages];
|
|
13994
|
-
const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
|
|
13995
|
-
const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
|
|
13996
|
-
const previousModel = this.model;
|
|
13997
|
-
const previousThinkingLevel = this.#thinkingLevel;
|
|
13998
|
-
const previousServiceTier = this.agent.serviceTier;
|
|
13999
|
-
const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
|
|
14000
|
-
const previousTools = [...this.agent.state.tools];
|
|
14001
|
-
const previousBaseSystemPrompt = this.#baseSystemPrompt;
|
|
14002
|
-
const previousSystemPrompt = this.agent.state.systemPrompt;
|
|
14003
|
-
const previousAgentSteeringQueue = this.agent.snapshotSteering();
|
|
14004
|
-
const previousAgentFollowUpQueue = this.agent.snapshotFollowUp();
|
|
14398
|
+
this.#disconnectFromAgent();
|
|
14399
|
+
await this.abort();
|
|
14005
14400
|
|
|
14006
|
-
|
|
14007
|
-
|
|
14008
|
-
|
|
14009
|
-
|
|
14010
|
-
|
|
14011
|
-
|
|
14012
|
-
|
|
14013
|
-
|
|
14401
|
+
// Flush pending writes before switching so restore snapshots reflect committed state.
|
|
14402
|
+
await this.sessionManager.flush();
|
|
14403
|
+
const previousSessionState = this.sessionManager.captureState();
|
|
14404
|
+
const previousSessionContext = this.buildDisplaySessionContext();
|
|
14405
|
+
// switchSession replaces these arrays wholesale during load/rollback, so retaining
|
|
14406
|
+
// the existing message objects is sufficient and avoids structured-clone failures for
|
|
14407
|
+
// extension/custom metadata that is valid to persist but not cloneable.
|
|
14408
|
+
const previousAgentMessages = [...this.agent.state.messages];
|
|
14409
|
+
const previousSteeringMessages = [...this.#steeringMessages];
|
|
14410
|
+
const previousFollowUpMessages = [...this.#followUpMessages];
|
|
14411
|
+
const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
|
|
14412
|
+
const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
|
|
14413
|
+
const previousModel = this.model;
|
|
14414
|
+
const previousThinkingLevel = this.#thinkingLevel;
|
|
14415
|
+
const previousServiceTier = this.agent.serviceTier;
|
|
14416
|
+
const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
|
|
14417
|
+
const previousTools = [...this.agent.state.tools];
|
|
14418
|
+
const previousBaseSystemPrompt = this.#baseSystemPrompt;
|
|
14419
|
+
const previousSystemPrompt = this.agent.state.systemPrompt;
|
|
14420
|
+
const previousAgentSteeringQueue = this.agent.snapshotSteering();
|
|
14421
|
+
const previousAgentFollowUpQueue = this.agent.snapshotFollowUp();
|
|
14014
14422
|
|
|
14015
|
-
|
|
14016
|
-
|
|
14017
|
-
this.#
|
|
14018
|
-
this.#
|
|
14423
|
+
this.#steeringMessages = [];
|
|
14424
|
+
this.#followUpMessages = [];
|
|
14425
|
+
this.#pendingNextTurnMessages = [];
|
|
14426
|
+
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
14427
|
+
const suspendedWorkflowGateEmitter = switchingToDifferentSession
|
|
14428
|
+
? this.#suspendWorkflowGateEmitter(previousSessionState.sessionId)
|
|
14429
|
+
: undefined;
|
|
14430
|
+
let unavailableDefaultChainMessage: string | undefined;
|
|
14019
14431
|
|
|
14020
|
-
|
|
14021
|
-
|
|
14022
|
-
|
|
14023
|
-
this.#
|
|
14024
|
-
await this.#restoreMCPSelectionsForSessionContext(sessionContext);
|
|
14432
|
+
try {
|
|
14433
|
+
await this.sessionManager.setSessionFile(sessionPath);
|
|
14434
|
+
this.#syncAgentSessionId();
|
|
14435
|
+
this.#rekeyHindsightMemoryForCurrentSessionId();
|
|
14025
14436
|
|
|
14026
|
-
|
|
14027
|
-
|
|
14028
|
-
|
|
14437
|
+
const sessionContext = this.buildDisplaySessionContext();
|
|
14438
|
+
const didReloadConversationChange =
|
|
14439
|
+
!switchingToDifferentSession &&
|
|
14440
|
+
this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
|
|
14441
|
+
await this.#restoreMCPSelectionsForSessionContext(sessionContext);
|
|
14029
14442
|
|
|
14030
|
-
|
|
14031
|
-
|
|
14032
|
-
|
|
14033
|
-
if (switchingToDifferentSession || didReloadConversationChange) {
|
|
14034
|
-
this.#closeAllProviderSessions(switchingToDifferentSession ? "session switch" : "session reload");
|
|
14035
|
-
this.#rebindProviderSessionState(new Map());
|
|
14036
|
-
}
|
|
14443
|
+
// The target session is loaded and MCP selections are restored: discard
|
|
14444
|
+
// pre-switch delivery queues before completing the restored agent state.
|
|
14445
|
+
this.agent.clearAllQueues();
|
|
14037
14446
|
|
|
14038
|
-
|
|
14039
|
-
|
|
14040
|
-
|
|
14041
|
-
|
|
14042
|
-
|
|
14043
|
-
|
|
14044
|
-
|
|
14045
|
-
this.#modelRegistry,
|
|
14046
|
-
this.settings,
|
|
14047
|
-
this.sessionId,
|
|
14048
|
-
{ managedFallback: true },
|
|
14049
|
-
);
|
|
14050
|
-
const controller = this.#defaultFallbackChain();
|
|
14051
|
-
this.seedDefaultFallbackResolution(resolution.activeIndex, resolution.skips);
|
|
14052
|
-
if (!resolution.model) {
|
|
14053
|
-
unavailableDefaultChainMessage = this.#fallbackExhaustionError(controller);
|
|
14054
|
-
throw new Error(unavailableDefaultChainMessage);
|
|
14055
|
-
}
|
|
14056
|
-
if (!this.model || !modelsAreEqual(this.model, resolution.model)) {
|
|
14057
|
-
this.#setModelAuthoritatively(resolution.model, "restore");
|
|
14058
|
-
}
|
|
14059
|
-
if (resolution.explicitThinkingLevel && resolution.thinkingLevel !== undefined) {
|
|
14060
|
-
this.setThinkingLevel(resolution.thinkingLevel);
|
|
14061
|
-
}
|
|
14062
|
-
}
|
|
14063
|
-
|
|
14064
|
-
const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
|
|
14065
|
-
const hasServiceTierEntry = this.sessionManager
|
|
14066
|
-
.getBranch()
|
|
14067
|
-
.some(entry => entry.type === "service_tier_change");
|
|
14068
|
-
const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
|
|
14069
|
-
const configuredServiceTier = this.settings.get("serviceTier");
|
|
14070
|
-
const persistedThinkingLevel = hasThinkingEntry
|
|
14071
|
-
? (sessionContext.thinkingLevel as ThinkingLevel | undefined)
|
|
14072
|
-
: defaultThinkingLevel;
|
|
14073
|
-
const nextThinkingLevel = resolveThinkingLevelForModel(
|
|
14074
|
-
this.model,
|
|
14075
|
-
persistedThinkingLevel === ThinkingLevel.Inherit
|
|
14076
|
-
? this.#getInheritedThinkingLevel()
|
|
14077
|
-
: persistedThinkingLevel,
|
|
14078
|
-
);
|
|
14079
|
-
this.#thinkingLevel = nextThinkingLevel;
|
|
14080
|
-
this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
|
|
14081
|
-
this.agent.serviceTier = hasServiceTierEntry
|
|
14082
|
-
? sessionContext.serviceTier
|
|
14083
|
-
: configuredServiceTier === "none"
|
|
14084
|
-
? undefined
|
|
14085
|
-
: configuredServiceTier;
|
|
14086
|
-
// Establish the successor's durable session identity only after every
|
|
14087
|
-
// restored state facet is live. Identity-bound extension hooks run below.
|
|
14088
|
-
await this.sessionManager.ensureOnDisk();
|
|
14447
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
14448
|
+
this.#resetInjectedContextSignatures();
|
|
14449
|
+
this.#syncTodoPhasesFromBranch();
|
|
14450
|
+
if (switchingToDifferentSession || didReloadConversationChange) {
|
|
14451
|
+
this.#closeAllProviderSessions(switchingToDifferentSession ? "session switch" : "session reload");
|
|
14452
|
+
this.#rebindProviderSessionState(new Map());
|
|
14453
|
+
}
|
|
14089
14454
|
|
|
14090
|
-
|
|
14091
|
-
|
|
14092
|
-
|
|
14093
|
-
|
|
14455
|
+
const configuredDefaultChain = sessionContext.configuredModelChains.default?.entries;
|
|
14456
|
+
const defaultEntries =
|
|
14457
|
+
configuredDefaultChain ?? (sessionContext.models.default ? [sessionContext.models.default] : []);
|
|
14458
|
+
this.#defaultFallbackController = undefined;
|
|
14459
|
+
if (defaultEntries.length > 0) {
|
|
14460
|
+
const resolution = await resolveModelChainWithAuth(
|
|
14461
|
+
defaultEntries,
|
|
14462
|
+
this.#modelRegistry,
|
|
14463
|
+
this.settings,
|
|
14464
|
+
this.sessionId,
|
|
14465
|
+
{ managedFallback: true },
|
|
14466
|
+
);
|
|
14467
|
+
const controller = this.#defaultFallbackChain();
|
|
14468
|
+
this.seedDefaultFallbackResolution(resolution.activeIndex, resolution.skips);
|
|
14469
|
+
if (!resolution.model) {
|
|
14470
|
+
unavailableDefaultChainMessage = this.#fallbackExhaustionError(controller);
|
|
14471
|
+
throw new Error(unavailableDefaultChainMessage);
|
|
14472
|
+
}
|
|
14473
|
+
if (!this.model || !modelsAreEqual(this.model, resolution.model)) {
|
|
14474
|
+
this.#setModelAuthoritatively(resolution.model, "restore");
|
|
14475
|
+
}
|
|
14476
|
+
if (resolution.explicitThinkingLevel && resolution.thinkingLevel !== undefined) {
|
|
14477
|
+
this.setThinkingLevel(resolution.thinkingLevel);
|
|
14478
|
+
}
|
|
14479
|
+
}
|
|
14094
14480
|
|
|
14095
|
-
|
|
14096
|
-
|
|
14097
|
-
|
|
14098
|
-
|
|
14099
|
-
|
|
14100
|
-
|
|
14101
|
-
|
|
14102
|
-
|
|
14103
|
-
|
|
14104
|
-
|
|
14105
|
-
|
|
14106
|
-
|
|
14107
|
-
|
|
14108
|
-
|
|
14109
|
-
|
|
14110
|
-
|
|
14111
|
-
|
|
14112
|
-
|
|
14113
|
-
|
|
14114
|
-
|
|
14115
|
-
|
|
14116
|
-
|
|
14117
|
-
|
|
14118
|
-
|
|
14119
|
-
|
|
14120
|
-
|
|
14121
|
-
|
|
14122
|
-
|
|
14123
|
-
|
|
14124
|
-
|
|
14125
|
-
|
|
14126
|
-
|
|
14127
|
-
|
|
14128
|
-
this.#
|
|
14129
|
-
|
|
14481
|
+
const hasThinkingEntry = this.sessionManager
|
|
14482
|
+
.getBranch()
|
|
14483
|
+
.some(entry => entry.type === "thinking_level_change");
|
|
14484
|
+
const hasServiceTierEntry = this.sessionManager
|
|
14485
|
+
.getBranch()
|
|
14486
|
+
.some(entry => entry.type === "service_tier_change");
|
|
14487
|
+
const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
|
|
14488
|
+
const configuredServiceTier = this.settings.get("serviceTier");
|
|
14489
|
+
const persistedThinkingLevel = hasThinkingEntry
|
|
14490
|
+
? (sessionContext.thinkingLevel as ThinkingLevel | undefined)
|
|
14491
|
+
: defaultThinkingLevel;
|
|
14492
|
+
const nextThinkingLevel = resolveThinkingLevelForModel(
|
|
14493
|
+
this.model,
|
|
14494
|
+
persistedThinkingLevel === ThinkingLevel.Inherit
|
|
14495
|
+
? this.#getInheritedThinkingLevel()
|
|
14496
|
+
: persistedThinkingLevel,
|
|
14497
|
+
);
|
|
14498
|
+
this.#thinkingLevel = nextThinkingLevel;
|
|
14499
|
+
this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
|
|
14500
|
+
this.agent.serviceTier = hasServiceTierEntry
|
|
14501
|
+
? sessionContext.serviceTier
|
|
14502
|
+
: configuredServiceTier === "none"
|
|
14503
|
+
? undefined
|
|
14504
|
+
: configuredServiceTier;
|
|
14505
|
+
// Establish the successor's durable session identity only after every
|
|
14506
|
+
// restored state facet is live. Identity-bound extension hooks run below.
|
|
14507
|
+
await this.sessionManager.ensureOnDisk();
|
|
14508
|
+
|
|
14509
|
+
if (switchingToDifferentSession) {
|
|
14510
|
+
this.#resetHindsightConversationTrackingIfHindsight();
|
|
14511
|
+
this.#resetIrcRosterDeliveryState();
|
|
14512
|
+
}
|
|
14513
|
+
|
|
14514
|
+
this.#reconnectToAgent();
|
|
14515
|
+
// Fence predecessor continuations before session_switch starts SDK runtime
|
|
14516
|
+
// teardown. The previous runtime waits for those continuations to settle;
|
|
14517
|
+
// waiting to transfer authority until after hooks creates a circular wait.
|
|
14518
|
+
if (suspendedWorkflowGateEmitter)
|
|
14519
|
+
this.#bindWorkflowGateEmitter(previousSessionState.sessionId, suspendedWorkflowGateEmitter);
|
|
14520
|
+
// session_switch is the post-commit identity signal. SDK authority and
|
|
14521
|
+
// other identity-bound integrations must not observe the successor until
|
|
14522
|
+
// messages, model state, MCP selections, and the agent subscription are live.
|
|
14523
|
+
if (this.#extensionRunner) {
|
|
14524
|
+
await this.#extensionRunner.emit({
|
|
14525
|
+
type: "session_switch",
|
|
14526
|
+
reason: "resume",
|
|
14527
|
+
previousSessionFile,
|
|
14528
|
+
});
|
|
14529
|
+
}
|
|
14530
|
+
return true;
|
|
14531
|
+
} catch (error) {
|
|
14532
|
+
this.sessionManager.restoreState(previousSessionState);
|
|
14533
|
+
this.#defaultFallbackController = undefined;
|
|
14534
|
+
this.#syncAgentSessionId(previousSessionState.sessionId);
|
|
14535
|
+
this.#restoreWorkflowGateEmitter(suspendedWorkflowGateEmitter);
|
|
14536
|
+
this.#rekeyHindsightMemoryForCurrentSessionId();
|
|
14537
|
+
let restoreMcpError: unknown;
|
|
14538
|
+
try {
|
|
14539
|
+
await this.#restoreMCPSelectionsForSessionContext(previousSessionContext);
|
|
14540
|
+
} catch (mcpError) {
|
|
14541
|
+
restoreMcpError = mcpError;
|
|
14542
|
+
logger.warn("Failed to restore MCP selections after switch error", {
|
|
14543
|
+
previousSessionFile,
|
|
14544
|
+
targetSessionFile: sessionPath,
|
|
14545
|
+
error: String(mcpError),
|
|
14546
|
+
});
|
|
14547
|
+
this.#selectedMCPToolNames = new Set(previousSelectedMCPToolNames);
|
|
14548
|
+
this.#setGuardedAgentTools(previousTools);
|
|
14549
|
+
this.#baseSystemPrompt = previousBaseSystemPrompt;
|
|
14550
|
+
this.agent.setSystemPrompt(previousSystemPrompt);
|
|
14551
|
+
}
|
|
14130
14552
|
this.#baseSystemPrompt = previousBaseSystemPrompt;
|
|
14131
14553
|
this.agent.setSystemPrompt(previousSystemPrompt);
|
|
14554
|
+
this.agent.replaceMessages(previousAgentMessages);
|
|
14555
|
+
this.#steeringMessages = previousSteeringMessages;
|
|
14556
|
+
this.#followUpMessages = previousFollowUpMessages;
|
|
14557
|
+
this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
|
|
14558
|
+
this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
|
|
14559
|
+
this.agent.clearAllQueues();
|
|
14560
|
+
this.agent.restoreSteering(previousAgentSteeringQueue);
|
|
14561
|
+
this.agent.restoreFollowUp(previousAgentFollowUpQueue);
|
|
14562
|
+
if (previousModel) {
|
|
14563
|
+
this.agent.setModel(previousModel);
|
|
14564
|
+
}
|
|
14565
|
+
this.#thinkingLevel = previousThinkingLevel;
|
|
14566
|
+
this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
|
|
14567
|
+
this.agent.serviceTier = previousServiceTier;
|
|
14568
|
+
this.#syncTodoPhasesFromBranch();
|
|
14569
|
+
this.#reconnectToAgent();
|
|
14570
|
+
if (restoreMcpError) {
|
|
14571
|
+
throw restoreMcpError;
|
|
14572
|
+
}
|
|
14573
|
+
if (unavailableDefaultChainMessage) {
|
|
14574
|
+
this.emitNotice(
|
|
14575
|
+
"error",
|
|
14576
|
+
`Could not restore session model: ${unavailableDefaultChainMessage}`,
|
|
14577
|
+
"fallback",
|
|
14578
|
+
);
|
|
14579
|
+
return false;
|
|
14580
|
+
}
|
|
14581
|
+
throw error;
|
|
14132
14582
|
}
|
|
14133
|
-
|
|
14134
|
-
this
|
|
14135
|
-
this.agent.replaceMessages(previousAgentMessages);
|
|
14136
|
-
this.#steeringMessages = previousSteeringMessages;
|
|
14137
|
-
this.#followUpMessages = previousFollowUpMessages;
|
|
14138
|
-
this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
|
|
14139
|
-
this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
|
|
14140
|
-
this.agent.clearAllQueues();
|
|
14141
|
-
this.agent.restoreSteering(previousAgentSteeringQueue);
|
|
14142
|
-
this.agent.restoreFollowUp(previousAgentFollowUpQueue);
|
|
14143
|
-
if (previousModel) {
|
|
14144
|
-
this.agent.setModel(previousModel);
|
|
14145
|
-
}
|
|
14146
|
-
this.#thinkingLevel = previousThinkingLevel;
|
|
14147
|
-
this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
|
|
14148
|
-
this.agent.serviceTier = previousServiceTier;
|
|
14149
|
-
this.#syncTodoPhasesFromBranch();
|
|
14150
|
-
this.#reconnectToAgent();
|
|
14151
|
-
if (restoreMcpError) {
|
|
14152
|
-
throw restoreMcpError;
|
|
14153
|
-
}
|
|
14154
|
-
if (unavailableDefaultChainMessage) {
|
|
14155
|
-
this.emitNotice("error", `Could not restore session model: ${unavailableDefaultChainMessage}`, "fallback");
|
|
14156
|
-
return false;
|
|
14157
|
-
}
|
|
14158
|
-
throw error;
|
|
14583
|
+
} finally {
|
|
14584
|
+
this.#endSessionTransition();
|
|
14159
14585
|
}
|
|
14160
14586
|
}
|
|
14161
14587
|
|
|
@@ -14172,74 +14598,79 @@ export class AgentSession {
|
|
|
14172
14598
|
selectedText: string;
|
|
14173
14599
|
cancelled: boolean;
|
|
14174
14600
|
}> {
|
|
14175
|
-
|
|
14176
|
-
|
|
14177
|
-
|
|
14601
|
+
this.#beginSessionTransition("branch");
|
|
14602
|
+
try {
|
|
14603
|
+
const previousSessionFile = this.sessionFile;
|
|
14604
|
+
const previousWorkflowGateSessionId = this.sessionId;
|
|
14605
|
+
const selectedEntry = this.sessionManager.getEntryForFidelity(entryId);
|
|
14178
14606
|
|
|
14179
|
-
|
|
14180
|
-
|
|
14181
|
-
|
|
14607
|
+
if (selectedEntry?.type !== "message" || selectedEntry.message.role !== "user") {
|
|
14608
|
+
throw new Error("Invalid entry ID for branching");
|
|
14609
|
+
}
|
|
14182
14610
|
|
|
14183
|
-
|
|
14611
|
+
const selectedText = this.#extractUserMessageText(selectedEntry.message.content);
|
|
14184
14612
|
|
|
14185
|
-
|
|
14613
|
+
let skipConversationRestore = false;
|
|
14186
14614
|
|
|
14187
|
-
|
|
14188
|
-
|
|
14189
|
-
|
|
14190
|
-
|
|
14191
|
-
|
|
14192
|
-
|
|
14615
|
+
// Emit session_before_branch event (can be cancelled)
|
|
14616
|
+
if (this.#extensionRunner?.hasHandlers("session_before_branch")) {
|
|
14617
|
+
const result = (await this.#extensionRunner.emit({
|
|
14618
|
+
type: "session_before_branch",
|
|
14619
|
+
entryId,
|
|
14620
|
+
})) as SessionBeforeBranchResult | undefined;
|
|
14193
14621
|
|
|
14194
|
-
|
|
14195
|
-
|
|
14622
|
+
if (result?.cancel) {
|
|
14623
|
+
return { selectedText, cancelled: true };
|
|
14624
|
+
}
|
|
14625
|
+
skipConversationRestore = result?.skipConversationRestore ?? false;
|
|
14196
14626
|
}
|
|
14197
|
-
skipConversationRestore = result?.skipConversationRestore ?? false;
|
|
14198
|
-
}
|
|
14199
14627
|
|
|
14200
|
-
|
|
14201
|
-
|
|
14202
|
-
|
|
14628
|
+
// Clear pending messages (bound to old session state)
|
|
14629
|
+
this.#pendingNextTurnMessages = [];
|
|
14630
|
+
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
14203
14631
|
|
|
14204
|
-
|
|
14205
|
-
|
|
14206
|
-
|
|
14632
|
+
// Flush pending writes before branching
|
|
14633
|
+
await this.sessionManager.flush();
|
|
14634
|
+
this.#cancelOwnAsyncJobs();
|
|
14207
14635
|
|
|
14208
|
-
|
|
14209
|
-
|
|
14210
|
-
|
|
14211
|
-
|
|
14212
|
-
|
|
14213
|
-
|
|
14214
|
-
|
|
14215
|
-
|
|
14216
|
-
|
|
14217
|
-
|
|
14218
|
-
|
|
14219
|
-
|
|
14636
|
+
if (!selectedEntry.parentId) {
|
|
14637
|
+
await this.sessionManager.newSession({ parentSession: previousSessionFile });
|
|
14638
|
+
} else {
|
|
14639
|
+
this.sessionManager.createBranchedSession(selectedEntry.parentId);
|
|
14640
|
+
}
|
|
14641
|
+
this.#syncTodoPhasesFromBranch();
|
|
14642
|
+
this.#syncAgentSessionId();
|
|
14643
|
+
this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
|
|
14644
|
+
this.#rekeyHindsightMemoryForCurrentSessionId();
|
|
14645
|
+
this.#resetHindsightConversationTrackingIfHindsight();
|
|
14646
|
+
this.#closeAllProviderSessions("session branch");
|
|
14647
|
+
this.#rebindProviderSessionState(new Map());
|
|
14220
14648
|
|
|
14221
|
-
|
|
14222
|
-
|
|
14649
|
+
// Reload messages from entries (works for both file and in-memory mode)
|
|
14650
|
+
const sessionContext = this.buildDisplaySessionContext();
|
|
14223
14651
|
|
|
14224
|
-
|
|
14652
|
+
await this.#restoreMCPSelectionsForSessionContext(sessionContext);
|
|
14225
14653
|
|
|
14226
|
-
|
|
14227
|
-
|
|
14228
|
-
|
|
14229
|
-
|
|
14230
|
-
|
|
14654
|
+
if (!skipConversationRestore) {
|
|
14655
|
+
this.agent.replaceMessages(sessionContext.messages);
|
|
14656
|
+
this.#resetInjectedContextSignatures();
|
|
14657
|
+
this.#closeCodexProviderSessionsForHistoryRewrite();
|
|
14658
|
+
}
|
|
14231
14659
|
|
|
14232
|
-
|
|
14233
|
-
|
|
14234
|
-
|
|
14235
|
-
|
|
14236
|
-
|
|
14237
|
-
|
|
14238
|
-
|
|
14239
|
-
|
|
14240
|
-
|
|
14660
|
+
this.#resetIrcRosterDeliveryState();
|
|
14661
|
+
// session_branch is the post-commit identity signal. Publish it only after
|
|
14662
|
+
// the successor's messages and MCP selections are restored.
|
|
14663
|
+
if (this.#extensionRunner) {
|
|
14664
|
+
await this.#extensionRunner.emit({
|
|
14665
|
+
type: "session_branch",
|
|
14666
|
+
previousSessionFile,
|
|
14667
|
+
});
|
|
14668
|
+
}
|
|
14241
14669
|
|
|
14242
|
-
|
|
14670
|
+
return { selectedText, cancelled: false };
|
|
14671
|
+
} finally {
|
|
14672
|
+
this.#endSessionTransition();
|
|
14673
|
+
}
|
|
14243
14674
|
}
|
|
14244
14675
|
|
|
14245
14676
|
// =========================================================================
|
|
@@ -14266,165 +14697,178 @@ export class AgentSession {
|
|
|
14266
14697
|
/** Raw session context built during navigation — pass to renderInitialMessages to skip a second O(N) walk. */
|
|
14267
14698
|
sessionContext?: SessionContext;
|
|
14268
14699
|
}> {
|
|
14269
|
-
|
|
14700
|
+
// Serialize with every other session-identity transition via the shared
|
|
14701
|
+
// lease (handoff/compact/new/switch/branch/clear/fork). navigateTree rewrites
|
|
14702
|
+
// live history in place, so a concurrent transition would race the same state.
|
|
14703
|
+
this.#beginSessionTransition("navigate-tree");
|
|
14704
|
+
try {
|
|
14705
|
+
const oldLeafId = this.sessionManager.getLeafId();
|
|
14270
14706
|
|
|
14271
|
-
|
|
14272
|
-
|
|
14273
|
-
|
|
14274
|
-
|
|
14707
|
+
// No-op if already at target
|
|
14708
|
+
if (targetId === oldLeafId) {
|
|
14709
|
+
return { cancelled: false };
|
|
14710
|
+
}
|
|
14275
14711
|
|
|
14276
|
-
|
|
14277
|
-
|
|
14278
|
-
|
|
14279
|
-
|
|
14712
|
+
// Model required for summarization
|
|
14713
|
+
if (options.summarize && !this.model) {
|
|
14714
|
+
throw new Error("No model available for summarization");
|
|
14715
|
+
}
|
|
14280
14716
|
|
|
14281
|
-
|
|
14282
|
-
|
|
14283
|
-
|
|
14284
|
-
|
|
14717
|
+
const targetEntry = this.sessionManager.getEntryForFidelity(targetId);
|
|
14718
|
+
if (!targetEntry) {
|
|
14719
|
+
throw new Error(`Entry ${targetId} not found`);
|
|
14720
|
+
}
|
|
14285
14721
|
|
|
14286
|
-
|
|
14287
|
-
|
|
14288
|
-
|
|
14289
|
-
|
|
14290
|
-
|
|
14291
|
-
|
|
14292
|
-
|
|
14293
|
-
|
|
14294
|
-
// Prepare event data
|
|
14295
|
-
const preparation: TreePreparation = {
|
|
14296
|
-
targetId,
|
|
14297
|
-
oldLeafId,
|
|
14298
|
-
commonAncestorId,
|
|
14299
|
-
entriesToSummarize,
|
|
14300
|
-
userWantsSummary: options.summarize ?? false,
|
|
14301
|
-
};
|
|
14722
|
+
// Collect entries to summarize (from old leaf to common ancestor).
|
|
14723
|
+
const { entries: collectedEntriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(
|
|
14724
|
+
this.sessionManager,
|
|
14725
|
+
oldLeafId,
|
|
14726
|
+
targetId,
|
|
14727
|
+
);
|
|
14728
|
+
const entriesToSummarize = this.#withoutEphemeralCustomMessageEntries(collectedEntriesToSummarize);
|
|
14302
14729
|
|
|
14303
|
-
|
|
14304
|
-
|
|
14305
|
-
|
|
14306
|
-
|
|
14730
|
+
// Prepare event data
|
|
14731
|
+
const preparation: TreePreparation = {
|
|
14732
|
+
targetId,
|
|
14733
|
+
oldLeafId,
|
|
14734
|
+
commonAncestorId,
|
|
14735
|
+
entriesToSummarize,
|
|
14736
|
+
userWantsSummary: options.summarize ?? false,
|
|
14737
|
+
};
|
|
14307
14738
|
|
|
14308
|
-
|
|
14309
|
-
|
|
14310
|
-
|
|
14311
|
-
|
|
14312
|
-
preparation,
|
|
14313
|
-
signal: this.#branchSummaryAbortController.signal,
|
|
14314
|
-
})) as SessionBeforeTreeResult | undefined;
|
|
14739
|
+
// Set up abort controller for summarization
|
|
14740
|
+
this.#branchSummaryAbortController = new AbortController();
|
|
14741
|
+
let hookSummary: { summary: string; details?: unknown } | undefined;
|
|
14742
|
+
let fromExtension = false;
|
|
14315
14743
|
|
|
14316
|
-
|
|
14317
|
-
|
|
14318
|
-
|
|
14744
|
+
// Emit session_before_tree event
|
|
14745
|
+
if (this.#extensionRunner?.hasHandlers("session_before_tree")) {
|
|
14746
|
+
const result = (await this.#extensionRunner.emit({
|
|
14747
|
+
type: "session_before_tree",
|
|
14748
|
+
preparation,
|
|
14749
|
+
signal: this.#branchSummaryAbortController.signal,
|
|
14750
|
+
})) as SessionBeforeTreeResult | undefined;
|
|
14319
14751
|
|
|
14320
|
-
|
|
14321
|
-
|
|
14322
|
-
|
|
14323
|
-
}
|
|
14324
|
-
}
|
|
14752
|
+
if (result?.cancel) {
|
|
14753
|
+
return { cancelled: true };
|
|
14754
|
+
}
|
|
14325
14755
|
|
|
14326
|
-
|
|
14327
|
-
|
|
14328
|
-
|
|
14329
|
-
|
|
14330
|
-
const model = this.model!;
|
|
14331
|
-
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
|
|
14332
|
-
if (!apiKey) {
|
|
14333
|
-
throw new Error(`No API key for ${model.provider}`);
|
|
14334
|
-
}
|
|
14335
|
-
const branchSummarySettings = this.settings.getGroup("branchSummary");
|
|
14336
|
-
const result = await generateBranchSummary(entriesToSummarize, {
|
|
14337
|
-
...this.#maintenanceProviderTransport(),
|
|
14338
|
-
model,
|
|
14339
|
-
apiKey,
|
|
14340
|
-
signal: this.#branchSummaryAbortController.signal,
|
|
14341
|
-
customInstructions: options.customInstructions,
|
|
14342
|
-
reserveTokens: branchSummarySettings.reserveTokens,
|
|
14343
|
-
metadata: this.agent.metadataForProvider(model.provider),
|
|
14344
|
-
convertToLlm,
|
|
14345
|
-
telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
|
|
14346
|
-
});
|
|
14347
|
-
this.#branchSummaryAbortController = undefined;
|
|
14348
|
-
if (result.aborted) {
|
|
14349
|
-
return { cancelled: true, aborted: true };
|
|
14756
|
+
if (result?.summary && options.summarize) {
|
|
14757
|
+
hookSummary = result.summary;
|
|
14758
|
+
fromExtension = true;
|
|
14759
|
+
}
|
|
14350
14760
|
}
|
|
14351
|
-
|
|
14352
|
-
|
|
14761
|
+
|
|
14762
|
+
// Run default summarizer if needed
|
|
14763
|
+
let summaryText: string | undefined;
|
|
14764
|
+
let summaryDetails: unknown;
|
|
14765
|
+
if (options.summarize && entriesToSummarize.length > 0 && !hookSummary) {
|
|
14766
|
+
const model = this.model!;
|
|
14767
|
+
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
|
|
14768
|
+
if (!apiKey) {
|
|
14769
|
+
throw new Error(`No API key for ${model.provider}`);
|
|
14770
|
+
}
|
|
14771
|
+
const branchSummarySettings = this.settings.getGroup("branchSummary");
|
|
14772
|
+
const result = await generateBranchSummary(entriesToSummarize, {
|
|
14773
|
+
...this.#maintenanceProviderTransport(),
|
|
14774
|
+
model,
|
|
14775
|
+
apiKey,
|
|
14776
|
+
signal: this.#branchSummaryAbortController.signal,
|
|
14777
|
+
customInstructions: options.customInstructions,
|
|
14778
|
+
reserveTokens: branchSummarySettings.reserveTokens,
|
|
14779
|
+
metadata: this.agent.metadataForProvider(model.provider),
|
|
14780
|
+
convertToLlm,
|
|
14781
|
+
telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
|
|
14782
|
+
});
|
|
14783
|
+
this.#branchSummaryAbortController = undefined;
|
|
14784
|
+
if (result.aborted) {
|
|
14785
|
+
return { cancelled: true, aborted: true };
|
|
14786
|
+
}
|
|
14787
|
+
if (result.error) {
|
|
14788
|
+
throw new Error(result.error);
|
|
14789
|
+
}
|
|
14790
|
+
summaryText = result.summary;
|
|
14791
|
+
summaryDetails = {
|
|
14792
|
+
readFiles: result.readFiles || [],
|
|
14793
|
+
modifiedFiles: result.modifiedFiles || [],
|
|
14794
|
+
};
|
|
14795
|
+
} else if (hookSummary) {
|
|
14796
|
+
summaryText = hookSummary.summary;
|
|
14797
|
+
summaryDetails = hookSummary.details;
|
|
14798
|
+
}
|
|
14799
|
+
|
|
14800
|
+
// Determine the new leaf position based on target type
|
|
14801
|
+
let newLeafId: string | null;
|
|
14802
|
+
let editorText: string | undefined;
|
|
14803
|
+
|
|
14804
|
+
if (targetEntry.type === "message" && targetEntry.message.role === "user") {
|
|
14805
|
+
// User message: leaf = parent (null if root), text goes to editor
|
|
14806
|
+
newLeafId = targetEntry.parentId;
|
|
14807
|
+
editorText = this.#extractUserMessageText(targetEntry.message.content);
|
|
14808
|
+
} else if (targetEntry.type === "custom_message") {
|
|
14809
|
+
// Custom message: leaf = parent (null if root), text goes to editor
|
|
14810
|
+
newLeafId = targetEntry.parentId;
|
|
14811
|
+
editorText =
|
|
14812
|
+
typeof targetEntry.content === "string"
|
|
14813
|
+
? targetEntry.content
|
|
14814
|
+
: targetEntry.content
|
|
14815
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
14816
|
+
.map(c => c.text)
|
|
14817
|
+
.join("");
|
|
14818
|
+
} else {
|
|
14819
|
+
// Non-user message: leaf = selected node
|
|
14820
|
+
newLeafId = targetId;
|
|
14821
|
+
}
|
|
14822
|
+
|
|
14823
|
+
// Switch leaf (with or without summary)
|
|
14824
|
+
// Summary is attached at the navigation target position (newLeafId), not the old branch
|
|
14825
|
+
let summaryEntry: BranchSummaryEntry | undefined;
|
|
14826
|
+
if (summaryText) {
|
|
14827
|
+
// Create summary at target position (can be null for root)
|
|
14828
|
+
const summaryId = this.sessionManager.branchWithSummary(
|
|
14829
|
+
newLeafId,
|
|
14830
|
+
summaryText,
|
|
14831
|
+
summaryDetails,
|
|
14832
|
+
fromExtension,
|
|
14833
|
+
);
|
|
14834
|
+
summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
|
|
14835
|
+
} else if (newLeafId === null) {
|
|
14836
|
+
// No summary, navigating to root - reset leaf
|
|
14837
|
+
this.sessionManager.resetLeaf();
|
|
14838
|
+
} else {
|
|
14839
|
+
// No summary, navigating to non-root
|
|
14840
|
+
this.sessionManager.branch(newLeafId);
|
|
14353
14841
|
}
|
|
14354
|
-
summaryText = result.summary;
|
|
14355
|
-
summaryDetails = {
|
|
14356
|
-
readFiles: result.readFiles || [],
|
|
14357
|
-
modifiedFiles: result.modifiedFiles || [],
|
|
14358
|
-
};
|
|
14359
|
-
} else if (hookSummary) {
|
|
14360
|
-
summaryText = hookSummary.summary;
|
|
14361
|
-
summaryDetails = hookSummary.details;
|
|
14362
|
-
}
|
|
14363
|
-
|
|
14364
|
-
// Determine the new leaf position based on target type
|
|
14365
|
-
let newLeafId: string | null;
|
|
14366
|
-
let editorText: string | undefined;
|
|
14367
|
-
|
|
14368
|
-
if (targetEntry.type === "message" && targetEntry.message.role === "user") {
|
|
14369
|
-
// User message: leaf = parent (null if root), text goes to editor
|
|
14370
|
-
newLeafId = targetEntry.parentId;
|
|
14371
|
-
editorText = this.#extractUserMessageText(targetEntry.message.content);
|
|
14372
|
-
} else if (targetEntry.type === "custom_message") {
|
|
14373
|
-
// Custom message: leaf = parent (null if root), text goes to editor
|
|
14374
|
-
newLeafId = targetEntry.parentId;
|
|
14375
|
-
editorText =
|
|
14376
|
-
typeof targetEntry.content === "string"
|
|
14377
|
-
? targetEntry.content
|
|
14378
|
-
: targetEntry.content
|
|
14379
|
-
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
14380
|
-
.map(c => c.text)
|
|
14381
|
-
.join("");
|
|
14382
|
-
} else {
|
|
14383
|
-
// Non-user message: leaf = selected node
|
|
14384
|
-
newLeafId = targetId;
|
|
14385
|
-
}
|
|
14386
|
-
|
|
14387
|
-
// Switch leaf (with or without summary)
|
|
14388
|
-
// Summary is attached at the navigation target position (newLeafId), not the old branch
|
|
14389
|
-
let summaryEntry: BranchSummaryEntry | undefined;
|
|
14390
|
-
if (summaryText) {
|
|
14391
|
-
// Create summary at target position (can be null for root)
|
|
14392
|
-
const summaryId = this.sessionManager.branchWithSummary(newLeafId, summaryText, summaryDetails, fromExtension);
|
|
14393
|
-
summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry;
|
|
14394
|
-
} else if (newLeafId === null) {
|
|
14395
|
-
// No summary, navigating to root - reset leaf
|
|
14396
|
-
this.sessionManager.resetLeaf();
|
|
14397
|
-
} else {
|
|
14398
|
-
// No summary, navigating to non-root
|
|
14399
|
-
this.sessionManager.branch(newLeafId);
|
|
14400
|
-
}
|
|
14401
14842
|
|
|
14402
|
-
|
|
14403
|
-
|
|
14404
|
-
|
|
14405
|
-
|
|
14406
|
-
|
|
14407
|
-
|
|
14408
|
-
|
|
14409
|
-
|
|
14843
|
+
// Update agent state through the canonical filtered display context so legacy
|
|
14844
|
+
// request-scoped entries cannot re-enter live history after tree navigation.
|
|
14845
|
+
const displayContext = this.buildDisplaySessionContext();
|
|
14846
|
+
await this.#restoreMCPSelectionsForSessionContext(displayContext);
|
|
14847
|
+
this.agent.replaceMessages(displayContext.messages);
|
|
14848
|
+
this.#resetInjectedContextSignatures();
|
|
14849
|
+
this.#syncTodoPhasesFromBranch();
|
|
14850
|
+
this.#closeCodexProviderSessionsForHistoryRewrite();
|
|
14410
14851
|
|
|
14411
|
-
|
|
14852
|
+
this.#branchSummaryAbortController = undefined;
|
|
14412
14853
|
|
|
14413
|
-
|
|
14414
|
-
|
|
14415
|
-
|
|
14416
|
-
|
|
14417
|
-
|
|
14418
|
-
|
|
14419
|
-
|
|
14420
|
-
|
|
14421
|
-
|
|
14422
|
-
|
|
14423
|
-
|
|
14424
|
-
|
|
14425
|
-
|
|
14854
|
+
// Emit session_tree event; only handlers can mutate session entries, so skip
|
|
14855
|
+
// the emit and the context rebuild when no handlers are registered (mirrors
|
|
14856
|
+
// the session_before_tree guard above).
|
|
14857
|
+
if (this.#extensionRunner?.hasHandlers("session_tree")) {
|
|
14858
|
+
await this.#extensionRunner.emit({
|
|
14859
|
+
type: "session_tree",
|
|
14860
|
+
newLeafId: this.sessionManager.getLeafId(),
|
|
14861
|
+
oldLeafId,
|
|
14862
|
+
summaryEntry,
|
|
14863
|
+
fromExtension: summaryText ? fromExtension : undefined,
|
|
14864
|
+
});
|
|
14865
|
+
const refreshedContext = this.buildDisplaySessionContext();
|
|
14866
|
+
return { editorText, cancelled: false, summaryEntry, sessionContext: refreshedContext };
|
|
14867
|
+
}
|
|
14868
|
+
return { editorText, cancelled: false, summaryEntry, sessionContext: displayContext };
|
|
14869
|
+
} finally {
|
|
14870
|
+
this.#endSessionTransition();
|
|
14426
14871
|
}
|
|
14427
|
-
return { editorText, cancelled: false, summaryEntry, sessionContext: displayContext };
|
|
14428
14872
|
}
|
|
14429
14873
|
|
|
14430
14874
|
/**
|