@gajae-code/coding-agent 0.12.0 → 0.12.1
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 +82 -1
- package/dist/types/async/job-manager.d.ts +17 -3
- package/dist/types/config/settings-schema.d.ts +54 -0
- package/dist/types/exa/mcp-client.d.ts +10 -1
- package/dist/types/exec/bash-executor.d.ts +45 -12
- package/dist/types/extensibility/extensions/types.d.ts +16 -2
- package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
- package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
- package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
- package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
- package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +18 -0
- package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
- package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
- package/dist/types/modes/acp/acp-agent.d.ts +2 -19
- package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
- package/dist/types/modes/components/settings-selector.d.ts +9 -0
- package/dist/types/modes/components/status-line/gh.d.ts +5 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
- package/dist/types/reminders/star-reminder.d.ts +3 -13
- package/dist/types/runtime-mcp/manager.d.ts +1 -0
- package/dist/types/sdk/acp/adapter.d.ts +3 -1
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
- package/dist/types/sdk/bus/config.d.ts +6 -2
- package/dist/types/sdk/bus/index.d.ts +29 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
- package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
- package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
- package/dist/types/sdk/bus/rich-render.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
- package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
- package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
- package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
- package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
- package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
- package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
- package/dist/types/sdk/prompt-status.d.ts +23 -3
- package/dist/types/sdk/session.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +38 -2
- package/dist/types/session/artifacts.d.ts +8 -0
- package/dist/types/session/blob-store.d.ts +57 -4
- package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
- package/dist/types/session/session-manager.d.ts +23 -6
- package/dist/types/session/session-storage.d.ts +4 -0
- package/dist/types/session/streaming-output.d.ts +36 -7
- package/dist/types/setup/credential-import.d.ts +12 -0
- package/dist/types/skill-state/active-state.d.ts +12 -0
- package/dist/types/skill-state/workflow-hud.d.ts +6 -0
- package/dist/types/task/executor.d.ts +3 -2
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/task/provider-retry-status.d.ts +8 -0
- package/dist/types/task/receipt.d.ts +7 -1
- package/dist/types/task/types.d.ts +23 -0
- package/dist/types/tools/ask-answer-registry.d.ts +2 -2
- package/dist/types/tools/bash-interactive.d.ts +4 -1
- package/dist/types/tools/bash.d.ts +3 -1
- package/dist/types/tools/image-gen.d.ts +4 -0
- package/dist/types/tools/index.d.ts +4 -0
- package/dist/types/tools/output-meta.d.ts +23 -2
- package/dist/types/tools/path-utils.d.ts +1 -0
- package/dist/types/tools/read.d.ts +0 -6
- package/dist/types/tools/review.d.ts +5 -7
- package/dist/types/tools/subagent.d.ts +2 -0
- package/dist/types/utils/gh.d.ts +13 -0
- package/dist/types/utils/image-resize.d.ts +1 -0
- package/dist/types/web/search/providers/kimi.d.ts +2 -0
- package/dist/types/web/search/providers/searxng.d.ts +7 -0
- package/dist/types/web/search/providers/xai.d.ts +2 -0
- package/package.json +10 -8
- package/scripts/acp-conformance-agent.ts +218 -0
- package/scripts/capture-gjc-bundle-settings.ts +306 -0
- package/scripts/generate-sdk-operation-inventory.ts +3 -0
- package/scripts/resident-memory-bench.ts +1341 -0
- package/scripts/run-acp-conformance.ts +285 -0
- package/src/async/job-manager.ts +78 -20
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/plugin-cli.ts +176 -18
- package/src/commands/team.ts +19 -1
- package/src/config/file-lock.ts +11 -2
- package/src/config/settings-schema.ts +51 -0
- package/src/coordinator-mcp/question-gate-codec.ts +1 -1
- package/src/coordinator-mcp/server.ts +5 -12
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
- package/src/defaults/gjc/skills/ralplan/SKILL.md +19 -16
- package/src/defaults/gjc/skills/team/SKILL.md +2 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +37 -24
- package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
- package/src/defaults/gjc-defaults.ts +4 -4
- package/src/exa/mcp-client.ts +12 -3
- package/src/exec/bash-executor.ts +144 -27
- package/src/extensibility/extensions/runner.ts +15 -0
- package/src/extensibility/extensions/types.ts +18 -1
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
- package/src/extensibility/gjc-plugins/index.ts +19 -2
- package/src/extensibility/gjc-plugins/installer.ts +189 -50
- package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
- package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
- package/src/extensibility/gjc-plugins/observability.ts +3 -3
- package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
- package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
- package/src/extensibility/gjc-plugins/schema.ts +84 -12
- package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
- package/src/extensibility/gjc-plugins/types.ts +141 -0
- package/src/extensibility/gjc-plugins/validation.ts +9 -0
- package/src/gjc-runtime/launch-tmux.ts +25 -6
- package/src/gjc-runtime/managed-owner-admission.ts +1 -1
- package/src/gjc-runtime/ralplan-runtime.ts +411 -17
- package/src/gjc-runtime/team-launch.ts +8 -0
- package/src/gjc-runtime/team-runtime.ts +140 -32
- package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
- package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
- package/src/gjc-runtime/tmux-sessions.ts +58 -13
- package/src/gjc-runtime/ultragoal-runtime.ts +401 -611
- package/src/gjc-runtime/workflow-manifest.generated.json +8 -99
- package/src/gjc-runtime/workflow-manifest.ts +4 -68
- package/src/internal-urls/docs-index.generated.ts +15 -15
- package/src/modes/DESIGN.md +51 -0
- package/src/modes/acp/acp-agent.ts +298 -186
- package/src/modes/components/gjc-bundle-settings.ts +508 -0
- package/src/modes/components/notifications-settings-editor.ts +12 -1
- package/src/modes/components/oauth-selector.ts +68 -30
- package/src/modes/components/session-observer-overlay.ts +305 -86
- package/src/modes/components/settings-selector.ts +56 -5
- package/src/modes/components/status-line/gh.ts +16 -0
- package/src/modes/components/tool-status-header.ts +3 -14
- package/src/modes/components/transcript-viewer-overlay.ts +99 -24
- package/src/modes/controllers/command-controller.ts +4 -0
- package/src/modes/controllers/extension-ui-controller.ts +7 -0
- package/src/modes/controllers/selector-controller.ts +45 -0
- package/src/modes/runtime-init.ts +3 -0
- package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
- package/src/prompts/system/eager-todo.md +8 -4
- package/src/prompts/system/system-prompt.md +11 -4
- package/src/prompts/tools/bash.md +5 -5
- package/src/prompts/tools/search-tool-bm25.md +5 -0
- package/src/prompts/tools/task.md +1 -1
- package/src/reminders/star-reminder.ts +4 -42
- package/src/runtime-mcp/manager.ts +19 -4
- package/src/sdk/acp/adapter.ts +22 -6
- package/src/sdk/broker/transport.ts +35 -3
- package/src/sdk/bus/attachment-registry.ts +5 -1
- package/src/sdk/bus/chat-daemon-cli.ts +4 -39
- package/src/sdk/bus/chat-daemon-control.ts +6 -3
- package/src/sdk/bus/config.ts +18 -2
- package/src/sdk/bus/index.ts +661 -126
- package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
- package/src/sdk/bus/lifecycle-commands.ts +4 -1
- package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
- package/src/sdk/bus/reconciliation-store.ts +95 -16
- package/src/sdk/bus/rich-draft.ts +1 -1
- package/src/sdk/bus/rich-render.ts +2 -2
- package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
- package/src/sdk/bus/telegram-cli.ts +33 -8
- package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
- package/src/sdk/bus/telegram-daemon.ts +916 -23
- package/src/sdk/bus/telegram-reference.ts +40 -3
- package/src/sdk/bus/threaded-render.ts +48 -3
- package/src/sdk/bus/topic-registry.ts +20 -0
- package/src/sdk/host/query/revision-store.ts +36 -2
- package/src/sdk/host/reverse-leases.ts +44 -7
- package/src/sdk/prompt-status.ts +16 -3
- package/src/sdk/protocol/operation-inventory.generated.json +22 -0
- package/src/sdk/session.ts +88 -9
- package/src/session/agent-session.ts +822 -172
- package/src/session/artifacts.ts +22 -0
- package/src/session/auth-broker-config.ts +10 -3
- package/src/session/blob-store.ts +915 -24
- package/src/session/internal/managed-session-scope.ts +44 -6
- package/src/session/internal/managed-session-storage.ts +48 -6
- package/src/session/session-manager.ts +1614 -435
- package/src/session/session-storage.ts +9 -1
- package/src/session/streaming-output.ts +238 -68
- package/src/setup/credential-import.ts +75 -13
- package/src/skill-state/active-state.ts +32 -0
- package/src/skill-state/workflow-hud.ts +17 -0
- package/src/skill-state/workflow-mutation-guard.ts +496 -2
- package/src/task/executor.ts +122 -39
- package/src/task/index.ts +23 -14
- package/src/task/provider-retry-status.ts +16 -0
- package/src/task/receipt.ts +23 -7
- package/src/task/render.ts +6 -2
- package/src/task/types.ts +108 -0
- package/src/tools/ask-answer-registry.ts +12 -6
- package/src/tools/ask.ts +86 -55
- package/src/tools/ast-edit.ts +1 -0
- package/src/tools/ast-grep.ts +1 -0
- package/src/tools/bash-interactive.ts +8 -3
- package/src/tools/bash.ts +503 -60
- package/src/tools/find.ts +1 -0
- package/src/tools/image-gen.ts +29 -5
- package/src/tools/index.ts +4 -0
- package/src/tools/output-meta.ts +108 -14
- package/src/tools/path-utils.ts +3 -2
- package/src/tools/read.ts +18 -0
- package/src/tools/review.ts +5 -7
- package/src/tools/search.ts +1 -0
- package/src/tools/subagent-render.ts +6 -2
- package/src/tools/subagent.ts +6 -0
- package/src/tools/telegram-send.ts +6 -3
- package/src/utils/gh.ts +39 -0
- package/src/utils/image-resize.ts +3 -0
- package/src/web/search/providers/anthropic.ts +4 -4
- package/src/web/search/providers/kimi.ts +15 -5
- package/src/web/search/providers/searxng.ts +47 -4
- package/src/web/search/providers/xai.ts +7 -2
- package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
- package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
- package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
package/src/sdk/bus/index.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { promisify } from "node:util";
|
|
|
30
30
|
import { ThinkingLevel } from "@gajae-code/agent-core";
|
|
31
31
|
import type { ImageContent, TextContent, Tool } from "@gajae-code/ai";
|
|
32
32
|
import { NotificationServer, nativeBuildInfo } from "@gajae-code/natives";
|
|
33
|
-
import { logger, postmortem, VERSION } from "@gajae-code/utils";
|
|
33
|
+
import { $credentialEnv, logger, postmortem, VERSION } from "@gajae-code/utils";
|
|
34
34
|
import { isModelProfileProviderAvailable, projectModelProfileCatalog } from "../../config/model-profile-contract";
|
|
35
35
|
import { isAuthenticated, kNoAuth } from "../../config/model-registry";
|
|
36
36
|
import { Settings } from "../../config/settings";
|
|
@@ -44,9 +44,11 @@ import {
|
|
|
44
44
|
type WorkflowGateTerminalProof,
|
|
45
45
|
} from "../../modes/shared/agent-wire/workflow-gate-broker";
|
|
46
46
|
import type { AgentSessionEvent } from "../../session/agent-session";
|
|
47
|
+
import type { ClientBridge } from "../../session/client-bridge";
|
|
47
48
|
import { parseThinkingLevel } from "../../thinking";
|
|
48
49
|
import type {
|
|
49
50
|
AskAnswerRequest,
|
|
51
|
+
AskAnswerSource,
|
|
50
52
|
AskAnswerSourceResult,
|
|
51
53
|
AskRemoteControl,
|
|
52
54
|
AskRemoteInteraction,
|
|
@@ -63,7 +65,7 @@ import { SessionSdkHost, shouldHostSdk } from "../host";
|
|
|
63
65
|
import { type ControlSurface, dispatchControl } from "../host/control";
|
|
64
66
|
import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "../host/query";
|
|
65
67
|
import { projectQ10Models } from "../models.js";
|
|
66
|
-
import { PROMPT_CLIENT_REF_MAX_LENGTH } from "../prompt-status";
|
|
68
|
+
import { PROMPT_CLIENT_REF_MAX_LENGTH, type SdkPromptTerminalOutcome } from "../prompt-status";
|
|
67
69
|
import { OPERATIONS } from "../protocol/operation-registry";
|
|
68
70
|
import {
|
|
69
71
|
lifecycleStartupCapabilityForApi,
|
|
@@ -644,7 +646,31 @@ export class PresentationArbiter {
|
|
|
644
646
|
) {}
|
|
645
647
|
|
|
646
648
|
retain(presentation: UnattendedGatePresentation): void {
|
|
647
|
-
const
|
|
649
|
+
const existing = this.presentations.get(presentation.gateId);
|
|
650
|
+
if (
|
|
651
|
+
existing &&
|
|
652
|
+
this.active?.gateId === presentation.gateId &&
|
|
653
|
+
(existing.options.length !== presentation.options.length ||
|
|
654
|
+
existing.options.some((option, index) => presentation.options[index] !== option))
|
|
655
|
+
) {
|
|
656
|
+
const active = this.active;
|
|
657
|
+
let status: RetireStatus;
|
|
658
|
+
try {
|
|
659
|
+
status = parseRetireStatus(this.server.retireIfUnclaimed(active).status);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
logger.warn(`interactive presentation replay retirement failed: ${String(error)}`);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (!isTerminalProof(status)) return;
|
|
665
|
+
this.routes.delete(active.actionId);
|
|
666
|
+
this.active = undefined;
|
|
667
|
+
}
|
|
668
|
+
const alreadyPresent = existing !== undefined;
|
|
669
|
+
if (existing?.multi && presentation.multi) {
|
|
670
|
+
presentation.selectedOptions = existing.selectedOptions.filter(option =>
|
|
671
|
+
presentation.options.includes(option),
|
|
672
|
+
);
|
|
673
|
+
}
|
|
648
674
|
if (!alreadyPresent) this.queue.push(presentation.gateId);
|
|
649
675
|
this.presentations.set(presentation.gateId, presentation);
|
|
650
676
|
// A fresh durable replay is explicit production recovery after transient N-API exhaustion.
|
|
@@ -773,6 +799,13 @@ export class PresentationArbiter {
|
|
|
773
799
|
? `(${presentation.selectedOptions.length} selected) ${presentation.question}`
|
|
774
800
|
: presentation.question,
|
|
775
801
|
options: presentation.options,
|
|
802
|
+
...(presentation.multi
|
|
803
|
+
? {
|
|
804
|
+
selectedOptionIndices: presentation.options.flatMap((option, index) =>
|
|
805
|
+
presentation.selectedOptions.includes(option) ? [index] : [],
|
|
806
|
+
),
|
|
807
|
+
}
|
|
808
|
+
: {}),
|
|
776
809
|
...(presentation.recommendedIndex === undefined
|
|
777
810
|
? {}
|
|
778
811
|
: { recommendedIndex: presentation.recommendedIndex }),
|
|
@@ -944,6 +977,19 @@ interface SessionRuntime {
|
|
|
944
977
|
pendingPromptCorrelations: Array<{ commandId: string; turnId: string }>;
|
|
945
978
|
/** Identity bound to the agent lifecycle currently in flight. */
|
|
946
979
|
activePromptCorrelation?: { commandId: string; turnId: string };
|
|
980
|
+
/** Binds the executing Agent run to a correlated prompt so cleanup targets only it. */
|
|
981
|
+
bindPromptExecutionHandle: (correlation: { commandId: string; turnId: string }, handle: string | undefined) => void;
|
|
982
|
+
/** Reads the durable non-terminal claim for a correlated prompt, if any. */
|
|
983
|
+
peekPromptPendingOutcome: (correlation: {
|
|
984
|
+
commandId: string;
|
|
985
|
+
turnId: string;
|
|
986
|
+
}) => SdkPromptTerminalOutcome | undefined;
|
|
987
|
+
/** Claims, fences, finalizes, and publishes exactly one normalized prompt terminal. */
|
|
988
|
+
terminalizePrompt: (
|
|
989
|
+
correlation: { commandId: string; turnId: string },
|
|
990
|
+
outcome: SdkPromptTerminalOutcome,
|
|
991
|
+
extra?: { finalText?: string },
|
|
992
|
+
) => Promise<void>;
|
|
947
993
|
/** Records a correlated prompt terminal boundary after agent unwind. */
|
|
948
994
|
/** Atomically claims a correlated prompt terminal boundary after agent unwind. */
|
|
949
995
|
recordPromptTerminal: (correlation: { commandId: string; turnId: string } | undefined) => boolean;
|
|
@@ -964,6 +1010,7 @@ interface SessionRuntime {
|
|
|
964
1010
|
commandId?: string;
|
|
965
1011
|
turnId?: string;
|
|
966
1012
|
finalText?: string;
|
|
1013
|
+
outcome?: SdkPromptTerminalOutcome;
|
|
967
1014
|
}
|
|
968
1015
|
| {
|
|
969
1016
|
type: "agent_failed";
|
|
@@ -971,6 +1018,7 @@ interface SessionRuntime {
|
|
|
971
1018
|
commandId: string;
|
|
972
1019
|
turnId: string;
|
|
973
1020
|
error: { code: string; message: string };
|
|
1021
|
+
outcome?: SdkPromptTerminalOutcome;
|
|
974
1022
|
},
|
|
975
1023
|
) => void;
|
|
976
1024
|
/** Publishes one canonical agent-wire event to the client that owns the active prompt. */
|
|
@@ -1134,6 +1182,7 @@ const defaultConfig: NotificationConfig = {
|
|
|
1134
1182
|
redact: false,
|
|
1135
1183
|
verbosity: "lean",
|
|
1136
1184
|
sessionScope: "all",
|
|
1185
|
+
sound: "all",
|
|
1137
1186
|
idleTimeoutMs: 60_000,
|
|
1138
1187
|
rich: { enabled: true },
|
|
1139
1188
|
richDraft: { enabled: false },
|
|
@@ -1143,8 +1192,17 @@ const defaultConfig: NotificationConfig = {
|
|
|
1143
1192
|
btw: { enabled: true },
|
|
1144
1193
|
};
|
|
1145
1194
|
|
|
1195
|
+
/**
|
|
1196
|
+
* Whether the notifications control channel is enabled.
|
|
1197
|
+
*
|
|
1198
|
+
* Trusted sources only: enabling it opens the session control/answer channel, so
|
|
1199
|
+
* a repository must not be able to turn it on. `$env` merges the caller's
|
|
1200
|
+
* `cwd/.env` into `process.env`; the sibling resolvers in `config.ts` and
|
|
1201
|
+
* `session-control.ts` already read an injected env record rather than the merged
|
|
1202
|
+
* view, and this direct read was the outlier.
|
|
1203
|
+
*/
|
|
1146
1204
|
export function notificationsEnabled(): boolean {
|
|
1147
|
-
return
|
|
1205
|
+
return $credentialEnv("GJC_NOTIFICATIONS") === "1" || Boolean($credentialEnv("GJC_NOTIFICATIONS_TOKEN"));
|
|
1148
1206
|
}
|
|
1149
1207
|
|
|
1150
1208
|
function streamIntervalMs(): number {
|
|
@@ -1617,6 +1675,80 @@ async function requestRecoveredSelectedAck(
|
|
|
1617
1675
|
}
|
|
1618
1676
|
}
|
|
1619
1677
|
|
|
1678
|
+
function createSdkUiAskAnswerSource(
|
|
1679
|
+
requestElicitation: (params: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>,
|
|
1680
|
+
): AskAnswerSource {
|
|
1681
|
+
const awaitAnswerRequest = async (
|
|
1682
|
+
request: AskAnswerRequest,
|
|
1683
|
+
signal?: AbortSignal,
|
|
1684
|
+
): Promise<AskAnswerSourceResult> => {
|
|
1685
|
+
if (signal?.aborted) return undefined;
|
|
1686
|
+
const choices = new Map<string, AskRemoteInteraction>();
|
|
1687
|
+
const oneOf =
|
|
1688
|
+
request.interaction === "selector"
|
|
1689
|
+
? [
|
|
1690
|
+
...request.options.map((label, index) => {
|
|
1691
|
+
const value = `option:${index}`;
|
|
1692
|
+
choices.set(value, { kind: "value", value: label });
|
|
1693
|
+
return { const: value, title: label };
|
|
1694
|
+
}),
|
|
1695
|
+
...request.controls
|
|
1696
|
+
.filter(control => control.enabled)
|
|
1697
|
+
.map(control => {
|
|
1698
|
+
const value = `control:${control.id}`;
|
|
1699
|
+
choices.set(value, { kind: "control", controlId: control.id });
|
|
1700
|
+
return { const: value, title: control.label };
|
|
1701
|
+
}),
|
|
1702
|
+
]
|
|
1703
|
+
: [];
|
|
1704
|
+
const response = await requestElicitation(
|
|
1705
|
+
{
|
|
1706
|
+
mode: "form",
|
|
1707
|
+
message: request.question,
|
|
1708
|
+
requestedSchema: {
|
|
1709
|
+
type: "object",
|
|
1710
|
+
properties: {
|
|
1711
|
+
value: { type: "string", ...(oneOf.length > 0 ? { oneOf } : {}) },
|
|
1712
|
+
},
|
|
1713
|
+
required: ["value"],
|
|
1714
|
+
},
|
|
1715
|
+
},
|
|
1716
|
+
signal,
|
|
1717
|
+
);
|
|
1718
|
+
if (signal?.aborted || !isRecord(response) || response.action !== "accept") return undefined;
|
|
1719
|
+
const value = isRecord(response.content) ? response.content.value : undefined;
|
|
1720
|
+
if (typeof value !== "string") return undefined;
|
|
1721
|
+
if (request.interaction !== "selector") return value;
|
|
1722
|
+
const interaction = choices.get(value);
|
|
1723
|
+
if (!interaction) return undefined;
|
|
1724
|
+
if (interaction.kind === "value") return interaction.value;
|
|
1725
|
+
let settled: Promise<AskSettlementResult> | undefined;
|
|
1726
|
+
return {
|
|
1727
|
+
source: "remote",
|
|
1728
|
+
interaction,
|
|
1729
|
+
settle(settlement) {
|
|
1730
|
+
if (!settled) {
|
|
1731
|
+
settled = Promise.resolve(
|
|
1732
|
+
settlement.kind === "commit"
|
|
1733
|
+
? { kind: "committed", ack: { status: "failed", reason: "unsupported" } }
|
|
1734
|
+
: settlement.kind === "invalid"
|
|
1735
|
+
? { kind: "invalid_closed" }
|
|
1736
|
+
: { kind: "resolved_without_commit" },
|
|
1737
|
+
);
|
|
1738
|
+
}
|
|
1739
|
+
return settled;
|
|
1740
|
+
},
|
|
1741
|
+
};
|
|
1742
|
+
};
|
|
1743
|
+
return {
|
|
1744
|
+
async awaitAnswer(question, options, signal) {
|
|
1745
|
+
const answer = await awaitAnswerRequest({ question, options, interaction: "selector", controls: [] }, signal);
|
|
1746
|
+
if (!answer || typeof answer === "string") return answer;
|
|
1747
|
+
return answer.interaction.kind === "value" ? answer.interaction.value : undefined;
|
|
1748
|
+
},
|
|
1749
|
+
awaitAnswerRequest,
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1620
1752
|
/** Register the interactive `ask` answer source for a session (the ask tool
|
|
1621
1753
|
* races the local UI against a remote reply). Returns the deregister disposer. */
|
|
1622
1754
|
function registerInteractiveAnswerSource(
|
|
@@ -1947,6 +2079,7 @@ function sdkQuerySurface(
|
|
|
1947
2079
|
getCapabilities: () => ({
|
|
1948
2080
|
operations: [...installedOperations(ctx, "control"), ...installedOperations(ctx, "query")],
|
|
1949
2081
|
hostTools: getInstalledDefinitions("host_tools") !== undefined,
|
|
2082
|
+
promptTerminalOutcomeVersion: 1,
|
|
1950
2083
|
}),
|
|
1951
2084
|
getAuthProviders: () => [...new Set(ctx.modelRegistry.getAll().map(model => model.provider))],
|
|
1952
2085
|
getTools: () => {
|
|
@@ -1990,13 +2123,21 @@ function sdkControlSurface(
|
|
|
1990
2123
|
trackReconciliation?: boolean,
|
|
1991
2124
|
) => void | Promise<void> = () => {},
|
|
1992
2125
|
onPromptFailed: (correlation: { commandId: string; turnId: string }, error: unknown) => void = () => {},
|
|
2126
|
+
onPromptAcceptFailed: (correlation: { commandId: string; turnId: string }) => void = () => {},
|
|
1993
2127
|
acceptGateResolution: () => boolean,
|
|
1994
2128
|
trackGateResolution: <T>(resolution: Promise<T>) => Promise<T>,
|
|
1995
2129
|
admitPrompt: (clientRef?: string) => void,
|
|
1996
2130
|
releasePromptAdmission: (clientRef?: string) => void,
|
|
2131
|
+
awaitReconciliationReady: () => Promise<void> = async () => {},
|
|
1997
2132
|
settings?: Settings,
|
|
1998
2133
|
configOverrides: Map<string, unknown> = new Map(),
|
|
1999
2134
|
configRevision: { current: number } = { current: 0 },
|
|
2135
|
+
abortOwnedPrompt: (
|
|
2136
|
+
connectionId: string | undefined,
|
|
2137
|
+
) => Promise<{ aborted: true; disposition: "cancelled" | "already_terminal" | "idle" }> = async () => ({
|
|
2138
|
+
aborted: true,
|
|
2139
|
+
disposition: "idle",
|
|
2140
|
+
}),
|
|
2000
2141
|
skillRecon?: {
|
|
2001
2142
|
admit: (clientRef?: string) => void;
|
|
2002
2143
|
release: (clientRef?: string) => void;
|
|
@@ -2011,7 +2152,10 @@ function sdkControlSurface(
|
|
|
2011
2152
|
) => Promise<void>;
|
|
2012
2153
|
lookup: (selector: { commandId?: string; turnId?: string; clientRef?: string }) => unknown;
|
|
2013
2154
|
},
|
|
2014
|
-
): ControlSurface {
|
|
2155
|
+
): ControlSurface & {
|
|
2156
|
+
cancelPendingPreflights(): void;
|
|
2157
|
+
cancelPendingPreflightsForConnection(connectionId: string): void;
|
|
2158
|
+
} {
|
|
2015
2159
|
const unavailable = (operation: string, reason: string) => () => {
|
|
2016
2160
|
throw Object.assign(new Error(`${operation} is unavailable: ${reason}`), { code: "unavailable" });
|
|
2017
2161
|
};
|
|
@@ -2065,9 +2209,15 @@ function sdkControlSurface(
|
|
|
2065
2209
|
return unavailable(operation, "no typed session seam is installed")();
|
|
2066
2210
|
return ctx.sdkControl(operation, input);
|
|
2067
2211
|
};
|
|
2068
|
-
const pendingPreflightCancellations = new
|
|
2212
|
+
const pendingPreflightCancellations = new Map<string, { connectionId?: string; cancel: () => void }>();
|
|
2213
|
+
const preflightKey = (connectionId: string | undefined, correlation: { commandId: string; turnId: string }) =>
|
|
2214
|
+
`${connectionId ?? ""}\0${correlation.commandId}\0${correlation.turnId}`;
|
|
2069
2215
|
const cancelPendingPreflights = () => {
|
|
2070
|
-
for (const cancel of pendingPreflightCancellations) cancel();
|
|
2216
|
+
for (const { cancel } of pendingPreflightCancellations.values()) cancel();
|
|
2217
|
+
};
|
|
2218
|
+
const cancelPendingPreflightsForConnection = (connectionId: string) => {
|
|
2219
|
+
for (const entry of pendingPreflightCancellations.values())
|
|
2220
|
+
if (entry.connectionId === connectionId) entry.cancel();
|
|
2071
2221
|
};
|
|
2072
2222
|
const isSessionBusy = () => isBusy() || ctx.isIdle?.() === false;
|
|
2073
2223
|
const awaitAbortReady = async () => {
|
|
@@ -2092,7 +2242,18 @@ function sdkControlSurface(
|
|
|
2092
2242
|
throw Object.assign(new Error("clientRef must be a non-empty string of at most 128 characters."), {
|
|
2093
2243
|
code: "invalid_input",
|
|
2094
2244
|
});
|
|
2095
|
-
if (trackReconciliation)
|
|
2245
|
+
if (trackReconciliation) {
|
|
2246
|
+
// Restart recovery must be committed before a tracked prompt reserves capacity,
|
|
2247
|
+
// otherwise it can admit against pre-hydration state or a lost clientRef.
|
|
2248
|
+
try {
|
|
2249
|
+
await awaitReconciliationReady();
|
|
2250
|
+
} catch {
|
|
2251
|
+
throw Object.assign(new Error("Prompt reconciliation state is unavailable; retry after restart."), {
|
|
2252
|
+
code: "unavailable",
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
admitPrompt(trimmedClientRef);
|
|
2256
|
+
}
|
|
2096
2257
|
try {
|
|
2097
2258
|
if (forceFresh && isSessionBusy()) {
|
|
2098
2259
|
throw Object.assign(
|
|
@@ -2140,17 +2301,31 @@ function sdkControlSurface(
|
|
|
2140
2301
|
status: "rejected",
|
|
2141
2302
|
error: Object.assign(new Error("Prompt preflight was cancelled before execution."), { code: "busy" }),
|
|
2142
2303
|
});
|
|
2143
|
-
pendingPreflightCancellations.
|
|
2304
|
+
pendingPreflightCancellations.set(preflightKey(requesterConnectionId, correlation), {
|
|
2305
|
+
connectionId: requesterConnectionId,
|
|
2306
|
+
cancel: cancelPreflight,
|
|
2307
|
+
});
|
|
2144
2308
|
const settleAccepted = async () => {
|
|
2145
2309
|
if (preflightSettled) return;
|
|
2146
2310
|
accepted = true;
|
|
2147
|
-
|
|
2311
|
+
try {
|
|
2312
|
+
await onPromptAccepted(correlation, requesterConnectionId, trimmedClientRef, trackReconciliation);
|
|
2313
|
+
} catch (error) {
|
|
2314
|
+
// Durable acceptance failed, so the prompt was never accepted: reject the
|
|
2315
|
+
// control preflight and rethrow so the awaiting session does not execute it.
|
|
2316
|
+
accepted = false;
|
|
2317
|
+
onPromptAcceptFailed(correlation);
|
|
2318
|
+
settlePreflight({ status: "rejected", error });
|
|
2319
|
+
throw error;
|
|
2320
|
+
}
|
|
2148
2321
|
settlePreflight({ status: "accepted" });
|
|
2149
2322
|
};
|
|
2150
2323
|
// Durable fence preferred; keep legacy onPreflightAccepted for hosts/tests that only fire the sync hook.
|
|
2151
2324
|
const onPreflightAcceptCommit = settleAccepted;
|
|
2152
2325
|
const onPreflightAccepted = () => {
|
|
2153
|
-
|
|
2326
|
+
// Legacy fire-and-forget hook: the rejection is already reported through the
|
|
2327
|
+
// preflight promise, so swallow it here instead of leaking an unhandled rejection.
|
|
2328
|
+
void settleAccepted().catch(() => {});
|
|
2154
2329
|
};
|
|
2155
2330
|
// Do not acknowledge the prompt until AgentSession's async preflight
|
|
2156
2331
|
// succeeds. The terminal result records correlation before agent_start can fire.
|
|
@@ -2192,24 +2367,35 @@ function sdkControlSurface(
|
|
|
2192
2367
|
if (trackReconciliation) releasePromptAdmission(trimmedClientRef);
|
|
2193
2368
|
throw error;
|
|
2194
2369
|
} finally {
|
|
2195
|
-
pendingPreflightCancellations.delete(
|
|
2370
|
+
pendingPreflightCancellations.delete(preflightKey(requesterConnectionId, correlation));
|
|
2196
2371
|
}
|
|
2197
2372
|
};
|
|
2198
|
-
const surface: ControlSurface & {
|
|
2373
|
+
const surface: ControlSurface & {
|
|
2374
|
+
cancelPendingPreflights(): void;
|
|
2375
|
+
cancelPendingPreflightsForConnection(connectionId: string): void;
|
|
2376
|
+
} = {
|
|
2199
2377
|
prompt: (text, images, clientRef) =>
|
|
2200
2378
|
submitPrompt(text, images, false, undefined, true, controlRequesterContext.getStore(), clientRef, true),
|
|
2201
2379
|
steer: text => sendSteer(text),
|
|
2202
2380
|
followUp: text => submitPrompt(text, undefined, false, "followUp", false, controlRequesterContext.getStore()),
|
|
2203
|
-
abort: () => {
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2381
|
+
abort: async () => {
|
|
2382
|
+
const requesterConnectionId = controlRequesterContext.getStore();
|
|
2383
|
+
const pendingPreflight = [...pendingPreflightCancellations.values()].some(
|
|
2384
|
+
entry => entry.connectionId === requesterConnectionId,
|
|
2385
|
+
);
|
|
2386
|
+
if (pendingPreflight) {
|
|
2387
|
+
if (requesterConnectionId) cancelPendingPreflightsForConnection(requesterConnectionId);
|
|
2388
|
+
else cancelPendingPreflights();
|
|
2389
|
+
return { aborted: true, disposition: "preflight_cancelled" };
|
|
2390
|
+
}
|
|
2391
|
+
return await abortOwnedPrompt(requesterConnectionId);
|
|
2207
2392
|
},
|
|
2208
2393
|
abortAndPrompt: async text => {
|
|
2209
2394
|
await awaitAbortReady();
|
|
2210
2395
|
return await submitPrompt(text, undefined, true, undefined, false, controlRequesterContext.getStore());
|
|
2211
2396
|
},
|
|
2212
2397
|
cancelPendingPreflights,
|
|
2398
|
+
cancelPendingPreflightsForConnection,
|
|
2213
2399
|
answerAsk: (id, answer) => {
|
|
2214
2400
|
const pending = pendingInteractive.get(id);
|
|
2215
2401
|
if (!pending) throw Object.assign(new Error(`Ask ${id} was not found.`), { code: "resource_gone" });
|
|
@@ -2385,7 +2571,16 @@ function sdkControlSurface(
|
|
|
2385
2571
|
const commandId = crypto.randomUUID();
|
|
2386
2572
|
const turnId = crypto.randomUUID();
|
|
2387
2573
|
const correlation = { commandId, turnId };
|
|
2388
|
-
if (skillRecon)
|
|
2574
|
+
if (skillRecon) {
|
|
2575
|
+
try {
|
|
2576
|
+
await awaitReconciliationReady();
|
|
2577
|
+
} catch {
|
|
2578
|
+
throw Object.assign(new Error("Skill reconciliation state is unavailable; retry after restart."), {
|
|
2579
|
+
code: "unavailable",
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
skillRecon.admit(trimmedClientRef);
|
|
2583
|
+
}
|
|
2389
2584
|
const { promise: acceptedP, resolve, reject } = Promise.withResolvers<Record<string, unknown>>();
|
|
2390
2585
|
let phase: "pending" | "accepted" | "rejected" = "pending";
|
|
2391
2586
|
const settleAccept = (value: Record<string, unknown>) => {
|
|
@@ -3315,7 +3510,6 @@ export function createNotificationsExtension(
|
|
|
3315
3510
|
if (lifecycleRequired && !lifecycleAgentDir)
|
|
3316
3511
|
return failLifecycleStartup("failed", "Lifecycle SDK startup requires an agent directory.");
|
|
3317
3512
|
|
|
3318
|
-
const gateOptions = new Map<string, string[]>();
|
|
3319
3513
|
const pendingInteractive = new Map<string, PendingInteractiveAsk>();
|
|
3320
3514
|
const pendingPromptCorrelations: Array<{ commandId: string; turnId: string }> = [];
|
|
3321
3515
|
const tag = sessionTag(id);
|
|
@@ -3353,32 +3547,86 @@ export function createNotificationsExtension(
|
|
|
3353
3547
|
|
|
3354
3548
|
const revisions = new RevisionStore(id, Date.now, { storageDir: stateRoot });
|
|
3355
3549
|
let host: SessionSdkHost | undefined;
|
|
3550
|
+
let disposeUiAnswerSource: (() => void) | undefined;
|
|
3356
3551
|
const installProviderDefinitions = (capability: string, definitions: unknown) => {
|
|
3357
3552
|
validateProviderDefinitions(capability, definitions);
|
|
3358
|
-
if (capability
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3553
|
+
if (capability === "permission") {
|
|
3554
|
+
ctx.setSdkPermissionProvider?.(async (toolCall, permissionOptions, signal) => {
|
|
3555
|
+
const result = await host!.reverse.request(
|
|
3556
|
+
"permission",
|
|
3557
|
+
"request",
|
|
3558
|
+
{
|
|
3559
|
+
toolCall,
|
|
3560
|
+
options: permissionOptions,
|
|
3561
|
+
},
|
|
3562
|
+
signal,
|
|
3563
|
+
);
|
|
3564
|
+
if (!result || typeof result !== "object")
|
|
3565
|
+
throw new Error("permission provider returned an invalid response");
|
|
3566
|
+
const response = result as { outcome?: unknown; optionId?: unknown; kind?: unknown };
|
|
3567
|
+
if (response.outcome === "cancelled") return { outcome: "cancelled" };
|
|
3568
|
+
if (response.outcome === "selected" && typeof response.optionId === "string")
|
|
3569
|
+
return {
|
|
3570
|
+
outcome: "selected",
|
|
3571
|
+
optionId: response.optionId,
|
|
3572
|
+
...(typeof response.kind === "string"
|
|
3573
|
+
? { kind: response.kind as "allow_once" | "allow_always" | "reject_once" | "reject_always" }
|
|
3574
|
+
: {}),
|
|
3575
|
+
};
|
|
3366
3576
|
throw new Error("permission provider returned an invalid response");
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3577
|
+
});
|
|
3578
|
+
return;
|
|
3579
|
+
}
|
|
3580
|
+
if (capability === "ui") {
|
|
3581
|
+
disposeUiAnswerSource?.();
|
|
3582
|
+
disposeUiAnswerSource = registerAskAnswerSource(
|
|
3583
|
+
id,
|
|
3584
|
+
createSdkUiAskAnswerSource(
|
|
3585
|
+
async (params, signal) => await host!.reverse.request("ui", "ui.elicit", params, signal),
|
|
3586
|
+
),
|
|
3587
|
+
);
|
|
3588
|
+
return;
|
|
3589
|
+
}
|
|
3590
|
+
if (capability !== "fs") return;
|
|
3591
|
+
// Only advertise the methods the client actually declared; a read-only client
|
|
3592
|
+
// must keep using the local write path instead of failing against the bridge.
|
|
3593
|
+
const names = new Set(
|
|
3594
|
+
(Array.isArray(definitions) ? definitions : [])
|
|
3595
|
+
.map(definition =>
|
|
3596
|
+
definition && typeof definition === "object" ? (definition as { name?: unknown }).name : undefined,
|
|
3597
|
+
)
|
|
3598
|
+
.filter((name): name is string => typeof name === "string"),
|
|
3599
|
+
);
|
|
3600
|
+
const canRead = names.size === 0 || names.has("fs.readTextFile");
|
|
3601
|
+
const canWrite = names.size === 0 || names.has("fs.writeTextFile");
|
|
3602
|
+
const bridge: ClientBridge = {
|
|
3603
|
+
capabilities: { readTextFile: canRead, writeTextFile: canWrite },
|
|
3604
|
+
deferAgentInitiatedTurns: true,
|
|
3605
|
+
async readTextFile(params) {
|
|
3606
|
+
const result = await host!.reverse.request("fs", "fs.readTextFile", params);
|
|
3607
|
+
if (
|
|
3608
|
+
!result ||
|
|
3609
|
+
typeof result !== "object" ||
|
|
3610
|
+
typeof (result as { content?: unknown }).content !== "string"
|
|
3611
|
+
)
|
|
3612
|
+
throw new Error("fs provider returned an invalid read response");
|
|
3613
|
+
return (result as { content: string }).content;
|
|
3614
|
+
},
|
|
3615
|
+
async writeTextFile(params) {
|
|
3616
|
+
await host!.reverse.request("fs", "fs.writeTextFile", params);
|
|
3617
|
+
},
|
|
3618
|
+
};
|
|
3619
|
+
if (!canRead) delete bridge.readTextFile;
|
|
3620
|
+
if (!canWrite) delete bridge.writeTextFile;
|
|
3621
|
+
ctx.setSdkClientBridge?.(bridge);
|
|
3379
3622
|
};
|
|
3380
3623
|
const removeProviderDefinitions = (capability: string) => {
|
|
3381
3624
|
if (capability === "permission") ctx.setSdkPermissionProvider?.(undefined);
|
|
3625
|
+
if (capability === "fs") ctx.setSdkClientBridge?.(undefined);
|
|
3626
|
+
if (capability === "ui") {
|
|
3627
|
+
disposeUiAnswerSource?.();
|
|
3628
|
+
disposeUiAnswerSource = undefined;
|
|
3629
|
+
}
|
|
3382
3630
|
};
|
|
3383
3631
|
|
|
3384
3632
|
const hostCapCache = new Map<string, ReadonlySet<string>>();
|
|
@@ -3389,6 +3637,8 @@ export function createNotificationsExtension(
|
|
|
3389
3637
|
const PROMPT_SUBMISSION_TTL_MS = 5 * 60_000;
|
|
3390
3638
|
const PROMPT_TERMINAL_TOMBSTONE_CAPACITY = 256;
|
|
3391
3639
|
const PROMPT_TERMINAL_TOMBSTONE_TTL_MS = 15 * 60_000;
|
|
3640
|
+
// SDK-owned terminalization grace; injectable in tests, never a user setting.
|
|
3641
|
+
const PROMPT_TERMINALIZATION_GRACE_MS = 10_000;
|
|
3392
3642
|
const promptSubmissionKey = (correlation: { commandId: string; turnId: string }) =>
|
|
3393
3643
|
`${correlation.commandId}:${correlation.turnId}`;
|
|
3394
3644
|
type PromptLifecycleFrame =
|
|
@@ -3398,6 +3648,7 @@ export function createNotificationsExtension(
|
|
|
3398
3648
|
commandId?: string;
|
|
3399
3649
|
turnId?: string;
|
|
3400
3650
|
finalText?: string;
|
|
3651
|
+
outcome?: SdkPromptTerminalOutcome;
|
|
3401
3652
|
}
|
|
3402
3653
|
| {
|
|
3403
3654
|
type: "agent_failed";
|
|
@@ -3405,6 +3656,7 @@ export function createNotificationsExtension(
|
|
|
3405
3656
|
commandId: string;
|
|
3406
3657
|
turnId: string;
|
|
3407
3658
|
error: { code: string; message: string };
|
|
3659
|
+
outcome?: SdkPromptTerminalOutcome;
|
|
3408
3660
|
};
|
|
3409
3661
|
type PromptSubmission = {
|
|
3410
3662
|
acknowledged: boolean;
|
|
@@ -3414,11 +3666,22 @@ export function createNotificationsExtension(
|
|
|
3414
3666
|
error: unknown;
|
|
3415
3667
|
terminal: boolean;
|
|
3416
3668
|
retainCorrelation: boolean;
|
|
3669
|
+
/** Fatal/uncertain closure: transport-level only, never a semantic terminal. */
|
|
3670
|
+
fatal?: boolean;
|
|
3417
3671
|
createdAt: number;
|
|
3672
|
+
deadlineMs: number;
|
|
3673
|
+
deadlineTimer?: Parameters<typeof clearTimeout>[0];
|
|
3674
|
+
phase: "active" | "outcome_claimed" | "terminalizing" | "publication_closed" | "delivered";
|
|
3675
|
+
outcome?: SdkPromptTerminalOutcome;
|
|
3676
|
+
/** Agent-owned resource run captured at acceptance; cleanup targets only this handle. */
|
|
3677
|
+
executionHandle?: string;
|
|
3418
3678
|
bufferedFrames: Array<PromptLifecycleFrame | Record<string, unknown>>;
|
|
3419
3679
|
};
|
|
3420
3680
|
const promptSubmissions = new Map<string, PromptSubmission>();
|
|
3421
|
-
|
|
3681
|
+
/** Connections fenced by a fatal prompt closure; their later frames are refused. */
|
|
3682
|
+
const fencedConnections = new Set<string>();
|
|
3683
|
+
let cancelPreflightsForConnection: ((connectionId: string) => void) | undefined;
|
|
3684
|
+
const promptTerminalTombstones = new Map<string, { connectionId: string; expiresAt: number }>();
|
|
3422
3685
|
// Authoritative bounded reconciliation state for Q26 turn.prompt_status
|
|
3423
3686
|
// (contract documented in ./prompt-reconciliation and ../prompt-status).
|
|
3424
3687
|
// Active records never age into terminal; documented TTL/capacity
|
|
@@ -3432,7 +3695,14 @@ export function createNotificationsExtension(
|
|
|
3432
3695
|
? createReconciliationStore({ sessionFile, sessionId: String(reconciliationSessionId) })
|
|
3433
3696
|
: null;
|
|
3434
3697
|
const kindReconciliation = createKindAwareReconciliation({ store: durableStore });
|
|
3435
|
-
|
|
3698
|
+
// Restart recovery must commit before any prompt is admitted; otherwise a new
|
|
3699
|
+
// admission can race the hydrated full-state replacement.
|
|
3700
|
+
const reconciliationReady: Promise<void> = durableStore
|
|
3701
|
+
? kindReconciliation.hydrateFromStore()
|
|
3702
|
+
: Promise.resolve();
|
|
3703
|
+
// Never let a hydration rejection become an unhandled rejection; tracked prompts
|
|
3704
|
+
// re-await the same promise and fail closed below.
|
|
3705
|
+
void reconciliationReady.catch(() => {});
|
|
3436
3706
|
// Backward-compatible process-local prompt reconciler kept for unit-test isolation;
|
|
3437
3707
|
// production path uses kindReconciliation for prompt+skill.
|
|
3438
3708
|
const reconciliation = createPromptReconciliation();
|
|
@@ -3452,32 +3722,39 @@ export function createNotificationsExtension(
|
|
|
3452
3722
|
);
|
|
3453
3723
|
if (pendingIndex !== -1) pendingPromptCorrelations.splice(pendingIndex, 1);
|
|
3454
3724
|
};
|
|
3455
|
-
const addTerminalTombstone = (key: string, now = Date.now()) => {
|
|
3725
|
+
const addTerminalTombstone = (key: string, connectionId: string, now = Date.now()) => {
|
|
3456
3726
|
promptTerminalTombstones.delete(key);
|
|
3457
|
-
promptTerminalTombstones.set(key, now + PROMPT_TERMINAL_TOMBSTONE_TTL_MS);
|
|
3727
|
+
promptTerminalTombstones.set(key, { connectionId, expiresAt: now + PROMPT_TERMINAL_TOMBSTONE_TTL_MS });
|
|
3458
3728
|
while (promptTerminalTombstones.size > PROMPT_TERMINAL_TOMBSTONE_CAPACITY)
|
|
3459
3729
|
promptTerminalTombstones.delete(promptTerminalTombstones.keys().next().value!);
|
|
3460
3730
|
};
|
|
3461
3731
|
const finalizePrompt = (key: string, correlation: { commandId: string; turnId: string }) => {
|
|
3732
|
+
const submission = promptSubmissions.get(key);
|
|
3733
|
+
if (submission?.deadlineTimer) clearTimeout(submission.deadlineTimer);
|
|
3462
3734
|
promptSubmissions.delete(key);
|
|
3463
3735
|
removePendingPromptCorrelation(correlation);
|
|
3464
|
-
addTerminalTombstone(key);
|
|
3736
|
+
if (submission) addTerminalTombstone(key, submission.connectionId);
|
|
3465
3737
|
};
|
|
3466
3738
|
const expirePromptDelivery = (key: string, submission: PromptSubmission) => {
|
|
3739
|
+
if (!submission.terminal) return;
|
|
3740
|
+
if (submission.deadlineTimer) clearTimeout(submission.deadlineTimer);
|
|
3467
3741
|
promptSubmissions.delete(key);
|
|
3468
|
-
|
|
3742
|
+
// A fatal closure is transport-level, not a committed semantic terminal: the
|
|
3743
|
+
// durable record stays authoritative, so it must never leave a tombstone.
|
|
3744
|
+
if (submission.fatal) return;
|
|
3469
3745
|
const [commandId, turnId] = key.split(":", 2);
|
|
3470
3746
|
if (!commandId || !turnId) return;
|
|
3471
3747
|
removePendingPromptCorrelation({ commandId, turnId });
|
|
3472
|
-
addTerminalTombstone(key);
|
|
3748
|
+
addTerminalTombstone(key, submission.connectionId);
|
|
3473
3749
|
};
|
|
3474
3750
|
const cleanupPromptRecords = (now = Date.now()) => {
|
|
3475
3751
|
kindReconciliation.cleanup();
|
|
3476
3752
|
reconciliation.cleanup();
|
|
3477
|
-
for (const [key,
|
|
3478
|
-
if (expiresAt <= now) promptTerminalTombstones.delete(key);
|
|
3753
|
+
for (const [key, tombstone] of promptTerminalTombstones)
|
|
3754
|
+
if (tombstone.expiresAt <= now) promptTerminalTombstones.delete(key);
|
|
3479
3755
|
for (const [key, submission] of promptSubmissions)
|
|
3480
|
-
if (submission.createdAt + PROMPT_SUBMISSION_TTL_MS <= now)
|
|
3756
|
+
if (submission.terminal && submission.createdAt + PROMPT_SUBMISSION_TTL_MS <= now)
|
|
3757
|
+
expirePromptDelivery(key, submission);
|
|
3481
3758
|
};
|
|
3482
3759
|
const abandonPrompt = (submission: PromptSubmission) => {
|
|
3483
3760
|
submission.abandoned = true;
|
|
@@ -3511,7 +3788,10 @@ export function createNotificationsExtension(
|
|
|
3511
3788
|
logger.warn(`sdk: correlated lifecycle delivery failed: ${String(error)}`);
|
|
3512
3789
|
abandonPrompt(submission);
|
|
3513
3790
|
}
|
|
3514
|
-
if (submission.terminal)
|
|
3791
|
+
if (submission.terminal) {
|
|
3792
|
+
submission.phase = "delivered";
|
|
3793
|
+
finalizePrompt(key, correlation);
|
|
3794
|
+
}
|
|
3515
3795
|
};
|
|
3516
3796
|
const emitPromptEvent = (event: AgentSessionEvent) => {
|
|
3517
3797
|
if (!runtime?.activePromptCorrelation) return;
|
|
@@ -3547,6 +3827,7 @@ export function createNotificationsExtension(
|
|
|
3547
3827
|
}
|
|
3548
3828
|
}
|
|
3549
3829
|
if (submission.terminal) {
|
|
3830
|
+
submission.phase = "delivered";
|
|
3550
3831
|
const [commandId, turnId] = key.split(":", 2);
|
|
3551
3832
|
if (commandId && turnId) finalizePrompt(key, { commandId, turnId });
|
|
3552
3833
|
}
|
|
@@ -3567,12 +3848,16 @@ export function createNotificationsExtension(
|
|
|
3567
3848
|
// failures that race the durable write still emit correlated terminals.
|
|
3568
3849
|
cleanupPromptRecords();
|
|
3569
3850
|
while (promptSubmissions.size >= PROMPT_SUBMISSION_CAPACITY) {
|
|
3570
|
-
const
|
|
3571
|
-
if (!
|
|
3572
|
-
|
|
3851
|
+
const oldestTerminal = [...promptSubmissions.entries()].find(([, submission]) => submission.terminal);
|
|
3852
|
+
if (!oldestTerminal)
|
|
3853
|
+
throw Object.assign(
|
|
3854
|
+
new Error("Too many active prompt submissions; reconcile or await terminal state."),
|
|
3855
|
+
{ code: "reconciliation_capacity" },
|
|
3856
|
+
);
|
|
3857
|
+
expirePromptDelivery(oldestTerminal[0], oldestTerminal[1]);
|
|
3573
3858
|
}
|
|
3574
3859
|
pendingPromptCorrelations.push(correlation);
|
|
3575
|
-
|
|
3860
|
+
const submission: PromptSubmission = {
|
|
3576
3861
|
acknowledged: false,
|
|
3577
3862
|
connectionId: requesterConnectionId,
|
|
3578
3863
|
abandoned: false,
|
|
@@ -3581,31 +3866,217 @@ export function createNotificationsExtension(
|
|
|
3581
3866
|
terminal: false,
|
|
3582
3867
|
retainCorrelation: trackReconciliation,
|
|
3583
3868
|
createdAt: Date.now(),
|
|
3869
|
+
deadlineMs: settings?.get("sdk.promptDeadlineMs") ?? 1_800_000,
|
|
3870
|
+
phase: "active",
|
|
3871
|
+
// Bound to the Agent run at `agent_start`; acceptance precedes execution.
|
|
3872
|
+
executionHandle: undefined,
|
|
3584
3873
|
bufferedFrames: [],
|
|
3585
|
-
}
|
|
3874
|
+
};
|
|
3875
|
+
promptSubmissions.set(promptSubmissionKey(correlation), submission);
|
|
3586
3876
|
if (trackReconciliation) await notePromptReconciliationAccepted(correlation, clientRef);
|
|
3877
|
+
submission.deadlineTimer = setTimeout(() => {
|
|
3878
|
+
void terminalizePrompt(
|
|
3879
|
+
correlation,
|
|
3880
|
+
{
|
|
3881
|
+
kind: "failed",
|
|
3882
|
+
code: "prompt_deadline_exceeded",
|
|
3883
|
+
message: "Prompt deadline exceeded.",
|
|
3884
|
+
provenance: "deadline",
|
|
3885
|
+
},
|
|
3886
|
+
{ fence: true },
|
|
3887
|
+
);
|
|
3888
|
+
}, submission.deadlineMs);
|
|
3889
|
+
};
|
|
3890
|
+
/** Roll back process-local registration when durable acceptance failed. */
|
|
3891
|
+
const discardPromptAcceptance = (correlation: { commandId: string; turnId: string }) => {
|
|
3892
|
+
promptSubmissions.delete(promptSubmissionKey(correlation));
|
|
3893
|
+
removePendingPromptCorrelation(correlation);
|
|
3587
3894
|
};
|
|
3588
3895
|
const recordPromptTerminal = (correlation: { commandId: string; turnId: string } | undefined) => {
|
|
3589
3896
|
if (!correlation) return false;
|
|
3590
|
-
|
|
3591
|
-
const key = promptSubmissionKey(correlation);
|
|
3592
|
-
if (promptTerminalTombstones.has(key)) return false;
|
|
3593
|
-
const submission = promptSubmissions.get(key);
|
|
3897
|
+
const submission = promptSubmissions.get(promptSubmissionKey(correlation));
|
|
3594
3898
|
if (!submission || submission.terminal) return false;
|
|
3595
3899
|
submission.terminal = true;
|
|
3900
|
+
submission.phase = "publication_closed";
|
|
3596
3901
|
return true;
|
|
3597
3902
|
};
|
|
3903
|
+
/**
|
|
3904
|
+
* Fail-closed teardown: the durable claim survives for restart recovery, but the
|
|
3905
|
+
* waiting client must still reject exactly once, so an infrastructure failure frame
|
|
3906
|
+
* is delivered before the connection is released.
|
|
3907
|
+
*/
|
|
3908
|
+
const failPromptClosed = (
|
|
3909
|
+
correlation: { commandId: string; turnId: string },
|
|
3910
|
+
submission: PromptSubmission,
|
|
3911
|
+
code: string,
|
|
3912
|
+
message: string,
|
|
3913
|
+
) => {
|
|
3914
|
+
if (submission.deadlineTimer) clearTimeout(submission.deadlineTimer);
|
|
3915
|
+
if (!submission.terminal) {
|
|
3916
|
+
submission.terminal = true;
|
|
3917
|
+
submission.fatal = true;
|
|
3918
|
+
submission.phase = "publication_closed";
|
|
3919
|
+
// Deliver the transport failure directly to the accepted requester instead of
|
|
3920
|
+
// `emitPromptLifecycle`: this must never enter the resumable semantic event
|
|
3921
|
+
// ring, finalize reconciliation, or tombstone the correlation, because the
|
|
3922
|
+
// durable claim stays authoritative for Q26 and restart recovery.
|
|
3923
|
+
// Delivered regardless of acknowledgement: a pre-ack fatal failure must still
|
|
3924
|
+
// settle the waiting request instead of leaving it pending forever.
|
|
3925
|
+
if (runtime)
|
|
3926
|
+
try {
|
|
3927
|
+
runtime.server.sendTo(
|
|
3928
|
+
submission.connectionId,
|
|
3929
|
+
JSON.stringify({
|
|
3930
|
+
type: "agent_failed",
|
|
3931
|
+
sessionId: runtime.id,
|
|
3932
|
+
...correlation,
|
|
3933
|
+
error: { code, message },
|
|
3934
|
+
}),
|
|
3935
|
+
);
|
|
3936
|
+
} catch (error) {
|
|
3937
|
+
logger.warn(`sdk: fatal prompt closure delivery failed: ${String(error)}`);
|
|
3938
|
+
}
|
|
3939
|
+
}
|
|
3940
|
+
// Fence the endpoint. The native transport exposes no close primitive, so the
|
|
3941
|
+
// connection is marked failed (every later inbound frame from it is refused),
|
|
3942
|
+
// its reverse leases are released, and its deliveries are abandoned. The durable
|
|
3943
|
+
// record stays active for Q26 and restart recovery.
|
|
3944
|
+
cancelPreflightsForConnection?.(submission.connectionId);
|
|
3945
|
+
fencedConnections.add(submission.connectionId);
|
|
3946
|
+
host?.handleDisconnect(submission.connectionId);
|
|
3947
|
+
for (const [key, owned] of promptSubmissions)
|
|
3948
|
+
if (owned.connectionId === submission.connectionId) {
|
|
3949
|
+
abandonPrompt(owned);
|
|
3950
|
+
const [commandId, turnId] = key.split(":", 2);
|
|
3951
|
+
if (commandId && turnId) removePendingPromptCorrelation({ commandId, turnId });
|
|
3952
|
+
}
|
|
3953
|
+
if (
|
|
3954
|
+
runtime?.activePromptCorrelation?.commandId === correlation.commandId &&
|
|
3955
|
+
runtime.activePromptCorrelation.turnId === correlation.turnId
|
|
3956
|
+
)
|
|
3957
|
+
runtime.activePromptCorrelation = undefined;
|
|
3958
|
+
};
|
|
3959
|
+
const bindPromptExecutionHandle = (
|
|
3960
|
+
correlation: { commandId: string; turnId: string },
|
|
3961
|
+
handle: string | undefined,
|
|
3962
|
+
) => {
|
|
3963
|
+
const submission = promptSubmissions.get(promptSubmissionKey(correlation));
|
|
3964
|
+
if (submission) submission.executionHandle = handle;
|
|
3965
|
+
};
|
|
3966
|
+
const terminalizePrompt = async (
|
|
3967
|
+
correlation: { commandId: string; turnId: string },
|
|
3968
|
+
requestedOutcome: SdkPromptTerminalOutcome,
|
|
3969
|
+
// Cleanup-initiated claims (cancel, deadline, owner disconnect) must abort the
|
|
3970
|
+
// run and prove settlement. A natural `agent_end`/`agent_failed` already unwound,
|
|
3971
|
+
// so aborting there would cancel the next turn instead of fencing this one.
|
|
3972
|
+
options: { fence?: boolean } = {},
|
|
3973
|
+
extra?: { finalText?: string; error?: { code: string; message: string } },
|
|
3974
|
+
) => {
|
|
3975
|
+
const submission = promptSubmissions.get(promptSubmissionKey(correlation));
|
|
3976
|
+
if (!submission || submission.terminal || submission.phase !== "active") return;
|
|
3977
|
+
submission.phase = "outcome_claimed";
|
|
3978
|
+
let winner: SdkPromptTerminalOutcome;
|
|
3979
|
+
try {
|
|
3980
|
+
winner = await kindReconciliation.claimPendingOutcome(correlation, requestedOutcome);
|
|
3981
|
+
} catch (error) {
|
|
3982
|
+
// The claim is the durability boundary: without it nothing may be published
|
|
3983
|
+
// and the endpoint must fail closed so the client rejects exactly once. The
|
|
3984
|
+
// last durable record stays active for restart recovery.
|
|
3985
|
+
logger.warn(`sdk: prompt claim persistence failed: ${String(error)}`);
|
|
3986
|
+
failPromptClosed(correlation, submission, "terminal_uncertain", "Prompt reconciliation is unavailable.");
|
|
3987
|
+
return;
|
|
3988
|
+
}
|
|
3989
|
+
submission.outcome = winner;
|
|
3990
|
+
submission.phase = "terminalizing";
|
|
3991
|
+
if (options.fence) {
|
|
3992
|
+
const seam = ctx as typeof ctx & {
|
|
3993
|
+
abortPromptAndWait?: (
|
|
3994
|
+
handle: string,
|
|
3995
|
+
options: { graceMs: number },
|
|
3996
|
+
) => Promise<{ status: "settled" } | { status: "unfenced" }>;
|
|
3997
|
+
};
|
|
3998
|
+
// Only the handle captured for this correlation may be fenced; a later run
|
|
3999
|
+
// must never be aborted by an older prompt's cleanup.
|
|
4000
|
+
if (typeof seam.abortPromptAndWait !== "function" || !submission.executionHandle) {
|
|
4001
|
+
failPromptClosed(
|
|
4002
|
+
correlation,
|
|
4003
|
+
submission,
|
|
4004
|
+
"terminal_uncertain",
|
|
4005
|
+
"Prompt resources could not be fenced with an exact run handle.",
|
|
4006
|
+
);
|
|
4007
|
+
return;
|
|
4008
|
+
}
|
|
4009
|
+
let proof: { status: "settled" } | { status: "unfenced" };
|
|
4010
|
+
try {
|
|
4011
|
+
proof = await seam.abortPromptAndWait(submission.executionHandle, {
|
|
4012
|
+
graceMs: PROMPT_TERMINALIZATION_GRACE_MS,
|
|
4013
|
+
});
|
|
4014
|
+
} catch (error) {
|
|
4015
|
+
logger.warn(`sdk: prompt resource fencing failed: ${String(error)}`);
|
|
4016
|
+
failPromptClosed(
|
|
4017
|
+
correlation,
|
|
4018
|
+
submission,
|
|
4019
|
+
"terminal_uncertain",
|
|
4020
|
+
"Prompt resources could not be settled before terminalization.",
|
|
4021
|
+
);
|
|
4022
|
+
return;
|
|
4023
|
+
}
|
|
4024
|
+
if (proof?.status !== "settled") {
|
|
4025
|
+
// No settlement proof: never publish a normal terminal. The durable
|
|
4026
|
+
// pending claim stays active so restart recovery finalizes it.
|
|
4027
|
+
failPromptClosed(
|
|
4028
|
+
correlation,
|
|
4029
|
+
submission,
|
|
4030
|
+
"terminal_uncertain",
|
|
4031
|
+
"Prompt resources did not settle before the terminalization grace expired.",
|
|
4032
|
+
);
|
|
4033
|
+
return;
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
try {
|
|
4037
|
+
await kindReconciliation.finalizePromptOutcome(correlation, winner, extra?.error);
|
|
4038
|
+
} catch (error) {
|
|
4039
|
+
// The durable pending claim survives; publishing an unpersisted terminal
|
|
4040
|
+
// would contradict it, so fail the endpoint closed instead.
|
|
4041
|
+
logger.warn(`sdk: prompt terminal persistence failed: ${String(error)}`);
|
|
4042
|
+
failPromptClosed(correlation, submission, "terminal_uncertain", "Prompt reconciliation is unavailable.");
|
|
4043
|
+
return;
|
|
4044
|
+
}
|
|
4045
|
+
if (submission.deadlineTimer) clearTimeout(submission.deadlineTimer);
|
|
4046
|
+
if (!recordPromptTerminal(correlation) || !runtime) return;
|
|
4047
|
+
if (winner.kind === "failed") {
|
|
4048
|
+
emitPromptLifecycle(correlation, {
|
|
4049
|
+
type: "agent_failed",
|
|
4050
|
+
sessionId: runtime.id,
|
|
4051
|
+
...correlation,
|
|
4052
|
+
error: extra?.error ?? { code: winner.code, message: winner.message },
|
|
4053
|
+
outcome: winner,
|
|
4054
|
+
});
|
|
4055
|
+
} else {
|
|
4056
|
+
emitPromptLifecycle(correlation, {
|
|
4057
|
+
type: "agent_end",
|
|
4058
|
+
sessionId: runtime.id,
|
|
4059
|
+
...correlation,
|
|
4060
|
+
...(extra?.finalText ? { finalText: extra.finalText } : {}),
|
|
4061
|
+
outcome: winner,
|
|
4062
|
+
});
|
|
4063
|
+
}
|
|
4064
|
+
};
|
|
3598
4065
|
const emitPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
|
|
3599
4066
|
const sanitized = sanitizePromptFailure(error);
|
|
3600
|
-
void
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
4067
|
+
void terminalizePrompt(
|
|
4068
|
+
correlation,
|
|
4069
|
+
{
|
|
4070
|
+
kind: "failed",
|
|
4071
|
+
code: "prompt_failed",
|
|
4072
|
+
message: sanitized.message,
|
|
4073
|
+
provenance: "agent_failed",
|
|
4074
|
+
},
|
|
4075
|
+
{},
|
|
4076
|
+
// The normalized outcome is the ACP contract; the wire error keeps the
|
|
4077
|
+
// existing sanitized discriminator so SDK clients keep branching on it.
|
|
4078
|
+
{ error: sanitized },
|
|
4079
|
+
);
|
|
3609
4080
|
};
|
|
3610
4081
|
const recordPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
|
|
3611
4082
|
const submission = promptSubmissions.get(promptSubmissionKey(correlation));
|
|
@@ -3662,13 +4133,29 @@ export function createNotificationsExtension(
|
|
|
3662
4133
|
() => runtime?.busy === true || pendingPromptCorrelations.length > 0,
|
|
3663
4134
|
recordPromptAccepted,
|
|
3664
4135
|
recordPromptFailure,
|
|
4136
|
+
discardPromptAcceptance,
|
|
3665
4137
|
() => runtime?.stopping !== true,
|
|
3666
4138
|
trackGateResolution,
|
|
3667
4139
|
admitPromptSubmission,
|
|
3668
4140
|
releasePromptAdmission,
|
|
4141
|
+
() => reconciliationReady,
|
|
3669
4142
|
settings,
|
|
3670
4143
|
configOverrides,
|
|
3671
4144
|
configRevision,
|
|
4145
|
+
async connectionId => {
|
|
4146
|
+
const active = [...promptSubmissions.entries()].find(
|
|
4147
|
+
([, submission]) => submission.connectionId === connectionId && !submission.terminal,
|
|
4148
|
+
);
|
|
4149
|
+
if (!active) return { aborted: true, disposition: "idle" as const };
|
|
4150
|
+
const [commandId, turnId] = active[0].split(":", 2);
|
|
4151
|
+
if (commandId && turnId)
|
|
4152
|
+
await terminalizePrompt(
|
|
4153
|
+
{ commandId, turnId },
|
|
4154
|
+
{ kind: "stopped", reason: "cancelled", provenance: "client_cancel" },
|
|
4155
|
+
{ fence: true },
|
|
4156
|
+
);
|
|
4157
|
+
return { aborted: true, disposition: "cancelled" as const };
|
|
4158
|
+
},
|
|
3672
4159
|
{
|
|
3673
4160
|
admit: (clientRef?: string) => kindReconciliation.admit("skill", clientRef),
|
|
3674
4161
|
release: (clientRef?: string) => kindReconciliation.releaseAdmission("skill", clientRef),
|
|
@@ -3678,6 +4165,7 @@ export function createNotificationsExtension(
|
|
|
3678
4165
|
lookup: selector => kindReconciliation.lookup("skill", selector),
|
|
3679
4166
|
},
|
|
3680
4167
|
);
|
|
4168
|
+
cancelPreflightsForConnection = controlSurface.cancelPendingPreflightsForConnection;
|
|
3681
4169
|
const abandonPromptResponse = (connectionId: string, frame: Record<string, unknown>) => {
|
|
3682
4170
|
if (
|
|
3683
4171
|
frame.type !== "control_response" ||
|
|
@@ -3951,6 +4439,9 @@ export function createNotificationsExtension(
|
|
|
3951
4439
|
busy: false,
|
|
3952
4440
|
pendingPromptCorrelations,
|
|
3953
4441
|
activePromptCorrelation: undefined,
|
|
4442
|
+
bindPromptExecutionHandle,
|
|
4443
|
+
peekPromptPendingOutcome: correlation => kindReconciliation.peekPendingOutcome(correlation),
|
|
4444
|
+
terminalizePrompt: (correlation, outcome, extra) => terminalizePrompt(correlation, outcome, {}, extra),
|
|
3954
4445
|
recordPromptTerminal,
|
|
3955
4446
|
notePromptReconciliation: (correlation, frame) => {
|
|
3956
4447
|
void kindReconciliation.noteTransition("prompt", correlation, frame);
|
|
@@ -4013,6 +4504,23 @@ export function createNotificationsExtension(
|
|
|
4013
4504
|
});
|
|
4014
4505
|
initializedRuntime.abortEphemeralTurns = () => ephemeralTurns.dispose();
|
|
4015
4506
|
initializedRuntime.disableEphemeralTurns = () => ephemeralTurns.disable();
|
|
4507
|
+
const sendEndpointStale = (connectionId: string, frame: Record<string, unknown>) => {
|
|
4508
|
+
const id = typeof frame.id === "string" ? frame.id : undefined;
|
|
4509
|
+
if (!id) return;
|
|
4510
|
+
const error = { code: "endpoint_stale", message: "session endpoint is stale." };
|
|
4511
|
+
const responseType =
|
|
4512
|
+
frame.type === "control_request"
|
|
4513
|
+
? "control_response"
|
|
4514
|
+
: frame.type === "query_request"
|
|
4515
|
+
? "query_response"
|
|
4516
|
+
: frame.type === "global_request"
|
|
4517
|
+
? "global_response"
|
|
4518
|
+
: undefined;
|
|
4519
|
+
if (!responseType) return;
|
|
4520
|
+
try {
|
|
4521
|
+
server.sendTo(connectionId, JSON.stringify({ type: responseType, id, ok: false, error }));
|
|
4522
|
+
} catch {}
|
|
4523
|
+
};
|
|
4016
4524
|
try {
|
|
4017
4525
|
server.onSdkFrame((err, inbound) => {
|
|
4018
4526
|
if (err || !inbound) return;
|
|
@@ -4020,20 +4528,12 @@ export function createNotificationsExtension(
|
|
|
4020
4528
|
const frame = JSON.parse(inbound.json) as unknown;
|
|
4021
4529
|
if (!frame || typeof frame !== "object") return;
|
|
4022
4530
|
const typedFrame = frame as Record<string, unknown>;
|
|
4531
|
+
if (inbound.connectionId && fencedConnections.has(inbound.connectionId)) {
|
|
4532
|
+
sendEndpointStale(inbound.connectionId, typedFrame);
|
|
4533
|
+
return;
|
|
4534
|
+
}
|
|
4023
4535
|
if (initializedRuntime.inboundFenced) {
|
|
4024
|
-
|
|
4025
|
-
try {
|
|
4026
|
-
server.sendTo(
|
|
4027
|
-
inbound.connectionId,
|
|
4028
|
-
JSON.stringify({
|
|
4029
|
-
type: "control_response",
|
|
4030
|
-
id: typedFrame.id,
|
|
4031
|
-
ok: false,
|
|
4032
|
-
error: { code: "endpoint_stale", message: "session endpoint is draining." },
|
|
4033
|
-
}),
|
|
4034
|
-
);
|
|
4035
|
-
} catch {}
|
|
4036
|
-
}
|
|
4536
|
+
sendEndpointStale(inbound.connectionId, typedFrame);
|
|
4037
4537
|
return;
|
|
4038
4538
|
}
|
|
4039
4539
|
if (typedFrame.type === "ephemeral_turn" || typedFrame.type === "ephemeral_turn_cancel") return;
|
|
@@ -4062,8 +4562,19 @@ export function createNotificationsExtension(
|
|
|
4062
4562
|
});
|
|
4063
4563
|
server.onConnectionClose((_err, connectionId) => {
|
|
4064
4564
|
if (!connectionId) return;
|
|
4565
|
+
controlSurface.cancelPendingPreflightsForConnection(connectionId);
|
|
4065
4566
|
host.handleDisconnect(connectionId);
|
|
4066
4567
|
hostCapCache.delete(connectionId);
|
|
4568
|
+
// The socket is gone, so its fence has nothing left to refuse. Dropping the
|
|
4569
|
+
// entry keeps the set bounded by live connections instead of growing forever.
|
|
4570
|
+
fencedConnections.delete(connectionId);
|
|
4571
|
+
// Deliberate deviation from the plan's "claim prompt_failed on old-owner
|
|
4572
|
+
// disconnect": that would break the shipped Q26 reconnect contract, where a
|
|
4573
|
+
// client may drop its socket and reconcile the still-running prompt without
|
|
4574
|
+
// duplicate execution (see test/sdk-host-wiring.test.ts "turn.prompt_status
|
|
4575
|
+
// reconciles an accepted prompt across client reconnect"). A closed socket
|
|
4576
|
+
// therefore only abandons delivery; ACP rejects its own local waiter once and
|
|
4577
|
+
// terminal authority stays with the eventual normalized SDK outcome.
|
|
4067
4578
|
for (const submission of promptSubmissions.values())
|
|
4068
4579
|
if (submission.connectionId === connectionId) abandonPrompt(submission);
|
|
4069
4580
|
});
|
|
@@ -4232,7 +4743,7 @@ export function createNotificationsExtension(
|
|
|
4232
4743
|
} else if (presentation?.multi && typeof rawAnswer === "string") {
|
|
4233
4744
|
answer = { selected: presentation.selectedOptions, other: true, custom: rawAnswer };
|
|
4234
4745
|
} else {
|
|
4235
|
-
const mapped = mapAnswerToGate(reply.answerJson,
|
|
4746
|
+
const mapped = mapAnswerToGate(reply.answerJson, presentation?.options ?? []);
|
|
4236
4747
|
if (!mapped.ok) {
|
|
4237
4748
|
// A numeric selector outside options is invalid (issue #2030): close the
|
|
4238
4749
|
// exact claim/receipt and reissue the interaction — never a success ack.
|
|
@@ -4585,16 +5096,28 @@ export function createNotificationsExtension(
|
|
|
4585
5096
|
if (!canDeliverAsync(runtime, generation)) {
|
|
4586
5097
|
return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
|
|
4587
5098
|
}
|
|
4588
|
-
|
|
4589
|
-
runtime,
|
|
4590
|
-
|
|
4591
|
-
type: "file_attachment",
|
|
5099
|
+
if (file.mime?.startsWith("image/")) {
|
|
5100
|
+
pushSessionFrame(runtime, {
|
|
5101
|
+
type: "image_attachment",
|
|
4592
5102
|
sessionId: runtime.id,
|
|
4593
|
-
|
|
5103
|
+
source: "telegram_send",
|
|
5104
|
+
mime: file.mime,
|
|
4594
5105
|
caption: file.caption,
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
5106
|
+
data: data.toString("base64"),
|
|
5107
|
+
});
|
|
5108
|
+
} else {
|
|
5109
|
+
pushFileAttachment(
|
|
5110
|
+
runtime,
|
|
5111
|
+
{
|
|
5112
|
+
type: "file_attachment",
|
|
5113
|
+
sessionId: runtime.id,
|
|
5114
|
+
name: path.basename(file.path),
|
|
5115
|
+
mime: file.mime,
|
|
5116
|
+
caption: file.caption,
|
|
5117
|
+
},
|
|
5118
|
+
data,
|
|
5119
|
+
);
|
|
5120
|
+
}
|
|
4598
5121
|
return { ok: true };
|
|
4599
5122
|
} catch (e) {
|
|
4600
5123
|
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
@@ -4628,7 +5151,6 @@ export function createNotificationsExtension(
|
|
|
4628
5151
|
activeRuntime.disposeGateTerminalController = () => {};
|
|
4629
5152
|
activeRuntime.disposeAckRecoveryParticipant = () => {};
|
|
4630
5153
|
activeRuntime.workflowGate = undefined;
|
|
4631
|
-
gateOptions.clear();
|
|
4632
5154
|
if (typeof gate?.onGateEmitted !== "function" || typeof gate.resolveGateFromNotification !== "function") {
|
|
4633
5155
|
return;
|
|
4634
5156
|
}
|
|
@@ -4653,7 +5175,6 @@ export function createNotificationsExtension(
|
|
|
4653
5175
|
const rawGateOptions = g.options ?? [];
|
|
4654
5176
|
const options = rawGateOptions.map(o => String((o as { label?: unknown }).label ?? ""));
|
|
4655
5177
|
const recommendedIndex = recommendedIndexFromGateOptions(rawGateOptions);
|
|
4656
|
-
gateOptions.set(g.gate_id, options);
|
|
4657
5178
|
const promptCtx = g.context as { prompt?: unknown; title?: unknown } | undefined;
|
|
4658
5179
|
const question =
|
|
4659
5180
|
(typeof promptCtx?.prompt === "string" && promptCtx.prompt) ||
|
|
@@ -5169,6 +5690,7 @@ export function createNotificationsExtension(
|
|
|
5169
5690
|
rt.busy = true;
|
|
5170
5691
|
const correlation = rt.pendingPromptCorrelations.shift();
|
|
5171
5692
|
rt.activePromptCorrelation = correlation;
|
|
5693
|
+
if (correlation) rt.bindPromptExecutionHandle(correlation, ctx.getActivePromptHandle());
|
|
5172
5694
|
rt.notePromptReconciliation(correlation, { type: "agent_start" });
|
|
5173
5695
|
rt.emitPromptLifecycle(correlation, { type: "agent_start", sessionId: id, ...correlation });
|
|
5174
5696
|
try {
|
|
@@ -5214,15 +5736,45 @@ export function createNotificationsExtension(
|
|
|
5214
5736
|
rt.busy = false;
|
|
5215
5737
|
const correlation = rt.activePromptCorrelation;
|
|
5216
5738
|
if (correlation) {
|
|
5217
|
-
const
|
|
5739
|
+
const assistants = (Array.isArray(event.messages) ? [...event.messages].reverse() : []).filter(
|
|
5740
|
+
message => message && typeof message === "object" && (message as { role?: unknown }).role === "assistant",
|
|
5741
|
+
) as Array<{ stopReason?: unknown; errorKind?: unknown }>;
|
|
5742
|
+
const finalAssistant = assistants[0];
|
|
5743
|
+
const pendingOutcome = rt.peekPromptPendingOutcome(correlation);
|
|
5744
|
+
let outcome: SdkPromptTerminalOutcome;
|
|
5745
|
+
if (pendingOutcome) outcome = pendingOutcome;
|
|
5746
|
+
else if (finalAssistant?.stopReason === "length")
|
|
5747
|
+
outcome = { kind: "stopped", reason: "max_tokens", provenance: "agent" };
|
|
5748
|
+
else if (finalAssistant?.errorKind === "provider_safety_stop")
|
|
5749
|
+
outcome = { kind: "stopped", reason: "refusal", provenance: "agent" };
|
|
5750
|
+
// A missing/normal final assistant with a non-failing loop stop reason is a
|
|
5751
|
+
// normal turn end; only explicit error/aborted assistants or non-completed
|
|
5752
|
+
// loop stop reasons are failures. Text is never parsed.
|
|
5753
|
+
else if (
|
|
5754
|
+
(event.stopReason === undefined || event.stopReason === "completed") &&
|
|
5755
|
+
finalAssistant?.stopReason !== "error" &&
|
|
5756
|
+
finalAssistant?.stopReason !== "aborted"
|
|
5757
|
+
)
|
|
5758
|
+
outcome = { kind: "stopped", reason: "end_turn", provenance: "agent" };
|
|
5759
|
+
else
|
|
5760
|
+
outcome = {
|
|
5761
|
+
kind: "failed",
|
|
5762
|
+
code: "prompt_failed",
|
|
5763
|
+
message: "Prompt submission failed.",
|
|
5764
|
+
provenance: "agent_failed",
|
|
5765
|
+
};
|
|
5766
|
+
const successAssistant = assistants.find(
|
|
5767
|
+
message => (message as { stopReason?: unknown }).stopReason === "stop",
|
|
5768
|
+
);
|
|
5769
|
+
const finalText = successAssistant ? acpFinalTextFromMessage(successAssistant).text : "";
|
|
5770
|
+
// The normalized outcome is the ACP contract; existing SDK clients keep the
|
|
5771
|
+
// legacy failure discriminator on the wire and in the reconciliation record.
|
|
5772
|
+
const terminalAssistant = assistants.find(
|
|
5218
5773
|
message =>
|
|
5219
|
-
message
|
|
5220
|
-
|
|
5221
|
-
(message as { role?: unknown }).role === "assistant" &&
|
|
5222
|
-
((message as { stopReason?: unknown }).stopReason === "error" ||
|
|
5223
|
-
(message as { stopReason?: unknown }).stopReason === "aborted"),
|
|
5774
|
+
(message as { stopReason?: unknown }).stopReason === "error" ||
|
|
5775
|
+
(message as { stopReason?: unknown }).stopReason === "aborted",
|
|
5224
5776
|
) as { stopReason?: "error" | "aborted"; errorMessage?: unknown } | undefined;
|
|
5225
|
-
const
|
|
5777
|
+
const legacyCode =
|
|
5226
5778
|
event.stopReason === "cancelled"
|
|
5227
5779
|
? "cancelled"
|
|
5228
5780
|
: terminalAssistant?.stopReason === "error"
|
|
@@ -5230,33 +5782,16 @@ export function createNotificationsExtension(
|
|
|
5230
5782
|
: terminalAssistant?.stopReason === "aborted"
|
|
5231
5783
|
? "aborted"
|
|
5232
5784
|
: undefined;
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
: "Prompt submission failed.",
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
if (rt.recordPromptTerminal(correlation)) {
|
|
5244
|
-
const finalAssistant = (Array.isArray(event.messages) ? [...event.messages].reverse() : []).find(
|
|
5245
|
-
message =>
|
|
5246
|
-
message &&
|
|
5247
|
-
typeof message === "object" &&
|
|
5248
|
-
(message as { role?: unknown }).role === "assistant" &&
|
|
5249
|
-
(message as { stopReason?: unknown }).stopReason === "stop",
|
|
5250
|
-
);
|
|
5251
|
-
const finalText = finalAssistant ? acpFinalTextFromMessage(finalAssistant).text : "";
|
|
5252
|
-
rt.emitPromptLifecycle(correlation, {
|
|
5253
|
-
type: "agent_end",
|
|
5254
|
-
sessionId: id,
|
|
5255
|
-
...correlation,
|
|
5256
|
-
...(finalText ? { finalText } : {}),
|
|
5257
|
-
});
|
|
5258
|
-
}
|
|
5259
|
-
}
|
|
5785
|
+
void rt.terminalizePrompt(correlation, outcome, {
|
|
5786
|
+
...(finalText ? { finalText } : {}),
|
|
5787
|
+
...(outcome.kind === "failed" && legacyCode
|
|
5788
|
+
? {
|
|
5789
|
+
// Only the legacy discriminator is preserved; provider text is never
|
|
5790
|
+
// retained, matching `sanitizePromptFailure`.
|
|
5791
|
+
error: { code: legacyCode, message: "Prompt submission failed." },
|
|
5792
|
+
}
|
|
5793
|
+
: {}),
|
|
5794
|
+
});
|
|
5260
5795
|
} else {
|
|
5261
5796
|
rt.emitPromptLifecycle(undefined, { type: "agent_end", sessionId: id });
|
|
5262
5797
|
}
|