@gajae-code/coding-agent 0.10.2 → 0.11.0
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 +50 -2
- package/dist/types/append-only-mode.d.ts +1 -1
- package/dist/types/cli/args.d.ts +4 -5
- package/dist/types/cli/daemon-cli.d.ts +12 -1
- package/dist/types/cli/notify-cli.d.ts +21 -1
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/commands/daemon.d.ts +39 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/commands/notify.d.ts +28 -1
- package/dist/types/commands/sdk.d.ts +33 -0
- package/dist/types/config/atomic-yaml-patch.d.ts +85 -0
- package/dist/types/config/model-profile-activation.d.ts +35 -15
- package/dist/types/config/model-profiles.d.ts +11 -5
- package/dist/types/config/model-registry.d.ts +3 -3
- package/dist/types/config/model-resolver.d.ts +39 -6
- package/dist/types/config/model-selector-value.d.ts +17 -0
- package/dist/types/config/models-config-schema.d.ts +10 -6
- package/dist/types/config/settings-schema.d.ts +133 -6
- package/dist/types/config/settings.d.ts +63 -35
- package/dist/types/coordinator-mcp/model-preset.d.ts +7 -6
- package/dist/types/coordinator-mcp/server.d.ts +6 -49
- package/dist/types/daemon/builtin.d.ts +6 -5
- package/dist/types/daemon/control-types.d.ts +1 -1
- package/dist/types/discovery/mcp-json.d.ts +12 -1
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
- package/dist/types/extensibility/extensions/compact-handler.d.ts +4 -2
- package/dist/types/extensibility/extensions/get-commands-handler.d.ts +2 -2
- package/dist/types/extensibility/extensions/loader.d.ts +10 -1
- package/dist/types/extensibility/extensions/runner.d.ts +3 -3
- package/dist/types/extensibility/extensions/types.d.ts +141 -16
- package/dist/types/extensibility/hooks/types.d.ts +1 -1
- package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -2
- package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +3 -4
- package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +13 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -1
- package/dist/types/gjc-runtime/windows-powershell-command.d.ts +9 -0
- package/dist/types/harness-control-plane/operate.d.ts +5 -5
- package/dist/types/harness-control-plane/owner.d.ts +18 -4
- package/dist/types/harness-control-plane/receipts.d.ts +0 -1
- package/dist/types/harness-control-plane/sdk-transport.d.ts +43 -0
- package/dist/types/harness-control-plane/session-transport.d.ts +48 -0
- package/dist/types/harness-control-plane/types.d.ts +3 -4
- package/dist/types/lsp/index.d.ts +1 -1
- package/dist/types/main.d.ts +32 -38
- package/dist/types/modes/acp/acp-agent.d.ts +224 -53
- package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -0
- package/dist/types/modes/acp/acp-mode.d.ts +6 -15
- package/dist/types/modes/acp/permission-mode.d.ts +4 -0
- package/dist/types/modes/acp/startup-options.d.ts +6 -0
- package/dist/types/modes/components/agent-dashboard.d.ts +3 -0
- package/dist/types/modes/components/model-selector.d.ts +1 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +135 -0
- package/dist/types/modes/components/settings-selector.d.ts +6 -4
- package/dist/types/modes/components/welcome.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +35 -1
- package/dist/types/modes/index.d.ts +0 -4
- package/dist/types/modes/interactive-mode.d.ts +3 -0
- package/dist/types/modes/runtime-init.d.ts +2 -2
- package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +6 -9
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -6
- package/dist/types/modes/shared/agent-wire/event-contract.d.ts +6 -6
- package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +4 -9
- package/dist/types/modes/shared/agent-wire/event-observation.d.ts +1 -1
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +169 -30
- package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +7 -7
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +105 -0
- package/dist/types/modes/types.d.ts +5 -0
- package/dist/types/runtime-mcp/client.d.ts +2 -0
- package/dist/types/runtime-mcp/config.d.ts +5 -1
- package/dist/types/runtime-mcp/manager.d.ts +8 -1
- package/dist/types/runtime-mcp/types.d.ts +4 -0
- package/dist/types/sdk/acp/adapter.d.ts +67 -0
- package/dist/types/sdk/acp/index.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +53 -0
- package/dist/types/sdk/broker/discovery.d.ts +30 -0
- package/dist/types/sdk/broker/ensure.d.ts +33 -0
- package/dist/types/sdk/broker/identity.d.ts +3 -0
- package/dist/types/sdk/broker/index.d.ts +8 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +95 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +85 -0
- package/dist/types/sdk/broker/process-incarnation.d.ts +14 -0
- package/dist/types/sdk/broker/runtime.d.ts +33 -0
- package/dist/types/sdk/broker/session-index.d.ts +57 -0
- package/dist/types/sdk/broker/state-version.d.ts +8 -0
- package/dist/types/sdk/broker/transport.d.ts +9 -0
- package/dist/types/sdk/bus/chat-command-policy.d.ts +49 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +21 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +111 -0
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +77 -0
- package/dist/types/sdk/bus/chat-effect-journal.d.ts +84 -0
- package/dist/types/{notifications → sdk/bus}/config-commands.d.ts +7 -2
- package/dist/types/sdk/bus/config.d.ts +274 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +72 -0
- package/dist/types/sdk/bus/discord-conversation.d.ts +58 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +45 -0
- package/dist/types/sdk/bus/discord-live-provider.d.ts +70 -0
- package/dist/types/sdk/bus/discord-provider.d.ts +85 -0
- package/dist/types/{notifications → sdk/bus}/html-format.d.ts +2 -2
- package/dist/types/{notifications → sdk/bus}/index.d.ts +113 -13
- package/dist/types/{notifications → sdk/bus}/lifecycle-control-runtime.d.ts +4 -2
- package/dist/types/{notifications → sdk/bus}/lifecycle-orchestrator.d.ts +18 -21
- package/dist/types/{notifications → sdk/bus}/managed-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +13 -0
- package/dist/types/sdk/bus/notification-orchestration.d.ts +170 -0
- package/dist/types/{notifications → sdk/bus}/notification-service.d.ts +7 -1
- package/dist/types/sdk/bus/session-control.d.ts +83 -0
- package/dist/types/sdk/bus/slack-conversation.d.ts +56 -0
- package/dist/types/sdk/bus/slack-daemon.d.ts +60 -0
- package/dist/types/sdk/bus/slack-live-provider.d.ts +59 -0
- package/dist/types/sdk/bus/slack-provider.d.ts +55 -0
- package/dist/types/{notifications → sdk/bus}/telegram-cli.d.ts +4 -4
- package/dist/types/{notifications → sdk/bus}/telegram-daemon-cli.d.ts +14 -5
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -0
- package/dist/types/{notifications → sdk/bus}/telegram-daemon-control.d.ts +2 -2
- package/dist/types/{notifications → sdk/bus}/telegram-daemon.d.ts +55 -17
- package/dist/types/{notifications → sdk/bus}/telegram-reference.d.ts +2 -2
- package/dist/types/sdk/bus/telegram-setup.d.ts +128 -0
- package/dist/types/{notifications → sdk/bus}/topic-registry.d.ts +11 -0
- package/dist/types/sdk/cli/index.d.ts +1 -0
- package/dist/types/sdk/cli/session-cli.d.ts +19 -0
- package/dist/types/sdk/client/client.d.ts +1 -0
- package/dist/types/sdk/client/discovery.d.ts +27 -0
- package/dist/types/sdk/client/index.d.ts +2 -0
- package/dist/types/sdk/host/control/dispatch.d.ts +34 -0
- package/dist/types/sdk/host/control/index.d.ts +2 -0
- package/dist/types/sdk/host/control/operations.d.ts +65 -0
- package/dist/types/sdk/host/events.d.ts +35 -0
- package/dist/types/sdk/host/host.d.ts +35 -0
- package/dist/types/sdk/host/index.d.ts +4 -0
- package/dist/types/sdk/host/query/cursor.d.ts +62 -0
- package/dist/types/sdk/host/query/handlers.d.ts +70 -0
- package/dist/types/sdk/host/query/index.d.ts +3 -0
- package/dist/types/sdk/host/query/revision-store.d.ts +60 -0
- package/dist/types/sdk/host/reverse-leases.d.ts +45 -0
- package/dist/types/sdk/host/types.d.ts +32 -0
- package/dist/types/sdk/index.d.ts +6 -0
- package/dist/types/sdk/lifecycle-session.d.ts +14 -0
- package/dist/types/sdk/mcp/index.d.ts +1 -0
- package/dist/types/sdk/mcp/server.d.ts +37 -0
- package/dist/types/sdk/models.d.ts +44 -0
- package/dist/types/sdk/protocol/adapter-validation.d.ts +14 -0
- package/dist/types/sdk/protocol/operation-registry.d.ts +24 -0
- package/dist/types/{sdk.d.ts → sdk/session.d.ts} +44 -38
- package/dist/types/sdk/startup-capability.d.ts +50 -0
- package/dist/types/session/agent-session.d.ts +114 -21
- package/dist/types/session/default-model-selection.d.ts +25 -0
- package/dist/types/session/fallback-chain-controller.d.ts +52 -0
- package/dist/types/session/session-manager.d.ts +58 -1
- package/dist/types/setup/credential-auto-import.d.ts +36 -6
- package/dist/types/tools/ask-answer-registry.d.ts +1 -1
- package/dist/types/tools/browser/benchmark/detector-report.d.ts +63 -0
- package/dist/types/tools/browser/benchmark/run-suite.d.ts +18 -0
- package/dist/types/tools/browser/launch.d.ts +22 -0
- package/dist/types/tools/browser/profile-discovery.d.ts +31 -0
- package/dist/types/tools/browser/profile-posture.d.ts +37 -0
- package/dist/types/tools/browser/profile-reuse.d.ts +39 -0
- package/dist/types/tools/browser/profile-warmup.d.ts +39 -0
- package/dist/types/tools/browser/registry.d.ts +19 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
- package/dist/types/tools/index.d.ts +8 -8
- package/package.json +34 -13
- package/scripts/build-sdk-package-smoke.ts +94 -0
- package/scripts/capture-notifications-settings-showcase.ts +435 -0
- package/scripts/compile-args.ts +4 -2
- package/scripts/g005-daemon-smoke.ts +4 -5
- package/scripts/g011-daemon-path-smoke.ts +5 -5
- package/scripts/generate-sdk-adapter-parity-manifest.ts +163 -0
- package/scripts/generate-sdk-operation-inventory.ts +867 -0
- package/scripts/generate-telegram-baseline-manifest.ts +128 -0
- package/scripts/manifest-schema.ts +124 -0
- package/scripts/run-test-manifest.ts +153 -0
- package/scripts/run-threat-model-check.ts +157 -0
- package/scripts/verify-gjc-sdk-canonicalization.ts +3467 -0
- package/scripts/verify-gjc-sdk-rename.ts +90 -0
- package/src/append-only-mode.ts +1 -1
- package/src/cli/args.ts +43 -12
- package/src/cli/auth-gateway-cli.ts +2 -2
- package/src/cli/config-cli.ts +2 -1
- package/src/cli/daemon-cli.ts +63 -14
- package/src/cli/list-models.ts +1 -1
- package/src/cli/notify-cli.ts +288 -286
- package/src/cli.ts +21 -2
- package/src/commands/daemon.ts +50 -6
- package/src/commands/harness.ts +93 -36
- package/src/commands/launch.ts +5 -2
- package/src/commands/mcp-serve.ts +19 -12
- package/src/commands/notify.ts +36 -3
- package/src/commands/sdk.ts +426 -0
- package/src/commit/agentic/agent.ts +1 -0
- package/src/config/atomic-yaml-patch.ts +423 -0
- package/src/config/model-profile-activation.ts +240 -89
- package/src/config/model-profiles.ts +18 -15
- package/src/config/model-registry.ts +51 -26
- package/src/config/model-resolver.ts +138 -51
- package/src/config/model-selector-value.ts +50 -0
- package/src/config/models-config-schema.ts +14 -21
- package/src/config/settings-schema.ts +128 -10
- package/src/config/settings.ts +1120 -158
- package/src/coordinator-mcp/model-preset.ts +74 -20
- package/src/coordinator-mcp/server.ts +1597 -3052
- package/src/daemon/builtin.ts +8 -5
- package/src/daemon/control-types.ts +1 -1
- package/src/defaults/gjc/skills/team/SKILL.md +4 -4
- package/src/discovery/mcp-json.ts +327 -20
- package/src/extensibility/custom-tools/types.ts +1 -1
- package/src/extensibility/extensions/compact-handler.ts +3 -3
- package/src/extensibility/extensions/get-commands-handler.ts +2 -2
- package/src/extensibility/extensions/loader.ts +69 -5
- package/src/extensibility/extensions/runner.ts +112 -4
- package/src/extensibility/extensions/types.ts +176 -17
- package/src/extensibility/hooks/types.ts +1 -1
- package/src/gjc-runtime/launch-tmux.ts +170 -86
- package/src/gjc-runtime/psmux-detect.ts +82 -4
- package/src/gjc-runtime/session-state-sidecar.ts +41 -31
- package/src/gjc-runtime/team-runtime.ts +54 -35
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +53 -17
- package/src/gjc-runtime/tmux-owner-isolation.ts +1 -1
- package/src/gjc-runtime/tmux-sessions.ts +19 -13
- package/src/gjc-runtime/ultragoal-guard.ts +53 -6
- package/src/gjc-runtime/ultragoal-runtime.ts +155 -3
- package/src/gjc-runtime/windows-powershell-command.ts +58 -0
- package/src/harness-control-plane/operate.ts +9 -9
- package/src/harness-control-plane/owner.ts +283 -39
- package/src/harness-control-plane/receipts.ts +0 -1
- package/src/harness-control-plane/sdk-transport.ts +395 -0
- package/src/harness-control-plane/session-transport.ts +94 -0
- package/src/harness-control-plane/types.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +34 -35
- package/src/lsp/client.ts +42 -6
- package/src/lsp/index.ts +2 -2
- package/src/main.ts +299 -204
- package/src/modes/DESIGN.md +258 -0
- package/src/modes/acp/acp-agent.ts +1228 -2612
- package/src/modes/acp/acp-client-bridge.ts +2 -20
- package/src/modes/acp/acp-event-mapper.ts +18 -2
- package/src/modes/acp/acp-mode.ts +8 -36
- package/src/modes/acp/permission-mode.ts +25 -0
- package/src/modes/acp/startup-options.ts +6 -0
- package/src/modes/components/agent-dashboard.ts +37 -17
- package/src/modes/components/model-selector.ts +93 -35
- package/src/modes/components/notifications-settings-editor.ts +1379 -0
- package/src/modes/components/settings-selector.ts +60 -11
- package/src/modes/components/welcome.ts +4 -4
- package/src/modes/controllers/command-controller.ts +1 -2
- package/src/modes/controllers/event-controller.ts +11 -16
- package/src/modes/controllers/extension-ui-controller.ts +348 -4
- package/src/modes/controllers/selector-controller.ts +674 -36
- package/src/modes/index.ts +0 -22
- package/src/modes/interactive-mode.ts +59 -20
- package/src/modes/runtime-init.ts +298 -6
- package/src/modes/shared/agent-wire/approval-gate.ts +9 -11
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +10 -11
- package/src/modes/shared/agent-wire/event-contract.ts +7 -8
- package/src/modes/shared/agent-wire/event-envelope.ts +9 -17
- package/src/modes/shared/agent-wire/event-observation.ts +16 -7
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +1254 -87
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +15 -15
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +113 -0
- package/src/modes/types.ts +5 -0
- package/src/runtime-mcp/client.ts +94 -19
- package/src/runtime-mcp/config.ts +27 -15
- package/src/runtime-mcp/manager.ts +343 -105
- package/src/runtime-mcp/oauth-flow.ts +42 -12
- package/src/runtime-mcp/transports/http.ts +49 -19
- package/src/runtime-mcp/transports/stdio.ts +47 -28
- package/src/runtime-mcp/types.ts +10 -0
- package/src/sdk/acp/adapter.ts +418 -0
- package/src/sdk/acp/index.ts +2 -0
- package/src/sdk/broker/broker.ts +642 -0
- package/src/sdk/broker/discovery.ts +94 -0
- package/src/sdk/broker/ensure.ts +249 -0
- package/src/sdk/broker/identity.ts +42 -0
- package/src/sdk/broker/index.ts +8 -0
- package/src/sdk/broker/internal-source-marker-2178.txt +1 -0
- package/src/sdk/broker/internal-source.bunfig.toml +1 -0
- package/src/sdk/broker/lifecycle-ledger.ts +286 -0
- package/src/sdk/broker/lifecycle.ts +2157 -0
- package/src/sdk/broker/process-incarnation.ts +159 -0
- package/src/sdk/broker/runtime.ts +164 -0
- package/src/sdk/broker/session-index.ts +318 -0
- package/src/sdk/broker/state-version.ts +23 -0
- package/src/sdk/broker/transport.ts +125 -0
- package/src/sdk/bus/chat-command-policy.ts +107 -0
- package/src/sdk/bus/chat-daemon-cli.ts +214 -0
- package/src/sdk/bus/chat-daemon-control.ts +713 -0
- package/src/sdk/bus/chat-daemon-runtime.ts +526 -0
- package/src/sdk/bus/chat-effect-journal.ts +353 -0
- package/src/{notifications → sdk/bus}/config-commands.ts +39 -10
- package/src/sdk/bus/config.ts +433 -0
- package/src/sdk/bus/conversation-store.ts +391 -0
- package/src/sdk/bus/discord-conversation.ts +85 -0
- package/src/sdk/bus/discord-daemon.ts +2275 -0
- package/src/sdk/bus/discord-live-provider.ts +521 -0
- package/src/sdk/bus/discord-provider.ts +56 -0
- package/src/{notifications → sdk/bus}/html-format.ts +2 -2
- package/src/sdk/bus/index.ts +3943 -0
- package/src/{notifications → sdk/bus}/lifecycle-commands.ts +2 -0
- package/src/{notifications → sdk/bus}/lifecycle-control-runtime.ts +225 -196
- package/src/{notifications → sdk/bus}/lifecycle-orchestrator.ts +161 -74
- package/src/{notifications → sdk/bus}/managed-daemon.ts +2 -2
- package/src/sdk/bus/native-runtime-compatibility.ts +42 -0
- package/src/sdk/bus/natives-augment.d.ts +38 -0
- package/src/sdk/bus/notification-orchestration.ts +535 -0
- package/src/{notifications → sdk/bus}/notification-service.ts +106 -12
- package/src/sdk/bus/session-control.ts +333 -0
- package/src/sdk/bus/slack-conversation.ts +74 -0
- package/src/sdk/bus/slack-daemon.ts +2144 -0
- package/src/sdk/bus/slack-live-provider.ts +380 -0
- package/src/sdk/bus/slack-provider.ts +76 -0
- package/src/{notifications → sdk/bus}/telegram-cli.ts +8 -8
- package/src/sdk/bus/telegram-daemon-cli.ts +330 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +14 -0
- package/src/{notifications → sdk/bus}/telegram-daemon-control.ts +4 -4
- package/src/{notifications → sdk/bus}/telegram-daemon.ts +502 -54
- package/src/{notifications → sdk/bus}/telegram-reference.ts +2 -2
- package/src/sdk/bus/telegram-setup.ts +663 -0
- package/src/{notifications → sdk/bus}/topic-registry.ts +32 -0
- package/src/sdk/cli/index.ts +1 -0
- package/src/sdk/cli/session-cli.ts +275 -0
- package/src/sdk/client/client.ts +1 -0
- package/src/sdk/client/discovery.ts +108 -0
- package/src/sdk/client/index.ts +2 -0
- package/src/sdk/host/control/dispatch.ts +367 -0
- package/src/sdk/host/control/index.ts +10 -0
- package/src/sdk/host/control/operations.ts +63 -0
- package/src/sdk/host/events.ts +91 -0
- package/src/sdk/host/host.ts +296 -0
- package/src/sdk/host/index.ts +4 -0
- package/src/sdk/host/query/cursor.ts +189 -0
- package/src/sdk/host/query/handlers.ts +533 -0
- package/src/sdk/host/query/index.ts +3 -0
- package/src/sdk/host/query/revision-store.ts +1126 -0
- package/src/sdk/host/reverse-leases.ts +325 -0
- package/src/sdk/host/types.ts +29 -0
- package/src/sdk/index.ts +13 -0
- package/src/sdk/lifecycle-session.ts +39 -0
- package/src/sdk/mcp/index.ts +1 -0
- package/src/sdk/mcp/server.ts +332 -0
- package/src/sdk/models.ts +168 -0
- package/src/sdk/protocol/adapter-validation.ts +48 -0
- package/src/sdk/protocol/operation-inventory.generated.json +5654 -0
- package/src/sdk/protocol/operation-registry.ts +312 -0
- package/src/{sdk.ts → sdk/session.ts} +339 -212
- package/src/sdk/startup-capability.ts +189 -0
- package/src/session/agent-session.ts +2453 -894
- package/src/session/default-model-selection.ts +93 -0
- package/src/session/fallback-chain-controller.ts +136 -0
- package/src/session/session-manager.ts +438 -11
- package/src/session/session-storage.ts +8 -1
- package/src/setup/credential-auto-import.ts +290 -35
- package/src/setup/hermes/templates/operator-instructions.v1.md +3 -5
- package/src/slash-commands/builtin-registry.ts +2 -1
- package/src/task/executor.ts +62 -13
- package/src/tools/ask-answer-registry.ts +1 -1
- package/src/tools/ask.ts +13 -8
- package/src/tools/browser/STEALTH-THIRD-PARTY-NOTICES.md +26 -0
- package/src/tools/browser/benchmark/detector-report.ts +157 -0
- package/src/tools/browser/benchmark/run-suite.ts +39 -0
- package/src/tools/browser/launch.ts +124 -19
- package/src/tools/browser/profile-discovery.ts +63 -0
- package/src/tools/browser/profile-posture.ts +59 -0
- package/src/tools/browser/profile-reuse.ts +102 -0
- package/src/tools/browser/profile-warmup.ts +109 -0
- package/src/tools/browser/registry.ts +87 -11
- package/src/tools/browser/tab-protocol.ts +1 -0
- package/src/tools/browser/tab-supervisor.ts +1 -0
- package/src/tools/browser/tab-worker.ts +1 -1
- package/src/tools/browser.ts +6 -0
- package/src/tools/index.ts +8 -8
- package/src/tools/puppeteer/14_stealth_permissions.txt +57 -0
- package/src/tools/puppeteer/15_stealth_webrtc.txt +60 -0
- package/src/tools/telegram-send.ts +2 -2
- package/vendor/insane-search/MANIFEST.json +2 -1
- package/vendor/insane-search/engine/bias_check.py +4 -1
- package/vendor/insane-search/engine/phase0.py +26 -1
- package/dist/types/harness-control-plane/rpc-adapter.d.ts +0 -69
- package/dist/types/modes/bridge/auth.d.ts +0 -12
- package/dist/types/modes/bridge/bridge-client-bridge.d.ts +0 -9
- package/dist/types/modes/bridge/bridge-mode.d.ts +0 -47
- package/dist/types/modes/bridge/bridge-ui-context.d.ts +0 -88
- package/dist/types/modes/bridge/event-stream.d.ts +0 -8
- package/dist/types/modes/rpc/host-tools.d.ts +0 -1
- package/dist/types/modes/rpc/host-uris.d.ts +0 -1
- package/dist/types/modes/rpc/rpc-client.d.ts +0 -299
- package/dist/types/modes/rpc/rpc-mode.d.ts +0 -86
- package/dist/types/modes/rpc/rpc-socket-security.d.ts +0 -10
- package/dist/types/modes/rpc/rpc-types.d.ts +0 -810
- package/dist/types/modes/shared/agent-wire/command-contract.d.ts +0 -18
- package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +0 -37
- package/dist/types/modes/shared/agent-wire/command-validation.d.ts +0 -2
- package/dist/types/modes/shared/agent-wire/handshake.d.ts +0 -56
- package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +0 -16
- package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +0 -17
- package/dist/types/modes/shared/agent-wire/protocol.d.ts +0 -25
- package/dist/types/modes/shared/agent-wire/responses.d.ts +0 -4
- package/dist/types/modes/shared/agent-wire/scopes.d.ts +0 -18
- package/dist/types/modes/shared/agent-wire/session-registry.d.ts +0 -25
- package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +0 -42
- package/dist/types/modes/shared/agent-wire/ui-result.d.ts +0 -27
- package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +0 -29
- package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +0 -68
- package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +0 -166
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +0 -127
- package/dist/types/notifications/config.d.ts +0 -132
- package/src/harness-control-plane/rpc-adapter.ts +0 -290
- package/src/modes/bridge/auth.ts +0 -41
- package/src/modes/bridge/bridge-client-bridge.ts +0 -47
- package/src/modes/bridge/bridge-mode.ts +0 -675
- package/src/modes/bridge/bridge-ui-context.ts +0 -200
- package/src/modes/bridge/event-stream.ts +0 -70
- package/src/modes/rpc/host-tools.ts +0 -1
- package/src/modes/rpc/host-uris.ts +0 -1
- package/src/modes/rpc/rpc-client.ts +0 -1166
- package/src/modes/rpc/rpc-mode.ts +0 -1029
- package/src/modes/rpc/rpc-socket-security.ts +0 -115
- package/src/modes/rpc/rpc-types.ts +0 -637
- package/src/modes/shared/agent-wire/command-contract.ts +0 -18
- package/src/modes/shared/agent-wire/command-dispatch.ts +0 -498
- package/src/modes/shared/agent-wire/command-validation.ts +0 -181
- package/src/modes/shared/agent-wire/handshake.ts +0 -157
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +0 -197
- package/src/modes/shared/agent-wire/host-uri-bridge.ts +0 -236
- package/src/modes/shared/agent-wire/protocol.ts +0 -46
- package/src/modes/shared/agent-wire/responses.ts +0 -17
- package/src/modes/shared/agent-wire/scopes.ts +0 -94
- package/src/modes/shared/agent-wire/session-registry.ts +0 -109
- package/src/modes/shared/agent-wire/ui-request-broker.ts +0 -150
- package/src/modes/shared/agent-wire/ui-result.ts +0 -48
- package/src/modes/shared/agent-wire/unattended-action-policy.ts +0 -365
- package/src/modes/shared/agent-wire/unattended-audit.ts +0 -176
- package/src/modes/shared/agent-wire/unattended-run-controller.ts +0 -426
- package/src/modes/shared/agent-wire/unattended-session.ts +0 -528
- package/src/notifications/config.ts +0 -219
- package/src/notifications/index.ts +0 -1835
- package/src/notifications/telegram-daemon-cli.ts +0 -243
- package/src/tools/puppeteer/09_stealth_locale.txt +0 -46
- /package/dist/types/{notifications → sdk/bus}/attachment-registry.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/chat-adapters.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/daemon-paths.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/engine.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/helpers.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/lifecycle-commands.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/operator-runtime.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/rate-limit-pool.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/recent-activity.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/reply-sent-store.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/rich-draft.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/rich-render.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/threaded-inbound.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/threaded-render.d.ts +0 -0
- /package/src/{notifications → sdk/bus}/attachment-registry.ts +0 -0
- /package/src/{notifications → sdk/bus}/chat-adapters.ts +0 -0
- /package/src/{notifications → sdk/bus}/daemon-paths.ts +0 -0
- /package/src/{notifications → sdk/bus}/engine.ts +0 -0
- /package/src/{notifications → sdk/bus}/helpers.ts +0 -0
- /package/src/{notifications → sdk/bus}/operator-runtime.ts +0 -0
- /package/src/{notifications → sdk/bus}/rate-limit-pool.ts +0 -0
- /package/src/{notifications → sdk/bus}/recent-activity.ts +0 -0
- /package/src/{notifications → sdk/bus}/reply-sent-store.ts +0 -0
- /package/src/{notifications → sdk/bus}/rich-draft.ts +0 -0
- /package/src/{notifications → sdk/bus}/rich-render.ts +0 -0
- /package/src/{notifications → sdk/bus}/threaded-inbound.ts +0 -0
- /package/src/{notifications → sdk/bus}/threaded-render.ts +0 -0
|
@@ -0,0 +1,3943 @@
|
|
|
1
|
+
/// <reference path="./natives-augment.d.ts" />
|
|
2
|
+
/**
|
|
3
|
+
* Notifications extension.
|
|
4
|
+
*
|
|
5
|
+
* Hosts a per-session loopback WebSocket notification server (the Rust core via
|
|
6
|
+
* N-API) and bridges GJC session events + the `ask` tool to it so a remote client
|
|
7
|
+
* (e.g. a Telegram bot) can both see action-needed signals and answer them
|
|
8
|
+
* through SDK-native session capabilities:
|
|
9
|
+
*
|
|
10
|
+
* - `ask` (interactive): registers an {@link AskAnswerSource}; the ask tool races
|
|
11
|
+
* the local UI against a remote reply. First valid answer wins; a local answer
|
|
12
|
+
* aborts the remote wait (and broadcasts `action_resolved` resolvedBy=local).
|
|
13
|
+
* - `ask` (workflow gate): observes emitted workflow gates and resolves the real
|
|
14
|
+
* gate on a remote reply via `ctx.workflowGate`.
|
|
15
|
+
* - `turn_end` -> `action_needed` (kind `idle`, deduped per turn).
|
|
16
|
+
* - `session_shutdown` -> `session_closed` frame, stop server, deregister answer source.
|
|
17
|
+
*
|
|
18
|
+
* Enable with Settings notifications config, `GJC_NOTIFICATIONS=1` (a token is
|
|
19
|
+
* generated), or `GJC_NOTIFICATIONS_TOKEN`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
23
|
+
|
|
24
|
+
import { execFile } from "node:child_process";
|
|
25
|
+
import * as crypto from "node:crypto";
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as os from "node:os";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
import { promisify } from "node:util";
|
|
30
|
+
import { ThinkingLevel } from "@gajae-code/agent-core";
|
|
31
|
+
import type { ImageContent, TextContent } from "@gajae-code/ai";
|
|
32
|
+
import { NotificationServer, nativeBuildInfo } from "@gajae-code/natives";
|
|
33
|
+
import { logger, postmortem, VERSION } from "@gajae-code/utils";
|
|
34
|
+
import { Settings } from "../../config/settings";
|
|
35
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
|
|
36
|
+
import type {
|
|
37
|
+
WorkflowGateEmitter,
|
|
38
|
+
WorkflowGateTerminalController,
|
|
39
|
+
WorkflowGateTerminalProof,
|
|
40
|
+
} from "../../modes/shared/agent-wire/workflow-gate-broker";
|
|
41
|
+
import { parseThinkingLevel } from "../../thinking";
|
|
42
|
+
import type {
|
|
43
|
+
AskAnswerRequest,
|
|
44
|
+
AskAnswerSourceResult,
|
|
45
|
+
AskRemoteControl,
|
|
46
|
+
AskRemoteInteraction,
|
|
47
|
+
AskRemoteReceipt,
|
|
48
|
+
AskSelectedAckOutcome,
|
|
49
|
+
AskSettlement,
|
|
50
|
+
AskSettlementResult,
|
|
51
|
+
} from "../../tools";
|
|
52
|
+
import { registerAskAnswerSource, registerWorkflowGateEmitterListener } from "../../tools/ask-answer-registry";
|
|
53
|
+
import { ensureBroker } from "../broker/ensure";
|
|
54
|
+
import { SessionIndex } from "../broker/session-index";
|
|
55
|
+
import { SessionSdkHost, shouldHostSdk } from "../host";
|
|
56
|
+
import { type ControlSurface, dispatchControl } from "../host/control";
|
|
57
|
+
import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "../host/query";
|
|
58
|
+
import { projectQ10Models } from "../models.js";
|
|
59
|
+
import { OPERATIONS } from "../protocol/operation-registry";
|
|
60
|
+
import {
|
|
61
|
+
lifecycleStartupCapabilityForApi,
|
|
62
|
+
normalizeSdkStartupFailure,
|
|
63
|
+
type SdkStartupFailure,
|
|
64
|
+
} from "../startup-capability";
|
|
65
|
+
|
|
66
|
+
import { registerTelegramFileSink } from "./attachment-registry";
|
|
67
|
+
import { ensureDiscordDaemon, ensureSlackDaemon } from "./chat-daemon-control";
|
|
68
|
+
import {
|
|
69
|
+
getNotificationConfig,
|
|
70
|
+
isDiscordConfigured,
|
|
71
|
+
isSessionNotificationsEnabled,
|
|
72
|
+
isSlackConfigured,
|
|
73
|
+
isTelegramConfigured,
|
|
74
|
+
type NotificationConfig,
|
|
75
|
+
type NotificationSettingsReader,
|
|
76
|
+
sessionTag,
|
|
77
|
+
} from "./config";
|
|
78
|
+
import { telegramControlCommandUsage } from "./config-commands";
|
|
79
|
+
import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage } from "./helpers";
|
|
80
|
+
import { assertNativeRuntimeCompatibility } from "./native-runtime-compatibility";
|
|
81
|
+
import { NotificationSessionController, type NotificationSessionRuntime } from "./session-control";
|
|
82
|
+
import { type EnsureDaemonResult, ensureTelegramDaemonRunningDetailed } from "./telegram-daemon";
|
|
83
|
+
|
|
84
|
+
// ===========================================================================
|
|
85
|
+
// Session lifecycle control protocol (TypeScript mirror of the Rust wire
|
|
86
|
+
// contract in `crates/gjc-sdk/src/lifecycle.rs`).
|
|
87
|
+
//
|
|
88
|
+
// These describe the frames exchanged over the daemon-owned, session-independent
|
|
89
|
+
// control endpoint for remote session create / close / resume. Field names are
|
|
90
|
+
// camelCase on the wire; `type`/`kind` discriminators are snake_case. The Rust
|
|
91
|
+
// ingress authenticates and forwards; the daemon (TypeScript) owns all policy,
|
|
92
|
+
// spawn orchestration, idempotency, rate limiting, audit, and UX.
|
|
93
|
+
// ===========================================================================
|
|
94
|
+
|
|
95
|
+
/** Where a `session_create` should run. Discriminated by `kind`. */
|
|
96
|
+
export type SessionCreateTarget =
|
|
97
|
+
| { kind: "existing_path"; path: string }
|
|
98
|
+
| { kind: "worktree"; repo: string; branch: string }
|
|
99
|
+
| { kind: "plain_dir"; path: string };
|
|
100
|
+
|
|
101
|
+
/** Identifies the session a `session_close` targets. */
|
|
102
|
+
export interface SessionCloseTarget {
|
|
103
|
+
sessionId: string;
|
|
104
|
+
/** Expected GJC-managed tmux session name (defense-in-depth match). */
|
|
105
|
+
tmuxSession?: string;
|
|
106
|
+
/** Expected `@gjc-session-state-file` tag (defense-in-depth match). */
|
|
107
|
+
sessionStateFile?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Identifies the session a `session_resume` targets. */
|
|
111
|
+
export interface SessionResumeTarget {
|
|
112
|
+
sessionIdOrPrefix: string;
|
|
113
|
+
/** Optional repo/working-dir hint to disambiguate matches. */
|
|
114
|
+
path?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Create a new session. */
|
|
118
|
+
export interface SessionCreateFrame {
|
|
119
|
+
type: "session_create";
|
|
120
|
+
requestId: string;
|
|
121
|
+
/** Deterministic lifecycle marker preallocated by the daemon before spawn. */
|
|
122
|
+
lifecycleRequestId: string;
|
|
123
|
+
/** Session id the daemon preallocated and propagates to the child. */
|
|
124
|
+
intendedSessionId: string;
|
|
125
|
+
/** Telegram update id (idempotency key on the daemon side). */
|
|
126
|
+
updateId: number;
|
|
127
|
+
chatId: string;
|
|
128
|
+
/** Control-endpoint token authorizing this frame. */
|
|
129
|
+
token: string;
|
|
130
|
+
target: SessionCreateTarget;
|
|
131
|
+
/** Reserved for a future capability transport; any supplied value is rejected before lifecycle acceptance. */
|
|
132
|
+
startupPromptRef?: string;
|
|
133
|
+
/** Model profile preset to activate for the spawned session (--mpreset). */
|
|
134
|
+
modelPreset?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Close (hard-kill, history preserved) a session. */
|
|
138
|
+
export interface SessionCloseFrame {
|
|
139
|
+
type: "session_close";
|
|
140
|
+
requestId: string;
|
|
141
|
+
updateId: number;
|
|
142
|
+
chatId: string;
|
|
143
|
+
token: string;
|
|
144
|
+
target: SessionCloseTarget;
|
|
145
|
+
/** Required force-only close flag; false/omitted is rejected by daemon policy. */
|
|
146
|
+
force?: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Resume a session (reattach if alive, else cold-restart from history). */
|
|
150
|
+
export interface SessionResumeFrame {
|
|
151
|
+
type: "session_resume";
|
|
152
|
+
requestId: string;
|
|
153
|
+
updateId: number;
|
|
154
|
+
chatId: string;
|
|
155
|
+
token: string;
|
|
156
|
+
target: SessionResumeTarget;
|
|
157
|
+
/** Reserved for a future capability transport; any supplied value is rejected before lifecycle acceptance. */
|
|
158
|
+
startupPromptRef?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Any client -> ingress lifecycle request frame. */
|
|
162
|
+
export type SessionLifecycleRequest = SessionCreateFrame | SessionCloseFrame | SessionResumeFrame;
|
|
163
|
+
|
|
164
|
+
/** Terminal status of a lifecycle request. */
|
|
165
|
+
export type LifecycleStatus = "ok" | "error";
|
|
166
|
+
|
|
167
|
+
/** A connected session's per-session endpoint, returned to the control client. */
|
|
168
|
+
export interface LifecycleEndpoint {
|
|
169
|
+
url: string;
|
|
170
|
+
token: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The Telegram topic/thread a session is surfaced in. */
|
|
174
|
+
export interface LifecycleTopic {
|
|
175
|
+
chatId: string;
|
|
176
|
+
threadId: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** How a create request was correlated to its spawned session. */
|
|
180
|
+
export type MatchedBy = "spawn_marker" | "session_ready";
|
|
181
|
+
|
|
182
|
+
/** Response to a successful `session_create`. */
|
|
183
|
+
export interface SessionCreateResponseFrame {
|
|
184
|
+
type: "session_create_response";
|
|
185
|
+
requestId: string;
|
|
186
|
+
status: LifecycleStatus;
|
|
187
|
+
lifecycleRequestId: string;
|
|
188
|
+
sessionId: string;
|
|
189
|
+
matchedBy: MatchedBy;
|
|
190
|
+
endpoint: LifecycleEndpoint;
|
|
191
|
+
topic: LifecycleTopic;
|
|
192
|
+
target: SessionCreateTarget;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Response to a successful `session_close`. */
|
|
196
|
+
export interface SessionCloseResponseFrame {
|
|
197
|
+
type: "session_close_response";
|
|
198
|
+
requestId: string;
|
|
199
|
+
status: LifecycleStatus;
|
|
200
|
+
sessionId: string;
|
|
201
|
+
processGone: boolean;
|
|
202
|
+
historyPreserved: boolean;
|
|
203
|
+
endpointStale: boolean;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Whether a resume reattached to a live session or cold-restarted a dead one. */
|
|
207
|
+
export type ResumeMode = "reattached" | "cold_restarted";
|
|
208
|
+
|
|
209
|
+
/** Response to a successful `session_resume`. */
|
|
210
|
+
export interface SessionResumeResponseFrame {
|
|
211
|
+
type: "session_resume_response";
|
|
212
|
+
requestId: string;
|
|
213
|
+
status: LifecycleStatus;
|
|
214
|
+
sessionId: string;
|
|
215
|
+
mode: ResumeMode;
|
|
216
|
+
endpoint: LifecycleEndpoint;
|
|
217
|
+
topic: LifecycleTopic;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Machine-readable reason a lifecycle request failed. */
|
|
221
|
+
export type LifecycleErrorReason =
|
|
222
|
+
| "unauthorized"
|
|
223
|
+
| "rate_limited"
|
|
224
|
+
| "duplicate_conflict"
|
|
225
|
+
| "invalid_target"
|
|
226
|
+
| "ambiguous_target"
|
|
227
|
+
| "spawn_failed"
|
|
228
|
+
| "discovery_timeout"
|
|
229
|
+
| "readiness_timeout"
|
|
230
|
+
| "close_refused"
|
|
231
|
+
| "not_found"
|
|
232
|
+
| "terminal_uncertain"
|
|
233
|
+
| "unsupported_platform";
|
|
234
|
+
|
|
235
|
+
/** A candidate returned with an `ambiguous_target` resume error. */
|
|
236
|
+
export interface ResumeCandidate {
|
|
237
|
+
sessionId: string;
|
|
238
|
+
path?: string;
|
|
239
|
+
/** Last-activity epoch-millis (session history file mtime), if known. */
|
|
240
|
+
mtimeMs?: number;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** A structured lifecycle error frame. */
|
|
244
|
+
export interface SessionLifecycleErrorFrame {
|
|
245
|
+
type: "session_lifecycle_error";
|
|
246
|
+
requestId: string;
|
|
247
|
+
status: LifecycleStatus;
|
|
248
|
+
reason: LifecycleErrorReason;
|
|
249
|
+
message: string;
|
|
250
|
+
candidates?: ResumeCandidate[];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Any ingress -> client lifecycle response frame. */
|
|
254
|
+
export type SessionLifecycleResponse =
|
|
255
|
+
| SessionCreateResponseFrame
|
|
256
|
+
| SessionCloseResponseFrame
|
|
257
|
+
| SessionResumeResponseFrame
|
|
258
|
+
| SessionLifecycleErrorFrame;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Replayable per-session readiness signal (mirror of the Rust `session_ready`
|
|
262
|
+
* frame). Buffered and replayed to late clients so WS-open alone never implies
|
|
263
|
+
* the session is live and surfaced.
|
|
264
|
+
*/
|
|
265
|
+
export interface SessionReadyFrame {
|
|
266
|
+
type: "session_ready";
|
|
267
|
+
sessionId: string;
|
|
268
|
+
lifecycleRequestId?: string;
|
|
269
|
+
startupPromptRef?: string;
|
|
270
|
+
repo?: string;
|
|
271
|
+
branch?: string;
|
|
272
|
+
title?: string;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Resolve the git dir for `cwd`, handling worktrees where `.git` is a file. */
|
|
276
|
+
function gitDir(cwd: string): string | undefined {
|
|
277
|
+
const dot = path.join(cwd, ".git");
|
|
278
|
+
try {
|
|
279
|
+
if (fs.statSync(dot).isDirectory()) return dot;
|
|
280
|
+
const m = fs
|
|
281
|
+
.readFileSync(dot, "utf8")
|
|
282
|
+
.trim()
|
|
283
|
+
.match(/^gitdir:\s*(.+)$/);
|
|
284
|
+
if (m) return path.resolve(cwd, m[1]);
|
|
285
|
+
} catch {}
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Best-effort current branch from `.git/HEAD` (no git spawn). */
|
|
290
|
+
function readGitBranch(cwd: string): string | undefined {
|
|
291
|
+
const gd = gitDir(cwd);
|
|
292
|
+
if (!gd) return undefined;
|
|
293
|
+
try {
|
|
294
|
+
const head = fs.readFileSync(path.join(gd, "HEAD"), "utf8").trim();
|
|
295
|
+
const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
|
|
296
|
+
return m ? m[1] : head.slice(0, 12);
|
|
297
|
+
} catch {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Resolve the shared git dir (the main repo's `.git`) for a possibly-linked worktree. */
|
|
303
|
+
function gitCommonDir(gd: string): string {
|
|
304
|
+
try {
|
|
305
|
+
const raw = fs.readFileSync(path.join(gd, "commondir"), "utf8").trim();
|
|
306
|
+
if (raw) return path.resolve(gd, raw);
|
|
307
|
+
} catch {}
|
|
308
|
+
return gd;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Best-effort real repository name (no git spawn): resolves the main worktree
|
|
313
|
+
* root directory so linked worktrees report the repo (e.g. `gajae-code`)
|
|
314
|
+
* instead of the worktree directory (e.g. `feat-foo-01047f11`).
|
|
315
|
+
*/
|
|
316
|
+
export function readGitRepoName(cwd: string): string | undefined {
|
|
317
|
+
const gd = gitDir(cwd);
|
|
318
|
+
if (!gd) return undefined;
|
|
319
|
+
const commonDir = gitCommonDir(gd);
|
|
320
|
+
// Strip the trailing `.git` to land on the main worktree root directory.
|
|
321
|
+
const repoRoot = path.basename(commonDir) === ".git" ? path.dirname(commonDir) : commonDir;
|
|
322
|
+
const name = path.basename(repoRoot);
|
|
323
|
+
return name && name !== ".git" ? name : undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Build the one-time identity header fields for a session thread. */
|
|
327
|
+
function buildIdentity(
|
|
328
|
+
cwd: string,
|
|
329
|
+
sessionName?: string,
|
|
330
|
+
): {
|
|
331
|
+
repo: string;
|
|
332
|
+
branch: string;
|
|
333
|
+
machine: string;
|
|
334
|
+
title?: string;
|
|
335
|
+
} {
|
|
336
|
+
const repo = readGitRepoName(cwd) ?? (path.basename(cwd) || cwd);
|
|
337
|
+
const branch = readGitBranch(cwd) ?? "(detached)";
|
|
338
|
+
// Send repo/branch and the raw session title separately; the consumer
|
|
339
|
+
// composes the topic name ("{repo}/{branch}" before the session title is
|
|
340
|
+
// auto-generated, then "{repo}/{branch} - {session title}" once it exists).
|
|
341
|
+
return { repo, branch, machine: os.hostname(), title: sessionName };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Compact cwd label for remote session identity; never emits the full host path by default. */
|
|
345
|
+
function compactCwd(cwd: string): string | undefined {
|
|
346
|
+
const home = os.homedir();
|
|
347
|
+
const resolved = path.resolve(cwd);
|
|
348
|
+
if (resolved === home) return "~";
|
|
349
|
+
const base = path.basename(resolved);
|
|
350
|
+
return base || path.parse(resolved).root || undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const execFileAsync = promisify(execFile);
|
|
354
|
+
|
|
355
|
+
/** Best-effort working-tree diff stat for the context update (no throw). */
|
|
356
|
+
async function readGitDiffStat(cwd: string): Promise<string | undefined> {
|
|
357
|
+
try {
|
|
358
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, "diff", "--stat", "--no-color"], {
|
|
359
|
+
timeout: 3000,
|
|
360
|
+
maxBuffer: 256 * 1024,
|
|
361
|
+
});
|
|
362
|
+
const trimmed = stdout.trim();
|
|
363
|
+
return trimmed ? trimmed.slice(0, 1500) : undefined;
|
|
364
|
+
} catch {
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
class DiffQueryError extends Error {
|
|
370
|
+
constructor(
|
|
371
|
+
readonly code: "not_git_repository" | "diff_too_large",
|
|
372
|
+
message: string,
|
|
373
|
+
) {
|
|
374
|
+
super(message);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
interface PendingInteractiveAsk {
|
|
379
|
+
resolve: (result: AskAnswerSourceResult) => void;
|
|
380
|
+
options: string[];
|
|
381
|
+
controls: readonly AskRemoteControl[];
|
|
382
|
+
actionId?: string;
|
|
383
|
+
retireForDirectControl: () => RetireStatus;
|
|
384
|
+
reissue: () => boolean;
|
|
385
|
+
complete: (actionId: string) => void;
|
|
386
|
+
completeDirect: () => void;
|
|
387
|
+
fail: (actionId: string) => void;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
interface UnattendedGatePresentation {
|
|
391
|
+
gateId: string;
|
|
392
|
+
sessionId: string;
|
|
393
|
+
question: string;
|
|
394
|
+
options: string[];
|
|
395
|
+
controls: readonly AskRemoteControl[];
|
|
396
|
+
multi: boolean;
|
|
397
|
+
allowEmpty: boolean;
|
|
398
|
+
navigationLabel?: "Next" | "Done";
|
|
399
|
+
selectedOptions: string[];
|
|
400
|
+
workflowGateId?: string;
|
|
401
|
+
onActivated?: (actionId: string, lease: { actionId: string; registrationEpoch: number }) => void;
|
|
402
|
+
onClosed?: () => void;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
type RetireStatus = "retired" | "already_terminal" | "claimed" | "stale";
|
|
406
|
+
type DirectControlOutcome = "accepted" | "rejected" | "unknown";
|
|
407
|
+
|
|
408
|
+
function parseRetireStatus(status: string): RetireStatus {
|
|
409
|
+
if (status === "retired" || status === "already_terminal" || status === "claimed" || status === "stale")
|
|
410
|
+
return status;
|
|
411
|
+
throw new Error(`Unexpected native retirement status: ${status}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function isTerminalProof(status: RetireStatus): status is "retired" | "already_terminal" {
|
|
415
|
+
return status === "retired" || status === "already_terminal";
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export class PresentationArbiter {
|
|
419
|
+
private readonly presentations = new Map<string, UnattendedGatePresentation>();
|
|
420
|
+
private readonly routes = new Map<string, string>();
|
|
421
|
+
private active: { actionId: string; gateId: string; registrationEpoch: number } | undefined;
|
|
422
|
+
private readonly queue: string[] = [];
|
|
423
|
+
private readonly retries = new Map<string, { attempts: number; exhausted: boolean; nextAt: number }>();
|
|
424
|
+
private readonly retiredProofs = new Map<string, WorkflowGateTerminalProof>();
|
|
425
|
+
private readonly directControls = new Map<string, number>();
|
|
426
|
+
/** Explicit terminal proof for a direct control fenced before native publication. */
|
|
427
|
+
private readonly queuedDirectControls = new Set<string>();
|
|
428
|
+
private retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
429
|
+
private retryTimerGateId: string | undefined;
|
|
430
|
+
private retryTimerGeneration = 0;
|
|
431
|
+
private readonly terminalCancellationTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
432
|
+
|
|
433
|
+
private headGeneration = 0;
|
|
434
|
+
private observedHead: string | undefined;
|
|
435
|
+
static readonly maxRegistrationAttempts = 3;
|
|
436
|
+
static readonly retryBaseDelayMs = 50;
|
|
437
|
+
static readonly retryMaxDelayMs = 1_000;
|
|
438
|
+
/** Bound an unavailable interactive answer source without discarding its head silently. */
|
|
439
|
+
static readonly terminalCancellationDelayMs = 250;
|
|
440
|
+
|
|
441
|
+
#observeHead(): number {
|
|
442
|
+
const head = this.queue[0];
|
|
443
|
+
if (head !== this.observedHead) {
|
|
444
|
+
this.observedHead = head;
|
|
445
|
+
this.headGeneration++;
|
|
446
|
+
if (this.retryTimer) {
|
|
447
|
+
clearTimeout(this.retryTimer);
|
|
448
|
+
this.retryTimer = undefined;
|
|
449
|
+
this.retryTimerGateId = undefined;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return this.headGeneration;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
#clearTerminalCancellation(gateId: string): void {
|
|
456
|
+
const timer = this.terminalCancellationTimers.get(gateId);
|
|
457
|
+
if (timer) clearTimeout(timer);
|
|
458
|
+
this.terminalCancellationTimers.delete(gateId);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
#scheduleTerminalCancellation(gateId: string): void {
|
|
462
|
+
const presentation = this.presentations.get(gateId);
|
|
463
|
+
if (presentation?.workflowGateId || this.terminalCancellationTimers.has(gateId)) return;
|
|
464
|
+
this.terminalCancellationTimers.set(
|
|
465
|
+
gateId,
|
|
466
|
+
setTimeout(() => {
|
|
467
|
+
this.terminalCancellationTimers.delete(gateId);
|
|
468
|
+
if (this.retries.get(gateId)?.exhausted && this.queue[0] === gateId) {
|
|
469
|
+
logger.warn("interactive_presentation_terminally_cancelled", { gateId });
|
|
470
|
+
this.cancel(gateId, "registration_exhausted");
|
|
471
|
+
}
|
|
472
|
+
}, PresentationArbiter.terminalCancellationDelayMs),
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
#promote(): void {
|
|
477
|
+
this.#observeHead();
|
|
478
|
+
const gateId = this.queue[0];
|
|
479
|
+
const retry = gateId ? this.retries.get(gateId) : undefined;
|
|
480
|
+
if (!this.active && gateId && !this.directControls.has(gateId) && !retry?.exhausted) {
|
|
481
|
+
if (retry && retry.nextAt > Date.now()) this.#scheduleRetry(gateId);
|
|
482
|
+
else this.reissue(gateId);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
#scheduleRetry(gateId: string): void {
|
|
487
|
+
const retry = this.retries.get(gateId);
|
|
488
|
+
if (!retry || retry.exhausted || this.queue[0] !== gateId) return;
|
|
489
|
+
const generation = this.#observeHead();
|
|
490
|
+
if (this.retryTimer && this.retryTimerGateId === gateId && this.retryTimerGeneration === generation) return;
|
|
491
|
+
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
492
|
+
this.retryTimerGateId = gateId;
|
|
493
|
+
this.retryTimerGeneration = generation;
|
|
494
|
+
const delay = Math.max(0, retry.nextAt - Date.now());
|
|
495
|
+
this.retryTimer = setTimeout(() => {
|
|
496
|
+
this.retryTimer = undefined;
|
|
497
|
+
const matchesHead = this.queue[0] === gateId && this.#observeHead() === generation;
|
|
498
|
+
this.retryTimerGateId = undefined;
|
|
499
|
+
if (matchesHead) this.reconcile();
|
|
500
|
+
}, delay);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Revalidates the live endpoint queue head before bounded recovery. */
|
|
504
|
+
reconcile(): void {
|
|
505
|
+
this.#observeHead();
|
|
506
|
+
const gateId = this.queue[0];
|
|
507
|
+
const retry = gateId ? this.retries.get(gateId) : undefined;
|
|
508
|
+
if (
|
|
509
|
+
!gateId ||
|
|
510
|
+
!this.presentations.has(gateId) ||
|
|
511
|
+
this.active ||
|
|
512
|
+
this.directControls.has(gateId) ||
|
|
513
|
+
retry?.exhausted
|
|
514
|
+
)
|
|
515
|
+
return;
|
|
516
|
+
if (retry && retry.nextAt > Date.now()) {
|
|
517
|
+
this.#scheduleRetry(gateId);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
this.#promote();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Explicit production recovery for a previously exhausted endpoint queue head. */
|
|
524
|
+
recover(gateId = this.queue[0]): void {
|
|
525
|
+
if (!gateId || this.queue[0] !== gateId || !this.presentations.has(gateId)) return;
|
|
526
|
+
this.retries.delete(gateId);
|
|
527
|
+
this.#clearTerminalCancellation(gateId);
|
|
528
|
+
|
|
529
|
+
this.#observeHead();
|
|
530
|
+
this.reconcile();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
hasActivePresentation(): boolean {
|
|
534
|
+
return this.active !== undefined;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
retireForDirectControl(gateId: string): RetireStatus {
|
|
538
|
+
if (!this.active || this.active.gateId !== gateId) return "stale";
|
|
539
|
+
const active = this.active;
|
|
540
|
+
this.directControls.set(gateId, this.queue.indexOf(gateId));
|
|
541
|
+
const status = parseRetireStatus(this.server.retireIfUnclaimed(active).status);
|
|
542
|
+
if (isTerminalProof(status)) {
|
|
543
|
+
this.routes.delete(active.actionId);
|
|
544
|
+
this.active = undefined;
|
|
545
|
+
this.retiredProofs.set(gateId, status);
|
|
546
|
+
return status;
|
|
547
|
+
}
|
|
548
|
+
this.directControls.delete(gateId);
|
|
549
|
+
return status;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
prepareDirectControl(
|
|
553
|
+
gateId: string,
|
|
554
|
+
): { status: "retired" | "queued"; ordinal: number } | { status: "claimed" | "stale" } {
|
|
555
|
+
const ordinal = this.queue.indexOf(gateId);
|
|
556
|
+
if (this.active?.gateId === gateId) {
|
|
557
|
+
const status = this.retireForDirectControl(gateId);
|
|
558
|
+
return status === "retired"
|
|
559
|
+
? { status, ordinal }
|
|
560
|
+
: { status: status === "already_terminal" ? "stale" : status };
|
|
561
|
+
}
|
|
562
|
+
if (!this.presentations.has(gateId) || ordinal < 0 || this.directControls.has(gateId)) return { status: "stale" };
|
|
563
|
+
// Fence the queued entry before awaiting durable resolution; promotion cannot
|
|
564
|
+
// republish it until the control has a known terminal outcome.
|
|
565
|
+
this.directControls.set(gateId, ordinal);
|
|
566
|
+
this.queuedDirectControls.add(gateId);
|
|
567
|
+
return { status: "queued", ordinal };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
finishDirectControl(
|
|
571
|
+
gateId: string,
|
|
572
|
+
prepared: { status: "retired" | "queued"; ordinal: number },
|
|
573
|
+
outcome: DirectControlOutcome,
|
|
574
|
+
): void {
|
|
575
|
+
if (outcome === "accepted") {
|
|
576
|
+
this.directControls.delete(gateId);
|
|
577
|
+
this.complete(gateId);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (outcome === "unknown") {
|
|
581
|
+
// A durable/store/advance failure may have committed. Remove local authority
|
|
582
|
+
// rather than minting a fresh action against an uncertain durable state.
|
|
583
|
+
this.directControls.delete(gateId);
|
|
584
|
+
this.complete(gateId);
|
|
585
|
+
logger.warn("workflow_gate_direct_control_uncertain", { gateId });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
this.directControls.delete(gateId);
|
|
589
|
+
this.queuedDirectControls.delete(gateId);
|
|
590
|
+
this.retiredProofs.delete(gateId);
|
|
591
|
+
if (!this.presentations.has(gateId)) return;
|
|
592
|
+
const current = this.queue.indexOf(gateId);
|
|
593
|
+
if (current >= 0) this.queue.splice(current, 1);
|
|
594
|
+
this.queue.splice(Math.min(prepared.ordinal, this.queue.length), 0, gateId);
|
|
595
|
+
this.reconcile();
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
constructor(
|
|
599
|
+
private readonly server: NotificationServer,
|
|
600
|
+
private readonly redact: () => boolean,
|
|
601
|
+
private readonly tag: string,
|
|
602
|
+
) {}
|
|
603
|
+
|
|
604
|
+
retain(presentation: UnattendedGatePresentation): void {
|
|
605
|
+
const alreadyPresent = this.presentations.has(presentation.gateId);
|
|
606
|
+
if (!alreadyPresent) this.queue.push(presentation.gateId);
|
|
607
|
+
this.presentations.set(presentation.gateId, presentation);
|
|
608
|
+
// A fresh durable replay is explicit production recovery after transient N-API exhaustion.
|
|
609
|
+
if (alreadyPresent) this.recover(presentation.gateId);
|
|
610
|
+
else this.#promote();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
routeFor(actionId: string): string | undefined {
|
|
614
|
+
return this.routes.get(actionId);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
presentationFor(actionId: string): UnattendedGatePresentation | undefined {
|
|
618
|
+
const gateId = this.routes.get(actionId);
|
|
619
|
+
return gateId ? this.presentations.get(gateId) : undefined;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** The native generic claim has already resolved this old action. */
|
|
623
|
+
toggle(actionId: string, label: string): boolean {
|
|
624
|
+
const presentation = this.presentationFor(actionId);
|
|
625
|
+
if (!presentation?.multi || !presentation.options.includes(label)) return false;
|
|
626
|
+
this.routes.delete(actionId);
|
|
627
|
+
if (this.active?.actionId === actionId) this.active = undefined;
|
|
628
|
+
const selected = new Set(presentation.selectedOptions);
|
|
629
|
+
if (selected.has(label)) selected.delete(label);
|
|
630
|
+
else selected.add(label);
|
|
631
|
+
presentation.selectedOptions = [...selected];
|
|
632
|
+
this.reissue(presentation.gateId);
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Clears an interactive route only when it is still the route that settled. */
|
|
637
|
+
completeInteractive(gateId: string, actionId: string): void {
|
|
638
|
+
if (this.routes.get(actionId) !== gateId) return;
|
|
639
|
+
this.routes.delete(actionId);
|
|
640
|
+
if (this.active?.actionId === actionId) this.active = undefined;
|
|
641
|
+
for (const routeGateId of this.routes.values()) {
|
|
642
|
+
if (routeGateId === gateId) return;
|
|
643
|
+
}
|
|
644
|
+
const presentation = this.presentations.get(gateId);
|
|
645
|
+
if (!presentation) return;
|
|
646
|
+
this.presentations.delete(gateId);
|
|
647
|
+
this.directControls.delete(gateId);
|
|
648
|
+
this.queuedDirectControls.delete(gateId);
|
|
649
|
+
this.retiredProofs.delete(gateId);
|
|
650
|
+
this.retries.delete(gateId);
|
|
651
|
+
this.#clearTerminalCancellation(gateId);
|
|
652
|
+
|
|
653
|
+
const index = this.queue.indexOf(gateId);
|
|
654
|
+
if (index >= 0) this.queue.splice(index, 1);
|
|
655
|
+
presentation.onClosed?.();
|
|
656
|
+
this.#promote();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Clears an interactive presentation after its route was retired for direct control. */
|
|
660
|
+
completeDirect(gateId: string): void {
|
|
661
|
+
const presentation = this.presentations.get(gateId);
|
|
662
|
+
if (!presentation) return;
|
|
663
|
+
this.presentations.delete(gateId);
|
|
664
|
+
this.directControls.delete(gateId);
|
|
665
|
+
this.queuedDirectControls.delete(gateId);
|
|
666
|
+
this.retiredProofs.delete(gateId);
|
|
667
|
+
this.retries.delete(gateId);
|
|
668
|
+
this.#clearTerminalCancellation(gateId);
|
|
669
|
+
|
|
670
|
+
const index = this.queue.indexOf(gateId);
|
|
671
|
+
if (index >= 0) this.queue.splice(index, 1);
|
|
672
|
+
presentation.onClosed?.();
|
|
673
|
+
this.#promote();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** Cancelling the source revokes every interactive route for its presentation. */
|
|
677
|
+
#discardInteractive(gateId: string): void {
|
|
678
|
+
const active = this.active;
|
|
679
|
+
if (active?.gateId === gateId) {
|
|
680
|
+
try {
|
|
681
|
+
this.server.retireIfUnclaimed(active);
|
|
682
|
+
} catch (error) {
|
|
683
|
+
logger.warn(`notifications: interactive route retirement failed: ${String(error)}`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
for (const [actionId, routeGateId] of this.routes) {
|
|
687
|
+
if (routeGateId !== gateId) continue;
|
|
688
|
+
this.routes.delete(actionId);
|
|
689
|
+
if (this.active?.actionId === actionId) this.active = undefined;
|
|
690
|
+
}
|
|
691
|
+
const presentation = this.presentations.get(gateId);
|
|
692
|
+
if (!presentation) return;
|
|
693
|
+
this.presentations.delete(gateId);
|
|
694
|
+
this.directControls.delete(gateId);
|
|
695
|
+
this.queuedDirectControls.delete(gateId);
|
|
696
|
+
this.retiredProofs.delete(gateId);
|
|
697
|
+
this.retries.delete(gateId);
|
|
698
|
+
this.#clearTerminalCancellation(gateId);
|
|
699
|
+
|
|
700
|
+
const index = this.queue.indexOf(gateId);
|
|
701
|
+
if (index >= 0) this.queue.splice(index, 1);
|
|
702
|
+
presentation.onClosed?.();
|
|
703
|
+
this.#promote();
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
reissueAfterFailure(actionId: string): void {
|
|
707
|
+
const gateId = this.routes.get(actionId);
|
|
708
|
+
if (!gateId) return;
|
|
709
|
+
this.routes.delete(actionId);
|
|
710
|
+
if (this.active?.actionId === actionId) this.active = undefined;
|
|
711
|
+
this.reconcile();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
reissue(gateId: string): string | undefined {
|
|
715
|
+
const presentation = this.presentations.get(gateId);
|
|
716
|
+
if (!presentation || this.directControls.has(gateId) || this.active) return undefined;
|
|
717
|
+
const actionId = `${presentation.workflowGateId ? "gate-interaction" : "ask"}:${crypto.randomUUID()}`;
|
|
718
|
+
this.routes.set(actionId, gateId);
|
|
719
|
+
try {
|
|
720
|
+
const lease = this.server.registerArbitratedAsk(
|
|
721
|
+
JSON.stringify(
|
|
722
|
+
notificationActionPayload(
|
|
723
|
+
{
|
|
724
|
+
type: "action_needed",
|
|
725
|
+
id: actionId,
|
|
726
|
+
kind: "ask",
|
|
727
|
+
sessionId: presentation.sessionId,
|
|
728
|
+
...(presentation.workflowGateId ? { workflowGateId: presentation.workflowGateId } : {}),
|
|
729
|
+
question:
|
|
730
|
+
presentation.selectedOptions.length > 0
|
|
731
|
+
? `(${presentation.selectedOptions.length} selected) ${presentation.question}`
|
|
732
|
+
: presentation.question,
|
|
733
|
+
options: presentation.options,
|
|
734
|
+
controls: presentation.multi
|
|
735
|
+
? [
|
|
736
|
+
{
|
|
737
|
+
id: "navigation_forward",
|
|
738
|
+
kind: "navigation",
|
|
739
|
+
label: presentation.navigationLabel ?? "Done",
|
|
740
|
+
enabled: presentation.allowEmpty || presentation.selectedOptions.length > 0,
|
|
741
|
+
},
|
|
742
|
+
]
|
|
743
|
+
: presentation.controls,
|
|
744
|
+
},
|
|
745
|
+
{ redact: this.redact(), sessionTag: this.tag },
|
|
746
|
+
),
|
|
747
|
+
),
|
|
748
|
+
true,
|
|
749
|
+
);
|
|
750
|
+
if (lease.actionId !== actionId) throw new Error("native arbitrated action id mismatch");
|
|
751
|
+
this.active = { actionId, gateId, registrationEpoch: lease.registrationEpoch };
|
|
752
|
+
this.retries.delete(gateId);
|
|
753
|
+
presentation.onActivated?.(actionId, lease);
|
|
754
|
+
return actionId;
|
|
755
|
+
} catch {
|
|
756
|
+
this.routes.delete(actionId);
|
|
757
|
+
const previous = this.retries.get(gateId);
|
|
758
|
+
const attempts = (previous?.attempts ?? 0) + 1;
|
|
759
|
+
const exhausted = attempts >= PresentationArbiter.maxRegistrationAttempts;
|
|
760
|
+
const delay = Math.min(
|
|
761
|
+
PresentationArbiter.retryMaxDelayMs,
|
|
762
|
+
PresentationArbiter.retryBaseDelayMs * 2 ** (attempts - 1),
|
|
763
|
+
);
|
|
764
|
+
this.retries.set(gateId, { attempts, exhausted, nextAt: Date.now() + delay });
|
|
765
|
+
logger.warn("workflow_gate_presentation_retry", {
|
|
766
|
+
gateId,
|
|
767
|
+
attempts,
|
|
768
|
+
maxAttempts: PresentationArbiter.maxRegistrationAttempts,
|
|
769
|
+
exhausted,
|
|
770
|
+
delayMs: exhausted ? undefined : delay,
|
|
771
|
+
});
|
|
772
|
+
// Exhaustion fences this queue head. Ordinary asks then terminally cancel
|
|
773
|
+
// through the same cancellation path so their caller cannot wait forever;
|
|
774
|
+
// durable workflow gates remain fenced for explicit recovery or cancellation.
|
|
775
|
+
if (exhausted) this.#scheduleTerminalCancellation(gateId);
|
|
776
|
+
else this.#scheduleRetry(gateId);
|
|
777
|
+
return undefined;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
closeInteraction(actionId: string, reason: string): boolean {
|
|
782
|
+
const gateId = this.routes.get(actionId);
|
|
783
|
+
const active = this.active;
|
|
784
|
+
if (!gateId || !active || active.actionId !== actionId) {
|
|
785
|
+
if (gateId) this.directControls.set(gateId, this.queue.indexOf(gateId));
|
|
786
|
+
logger.error(`notifications: terminalize ${actionId} lacks an exact active lease`);
|
|
787
|
+
return false;
|
|
788
|
+
}
|
|
789
|
+
const status = parseRetireStatus(this.server.retireIfUnclaimed(active).status);
|
|
790
|
+
if (isTerminalProof(status)) {
|
|
791
|
+
this.routes.delete(actionId);
|
|
792
|
+
this.active = undefined;
|
|
793
|
+
void reason;
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
796
|
+
this.directControls.set(gateId, this.queue.indexOf(gateId));
|
|
797
|
+
logger.error(`notifications: terminalize ${actionId} returned ${status}`);
|
|
798
|
+
return false;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
complete(gateId: string): WorkflowGateTerminalProof {
|
|
802
|
+
let proof = this.retiredProofs.get(gateId);
|
|
803
|
+
for (const [actionId, routeGateId] of this.routes) {
|
|
804
|
+
if (routeGateId !== gateId) continue;
|
|
805
|
+
if (!this.closeInteraction(actionId, "gate_complete"))
|
|
806
|
+
throw new Error(`workflow gate ${gateId} presentation lacks exact terminal proof`);
|
|
807
|
+
proof = "retired";
|
|
808
|
+
}
|
|
809
|
+
const presentation = this.presentations.get(gateId);
|
|
810
|
+
if (!proof && this.queuedDirectControls.has(gateId)) proof = "not_published";
|
|
811
|
+
if (!proof && presentation)
|
|
812
|
+
throw new Error(`workflow gate ${gateId} presentation lacks an active terminal lease`);
|
|
813
|
+
this.presentations.delete(gateId);
|
|
814
|
+
this.directControls.delete(gateId);
|
|
815
|
+
this.queuedDirectControls.delete(gateId);
|
|
816
|
+
this.retiredProofs.delete(gateId);
|
|
817
|
+
this.retries.delete(gateId);
|
|
818
|
+
this.#clearTerminalCancellation(gateId);
|
|
819
|
+
|
|
820
|
+
const index = this.queue.indexOf(gateId);
|
|
821
|
+
if (index >= 0) this.queue.splice(index, 1);
|
|
822
|
+
presentation?.onClosed?.();
|
|
823
|
+
this.#promote();
|
|
824
|
+
return proof ?? "already_terminal";
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
cancelInteractive(): void {
|
|
828
|
+
for (const [gateId, presentation] of this.presentations) {
|
|
829
|
+
if (!presentation.workflowGateId) this.#discardInteractive(gateId);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
cancel(gateId: string, reason: string): void {
|
|
834
|
+
this.#discardInteractive(gateId);
|
|
835
|
+
void reason;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
dispose(): void {
|
|
839
|
+
for (const gateId of [...this.presentations.keys()]) this.cancel(gateId, "session_shutdown");
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
interface SessionRuntime {
|
|
844
|
+
server: NotificationServer;
|
|
845
|
+
host: SessionSdkHost;
|
|
846
|
+
/** Owns stateRoot-backed revisions and removes their spills on terminal shutdown. */
|
|
847
|
+
revisions: RevisionStore;
|
|
848
|
+
/** Releases all snapshot pins before the revision store is closed. */
|
|
849
|
+
cursors: CursorRegistry;
|
|
850
|
+
/** Current endpoint session identity; never re-key an existing host across a switch. */
|
|
851
|
+
id: string;
|
|
852
|
+
idleSeq: number;
|
|
853
|
+
/** Interactive asks awaiting a remote answer, by action id. */
|
|
854
|
+
pendingInteractive: Map<string, PendingInteractiveAsk>;
|
|
855
|
+
/** Deregisters this session's ask answer source. */
|
|
856
|
+
disposeAnswerSource: () => void;
|
|
857
|
+
/** Deregisters this session's Telegram file sink. */
|
|
858
|
+
disposeFileSink: () => void;
|
|
859
|
+
/** Deregisters this session's workflow-gate listener. */
|
|
860
|
+
disposeGateListener: () => void;
|
|
861
|
+
/** Whether notification-only delivery and answer resources are active. */
|
|
862
|
+
notificationsActive: boolean;
|
|
863
|
+
/** Set as soon as terminal teardown is requested, before startup settles. */
|
|
864
|
+
stopping: boolean;
|
|
865
|
+
/** Recreates notification-only resources after `/notify on`. */
|
|
866
|
+
enableNotifications: () => void;
|
|
867
|
+
/** Deregisters canonical workflow-gate terminal cleanup. */
|
|
868
|
+
disposeGateTerminalController: () => void;
|
|
869
|
+
disposeAckRecoveryParticipant: () => void;
|
|
870
|
+
disposeGateEmitterListener: () => void;
|
|
871
|
+
waitForGateResolutionQuiescence: () => Promise<void>;
|
|
872
|
+
workflowGate?: WorkflowGateEmitter;
|
|
873
|
+
gatePresentations?: PresentationArbiter;
|
|
874
|
+
redact: boolean;
|
|
875
|
+
/** True only after the exact host generation was registered with the broker index. */
|
|
876
|
+
brokerRegistrationActive: boolean;
|
|
877
|
+
verbosity: "lean" | "verbose";
|
|
878
|
+
sessionTag: string;
|
|
879
|
+
/** Whether the agent loop is currently running (drives the typing indicator). */
|
|
880
|
+
busy: boolean;
|
|
881
|
+
/** Prompt command/turn identities awaiting their corresponding agent_start. */
|
|
882
|
+
pendingPromptCorrelations: Array<{ commandId: string; turnId: string }>;
|
|
883
|
+
/** Identity bound to the agent lifecycle currently in flight. */
|
|
884
|
+
activePromptCorrelation?: { commandId: string; turnId: string };
|
|
885
|
+
/** Records a correlated prompt terminal boundary after agent unwind. */
|
|
886
|
+
/** Atomically claims a correlated prompt terminal boundary after agent unwind. */
|
|
887
|
+
recordPromptTerminal: (correlation: { commandId: string; turnId: string } | undefined) => boolean;
|
|
888
|
+
/** Records correlated lifecycle frames for replay and delivers them only to the accepted requester after acknowledgement. */
|
|
889
|
+
emitPromptLifecycle: (
|
|
890
|
+
correlation: { commandId: string; turnId: string } | undefined,
|
|
891
|
+
frame:
|
|
892
|
+
| { type: "agent_start" | "agent_end"; sessionId: string; commandId?: string; turnId?: string }
|
|
893
|
+
| {
|
|
894
|
+
type: "agent_failed";
|
|
895
|
+
sessionId: string;
|
|
896
|
+
commandId: string;
|
|
897
|
+
turnId: string;
|
|
898
|
+
error: { code: string; message: string };
|
|
899
|
+
},
|
|
900
|
+
) => void;
|
|
901
|
+
/** Inbound Telegram update ids injected but not yet consumed by a turn. */
|
|
902
|
+
pendingInbound: Set<number>;
|
|
903
|
+
/** Latest assistant text of the in-flight turn (from message_update). */
|
|
904
|
+
currentTurnText?: string;
|
|
905
|
+
/** Assistant text already flushed before an ask this turn (turn-scoped dedupe
|
|
906
|
+
* so turn_end does not re-emit the pre-ask lead-in). Reset each turn. */
|
|
907
|
+
preAskFlushedText?: string;
|
|
908
|
+
/** Live streaming: opt-in flag, monotonic per-turn ref, and emit throttle state. */
|
|
909
|
+
stream: boolean;
|
|
910
|
+
turnSeq?: number;
|
|
911
|
+
liveRef?: string;
|
|
912
|
+
lastLiveAt?: number;
|
|
913
|
+
lastLiveText?: string;
|
|
914
|
+
/** True between turn_end and the next turn_start: drops late async message_update
|
|
915
|
+
* frames so a stale live edit can never be emitted after the finalized turn. */
|
|
916
|
+
turnClosed?: boolean;
|
|
917
|
+
/** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */
|
|
918
|
+
cancelPostmortemCleanup: () => void;
|
|
919
|
+
/** Stops optional broker presence heartbeats. */
|
|
920
|
+
stopBrokerHeartbeat: () => void;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** Request-local requester authority for stable ControlSurface dispatches. */
|
|
924
|
+
const controlRequesterContext = new AsyncLocalStorage<string>();
|
|
925
|
+
type SessionStartStatus = "started" | "already" | "disabled" | "failed";
|
|
926
|
+
type SessionStartResult = {
|
|
927
|
+
status: SessionStartStatus;
|
|
928
|
+
runtime?: SessionRuntime;
|
|
929
|
+
failure?: SdkStartupFailure;
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
function pushSessionFrame(
|
|
933
|
+
runtime: Pick<SessionRuntime, "server" | "host">,
|
|
934
|
+
frame: { type: string; [key: string]: unknown },
|
|
935
|
+
): void {
|
|
936
|
+
runtime.host.emitEvent({ kind: frame.type, payload: frame });
|
|
937
|
+
runtime.server.pushFrame(JSON.stringify(frame));
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** Agent lifecycle is SDK session truth, independent of optional chat delivery. */
|
|
941
|
+
function emitAgentLifecycle(
|
|
942
|
+
runtime: Pick<SessionRuntime, "server" | "host">,
|
|
943
|
+
frame: { type: "agent_start" | "agent_end"; sessionId: string; commandId?: string; turnId?: string },
|
|
944
|
+
): void {
|
|
945
|
+
try {
|
|
946
|
+
const json = JSON.stringify(frame);
|
|
947
|
+
runtime.host.emitEvent({ kind: frame.type, payload: frame });
|
|
948
|
+
runtime.server.pushFrame(json);
|
|
949
|
+
} catch (error) {
|
|
950
|
+
logger.warn(`sdk: lifecycle delivery failed: ${String(error)}`);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
interface ResolvedSettings {
|
|
955
|
+
settings: Settings | undefined;
|
|
956
|
+
cfg: NotificationConfig;
|
|
957
|
+
settingsAvailable: boolean;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const TELEGRAM_FILE_REDACTION_ERROR = "Telegram file attachments are disabled while notifications redaction is on.";
|
|
961
|
+
|
|
962
|
+
const defaultConfig: NotificationConfig = {
|
|
963
|
+
enabled: false,
|
|
964
|
+
botToken: undefined,
|
|
965
|
+
chatId: undefined,
|
|
966
|
+
discord: {
|
|
967
|
+
botToken: undefined,
|
|
968
|
+
},
|
|
969
|
+
slack: {
|
|
970
|
+
botToken: undefined,
|
|
971
|
+
channelId: undefined,
|
|
972
|
+
},
|
|
973
|
+
redact: false,
|
|
974
|
+
verbosity: "lean",
|
|
975
|
+
sessionScope: "all",
|
|
976
|
+
idleTimeoutMs: 60_000,
|
|
977
|
+
rich: { enabled: true },
|
|
978
|
+
richDraft: { enabled: false },
|
|
979
|
+
topics: {},
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
export function notificationsEnabled(): boolean {
|
|
983
|
+
return process.env.GJC_NOTIFICATIONS === "1" || Boolean(process.env.GJC_NOTIFICATIONS_TOKEN);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Live streaming (opt-in): emit throttled non-finalized `turn_stream` frames as
|
|
987
|
+
// the assistant message streams so remote clients can edit ONE message live. The
|
|
988
|
+
// finalized frame (turn_end) carries the same messageRef and stays authoritative,
|
|
989
|
+
// so a dropped live frame self-heals. Off unless GJC_NOTIFICATIONS_STREAM=1.
|
|
990
|
+
function streamingEnabled(): boolean {
|
|
991
|
+
return process.env.GJC_NOTIFICATIONS_STREAM === "1";
|
|
992
|
+
}
|
|
993
|
+
function streamIntervalMs(): number {
|
|
994
|
+
return Math.max(200, Number(process.env.GJC_NOTIFICATIONS_STREAM_INTERVAL_MS) || 500);
|
|
995
|
+
}
|
|
996
|
+
// Max chars of a turn's assistant text carried by the FINALIZED turn_stream (and
|
|
997
|
+
// the pre-ask capture). Finalized turns default to the bounded full-turn ceiling
|
|
998
|
+
// because split-capable clients such as the Telegram daemon schedule each
|
|
999
|
+
// splitTelegramHtml chunk through the shared rate-limit pool. Operators who want
|
|
1000
|
+
// glanceable summaries can lower this with GJC_NOTIFICATIONS_TURN_MAX. The value
|
|
1001
|
+
// is always clamped to a finite [280, TURN_TEXT_MAX_CEILING] range so the cap can
|
|
1002
|
+
// never be unbounded. Live frames are intentionally NOT raised — they stay one
|
|
1003
|
+
// editable preview message rather than fanning a long in-progress turn across
|
|
1004
|
+
// sends.
|
|
1005
|
+
const TURN_TEXT_MAX_CEILING = 40_000;
|
|
1006
|
+
function turnTextMax(): number {
|
|
1007
|
+
const raw = Number(process.env.GJC_NOTIFICATIONS_TURN_MAX);
|
|
1008
|
+
if (!Number.isFinite(raw) || raw <= 0) return TURN_TEXT_MAX_CEILING;
|
|
1009
|
+
return Math.min(TURN_TEXT_MAX_CEILING, Math.max(280, raw));
|
|
1010
|
+
}
|
|
1011
|
+
function resolveNotificationConfig(settings: Settings): NotificationConfig {
|
|
1012
|
+
const reader = settings as Partial<NotificationSettingsReader>;
|
|
1013
|
+
return typeof reader.getNotificationSettingsSnapshot === "function"
|
|
1014
|
+
? getNotificationConfig(reader as NotificationSettingsReader)
|
|
1015
|
+
: defaultConfig;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function resolveSettings(settingsOverride?: Settings): ResolvedSettings {
|
|
1019
|
+
if (settingsOverride)
|
|
1020
|
+
return { settings: settingsOverride, cfg: resolveNotificationConfig(settingsOverride), settingsAvailable: true };
|
|
1021
|
+
try {
|
|
1022
|
+
const settings = Settings.instance;
|
|
1023
|
+
return { settings, cfg: getNotificationConfig(settings), settingsAvailable: true };
|
|
1024
|
+
} catch {
|
|
1025
|
+
return { settings: undefined, cfg: defaultConfig, settingsAvailable: false };
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function resolveToken(): string {
|
|
1030
|
+
// `GJC_NOTIFICATIONS_TOKEN` remains an enablement compatibility flag, never
|
|
1031
|
+
// a reusable endpoint credential. Every host identity gets fresh authority.
|
|
1032
|
+
return crypto.randomBytes(24).toString("base64url");
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function parseAnswer(answerJson: string): unknown {
|
|
1036
|
+
try {
|
|
1037
|
+
return JSON.parse(answerJson);
|
|
1038
|
+
} catch {
|
|
1039
|
+
return answerJson;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** Map a client answer to the option LABEL the local UI would return (or free text). */
|
|
1044
|
+
function mapAnswerToLabel(answerJson: string, options: string[]): string | undefined {
|
|
1045
|
+
const answer = parseAnswer(answerJson);
|
|
1046
|
+
if (typeof answer === "number") return options[answer];
|
|
1047
|
+
if (typeof answer === "string") return answer;
|
|
1048
|
+
if (answer && typeof answer === "object") {
|
|
1049
|
+
const sel = (answer as { selected?: unknown; custom?: unknown }).selected;
|
|
1050
|
+
if (Array.isArray(sel) && sel.length > 0) {
|
|
1051
|
+
const first = sel[0];
|
|
1052
|
+
return typeof first === "number" ? options[first] : String(first);
|
|
1053
|
+
}
|
|
1054
|
+
const custom = (answer as { custom?: unknown }).custom;
|
|
1055
|
+
if (typeof custom === "string") return custom;
|
|
1056
|
+
}
|
|
1057
|
+
return undefined;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** Workflow-gate answer shape. */
|
|
1061
|
+
interface GateAnswer {
|
|
1062
|
+
selected: string[];
|
|
1063
|
+
other?: boolean;
|
|
1064
|
+
custom?: string;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Discriminated result of mapping a client answer to a workflow-gate answer.
|
|
1069
|
+
* `ok: false` means the reply is invalid and the caller must close the exact
|
|
1070
|
+
* claim/receipt and reissue the interaction rather than durably accepting it.
|
|
1071
|
+
*/
|
|
1072
|
+
type GateAnswerResult = { ok: true; answer: GateAnswer } | { ok: false; reason: string };
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Map a client answer to the workflow-gate answer shape.
|
|
1076
|
+
*
|
|
1077
|
+
* The protocol defines a numeric reply as an option index, so a number outside
|
|
1078
|
+
* `options` is invalid: it must NOT be converted into free text that passes the
|
|
1079
|
+
* ask schema and triggers a misleading success acknowledgement.
|
|
1080
|
+
* Only JSON strings enter the free-text/Other path.
|
|
1081
|
+
*/
|
|
1082
|
+
export function mapAnswerToGate(answerJson: string, options: string[]): GateAnswerResult {
|
|
1083
|
+
const answer = parseAnswer(answerJson);
|
|
1084
|
+
if (typeof answer === "number") {
|
|
1085
|
+
const label = options[answer];
|
|
1086
|
+
return label === undefined
|
|
1087
|
+
? { ok: false, reason: "numeric_selector_out_of_range" }
|
|
1088
|
+
: { ok: true, answer: { selected: [label] } };
|
|
1089
|
+
}
|
|
1090
|
+
if (typeof answer === "string") {
|
|
1091
|
+
return {
|
|
1092
|
+
ok: true,
|
|
1093
|
+
answer: options.includes(answer) ? { selected: [answer] } : { selected: [], other: true, custom: answer },
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
if (answer && typeof answer === "object") {
|
|
1097
|
+
const obj = answer as { selected?: unknown; custom?: unknown };
|
|
1098
|
+
const selected = Array.isArray(obj.selected)
|
|
1099
|
+
? obj.selected.map(s => (typeof s === "number" ? (options[s] ?? String(s)) : String(s)))
|
|
1100
|
+
: [];
|
|
1101
|
+
const custom = typeof obj.custom === "string" ? obj.custom : undefined;
|
|
1102
|
+
return { ok: true, answer: { selected, other: custom !== undefined, custom } };
|
|
1103
|
+
}
|
|
1104
|
+
return { ok: true, answer: { selected: [] } };
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
interface NotificationControlCommandPayload {
|
|
1108
|
+
name?: unknown;
|
|
1109
|
+
action?: unknown;
|
|
1110
|
+
level?: unknown;
|
|
1111
|
+
global?: unknown;
|
|
1112
|
+
selector?: unknown;
|
|
1113
|
+
instructions?: unknown;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
export interface NotificationControlCommandResult {
|
|
1117
|
+
status: "ok" | "error" | "unavailable";
|
|
1118
|
+
message: string;
|
|
1119
|
+
modelChoices?: Array<{ selector: string; label: string }>;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function parseControlCommandPayload(json: string | undefined): NotificationControlCommandPayload | undefined {
|
|
1123
|
+
if (!json) return undefined;
|
|
1124
|
+
try {
|
|
1125
|
+
const parsed = JSON.parse(json) as unknown;
|
|
1126
|
+
return parsed && typeof parsed === "object" ? (parsed as NotificationControlCommandPayload) : undefined;
|
|
1127
|
+
} catch {
|
|
1128
|
+
return undefined;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function formatCompactTokenCount(value: number | null | undefined): string {
|
|
1133
|
+
if (value == null) return "unknown";
|
|
1134
|
+
if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1))}m`;
|
|
1135
|
+
if (value >= 1_000) return `${Number((value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1))}k`;
|
|
1136
|
+
return value.toLocaleString();
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function formatContextUsageLine(ctx: ExtensionContext): string {
|
|
1140
|
+
const usage = ctx.getContextUsage();
|
|
1141
|
+
if (!usage) return "Context usage unavailable.";
|
|
1142
|
+
const tokens = formatCompactTokenCount(usage.tokens);
|
|
1143
|
+
const window = formatCompactTokenCount(usage.contextWindow);
|
|
1144
|
+
const pct = usage.percent == null ? "unknown" : `${usage.percent.toFixed(1)}%`;
|
|
1145
|
+
return `Context: ${tokens}/${window} ${pct}`;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function formatLocalUsage(ctx: ExtensionContext): string {
|
|
1149
|
+
const stats = ctx.sessionManager.getUsageStatistics();
|
|
1150
|
+
return [
|
|
1151
|
+
"Usage",
|
|
1152
|
+
`Input tokens: ${stats.input}`,
|
|
1153
|
+
`Output tokens: ${stats.output}`,
|
|
1154
|
+
`Cache read tokens: ${stats.cacheRead}`,
|
|
1155
|
+
`Cache write tokens: ${stats.cacheWrite}`,
|
|
1156
|
+
`Premium requests: ${stats.premiumRequests}`,
|
|
1157
|
+
`Cost: $${stats.cost.toFixed(6)}`,
|
|
1158
|
+
].join("\n");
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
interface SafeUsageWindow {
|
|
1162
|
+
kind: "5h" | "7d";
|
|
1163
|
+
usedFraction?: number;
|
|
1164
|
+
resetsAt?: number;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1168
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function classifyUsageWindow(limit: Record<string, unknown>): "5h" | "7d" | undefined {
|
|
1172
|
+
const window = isRecord(limit.window) ? limit.window : undefined;
|
|
1173
|
+
const scope = isRecord(limit.scope) ? limit.scope : undefined;
|
|
1174
|
+
const ids = [window?.id, scope?.windowId, limit.id];
|
|
1175
|
+
for (const id of ids) {
|
|
1176
|
+
if (typeof id !== "string") continue;
|
|
1177
|
+
const normalized = id.toLowerCase();
|
|
1178
|
+
if (normalized === "5h" || normalized === "7d") return normalized;
|
|
1179
|
+
}
|
|
1180
|
+
const durationMs = window?.durationMs;
|
|
1181
|
+
if (typeof durationMs !== "number" || !Number.isFinite(durationMs)) return undefined;
|
|
1182
|
+
if (Math.abs(durationMs - 5 * 60 * 60_000) <= 30 * 60_000) return "5h";
|
|
1183
|
+
if (Math.abs(durationMs - 7 * 24 * 60 * 60_000) <= 12 * 60 * 60_000) return "7d";
|
|
1184
|
+
return undefined;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function getUsageUsedFraction(amount: Record<string, unknown> | undefined): number | undefined {
|
|
1188
|
+
if (!amount) return undefined;
|
|
1189
|
+
const usedFraction = amount.usedFraction;
|
|
1190
|
+
if (typeof usedFraction === "number" && Number.isFinite(usedFraction)) return usedFraction;
|
|
1191
|
+
const used = amount.used;
|
|
1192
|
+
if (typeof used !== "number" || !Number.isFinite(used)) return undefined;
|
|
1193
|
+
if (amount.unit === "percent") return used / 100;
|
|
1194
|
+
const limit = amount.limit;
|
|
1195
|
+
return typeof limit === "number" && Number.isFinite(limit) && limit !== 0 ? used / limit : undefined;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function formatStableResetTime(value: number): string | undefined {
|
|
1199
|
+
if (!Number.isFinite(value)) return undefined;
|
|
1200
|
+
try {
|
|
1201
|
+
return new Date(value)
|
|
1202
|
+
.toISOString()
|
|
1203
|
+
.replace("T", " ")
|
|
1204
|
+
.replace(/\.\d{3}Z$/, " UTC");
|
|
1205
|
+
} catch {
|
|
1206
|
+
return undefined;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function shouldReplaceUsageWindow(current: SafeUsageWindow, candidate: SafeUsageWindow): boolean {
|
|
1211
|
+
if (candidate.usedFraction !== undefined) {
|
|
1212
|
+
if (current.usedFraction === undefined || candidate.usedFraction > current.usedFraction) return true;
|
|
1213
|
+
if (candidate.usedFraction < current.usedFraction) return false;
|
|
1214
|
+
}
|
|
1215
|
+
if (current.usedFraction !== undefined && candidate.usedFraction === undefined) return false;
|
|
1216
|
+
if (candidate.resetsAt === undefined) return false;
|
|
1217
|
+
return current.resetsAt === undefined || candidate.resetsAt < current.resetsAt;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function formatRemoteUsageWindows(reports: unknown): string[] {
|
|
1221
|
+
if (!Array.isArray(reports)) return [];
|
|
1222
|
+
const windows = new Map<SafeUsageWindow["kind"], SafeUsageWindow>();
|
|
1223
|
+
for (const report of reports) {
|
|
1224
|
+
if (!isRecord(report) || !Array.isArray(report.limits)) continue;
|
|
1225
|
+
for (const value of report.limits) {
|
|
1226
|
+
if (!isRecord(value)) continue;
|
|
1227
|
+
const kind = classifyUsageWindow(value);
|
|
1228
|
+
if (!kind) continue;
|
|
1229
|
+
const window = isRecord(value.window) ? value.window : undefined;
|
|
1230
|
+
const amount = isRecord(value.amount) ? value.amount : undefined;
|
|
1231
|
+
const usedFraction = getUsageUsedFraction(amount);
|
|
1232
|
+
const resetsAt = window?.resetsAt;
|
|
1233
|
+
const candidate: SafeUsageWindow = {
|
|
1234
|
+
kind,
|
|
1235
|
+
...(typeof usedFraction === "number" && Number.isFinite(usedFraction) ? { usedFraction } : {}),
|
|
1236
|
+
...(typeof resetsAt === "number" && Number.isFinite(resetsAt) ? { resetsAt } : {}),
|
|
1237
|
+
};
|
|
1238
|
+
const current = windows.get(kind);
|
|
1239
|
+
if (!current || shouldReplaceUsageWindow(current, candidate)) windows.set(kind, candidate);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
return (["5h", "7d"] as const).flatMap(kind => {
|
|
1243
|
+
const window = windows.get(kind);
|
|
1244
|
+
if (!window) return [];
|
|
1245
|
+
const details = [kind === "5h" ? "5-hour limit" : "Weekly limit"];
|
|
1246
|
+
if (window.usedFraction !== undefined) details.push(`${Number((window.usedFraction * 100).toFixed(1))}% used`);
|
|
1247
|
+
const resetTime = window.resetsAt === undefined ? undefined : formatStableResetTime(window.resetsAt);
|
|
1248
|
+
if (resetTime) details.push(`resets ${resetTime}`);
|
|
1249
|
+
return [details.join(" — ")];
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
async function formatUsage(ctx: ExtensionContext, api: ExtensionAPI): Promise<string> {
|
|
1254
|
+
const local = formatLocalUsage(ctx);
|
|
1255
|
+
try {
|
|
1256
|
+
const windows = formatRemoteUsageWindows(await api.fetchUsageReportsForControl());
|
|
1257
|
+
return windows.length > 0 ? `${local}\n\nUsage windows\n${windows.join("\n")}` : local;
|
|
1258
|
+
} catch {
|
|
1259
|
+
logger.warn("notifications: usage report fetch failed");
|
|
1260
|
+
return local;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function formatReasoningSettings(api: ExtensionAPI): string {
|
|
1265
|
+
const level = api.getThinkingLevel() ?? ThinkingLevel.Off;
|
|
1266
|
+
const display = api.getThinkingVisibility() === "visible" ? "on" : "off";
|
|
1267
|
+
return [
|
|
1268
|
+
"🧠 Reasoning Settings",
|
|
1269
|
+
`Effort: ${level}`,
|
|
1270
|
+
`Scope: ${api.getThinkingScopeForControl()}`,
|
|
1271
|
+
`Display: ${display}`,
|
|
1272
|
+
telegramControlCommandUsage("reasoning"),
|
|
1273
|
+
].join("\n");
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const TELEGRAM_MODEL_CHOICE_LIMIT = 40;
|
|
1277
|
+
|
|
1278
|
+
function getModelChoices(ctx: ExtensionContext): Array<{ selector: string; label: string }> {
|
|
1279
|
+
const choices = new Map<string, { selector: string; label: string }>();
|
|
1280
|
+
for (const model of ctx.modelRegistry.getAvailable()) {
|
|
1281
|
+
const selector = `${model.provider}/${model.id}`;
|
|
1282
|
+
if (!choices.has(selector)) {
|
|
1283
|
+
choices.set(selector, { selector, label: selector.replace(/[\u0000-\u001F\u007F]/g, " ") });
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
return [...choices.values()]
|
|
1287
|
+
.sort((left, right) => left.selector.localeCompare(right.selector))
|
|
1288
|
+
.slice(0, TELEGRAM_MODEL_CHOICE_LIMIT);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const CONTROL_COMMAND_FAILURE_MESSAGE = "Control command failed.";
|
|
1292
|
+
const STALE_MODEL_BUTTON_MESSAGE = "Button is stale. Run /model again.";
|
|
1293
|
+
|
|
1294
|
+
export async function executeNotificationControlCommand(
|
|
1295
|
+
command: NotificationControlCommandPayload | undefined,
|
|
1296
|
+
ctx: ExtensionContext,
|
|
1297
|
+
api: ExtensionAPI,
|
|
1298
|
+
expectedSessionId?: string,
|
|
1299
|
+
): Promise<NotificationControlCommandResult> {
|
|
1300
|
+
try {
|
|
1301
|
+
return await executeNotificationControlCommandUnchecked(command, ctx, api, expectedSessionId);
|
|
1302
|
+
} catch {
|
|
1303
|
+
logger.warn("notifications: control command failed");
|
|
1304
|
+
return { status: "error", message: CONTROL_COMMAND_FAILURE_MESSAGE };
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
async function executeNotificationControlCommandUnchecked(
|
|
1309
|
+
command: NotificationControlCommandPayload | undefined,
|
|
1310
|
+
ctx: ExtensionContext,
|
|
1311
|
+
api: ExtensionAPI,
|
|
1312
|
+
expectedSessionId?: string,
|
|
1313
|
+
): Promise<NotificationControlCommandResult> {
|
|
1314
|
+
if (!command || typeof command.name !== "string") return { status: "error", message: "Invalid control command." };
|
|
1315
|
+
switch (command.name) {
|
|
1316
|
+
case "reasoning": {
|
|
1317
|
+
const global = command.global === true;
|
|
1318
|
+
if (command.action === "status") return { status: "ok", message: formatReasoningSettings(api) };
|
|
1319
|
+
if (command.action === "cycle") {
|
|
1320
|
+
const next = api.cycleThinkingLevel();
|
|
1321
|
+
return next
|
|
1322
|
+
? { status: "ok", message: formatReasoningSettings(api) }
|
|
1323
|
+
: { status: "unavailable", message: "Reasoning effort unavailable for this session." };
|
|
1324
|
+
}
|
|
1325
|
+
if (command.action === "set" && typeof command.level === "string") {
|
|
1326
|
+
const requestedLevel = command.level.toLowerCase();
|
|
1327
|
+
const level = requestedLevel === "none" ? "off" : requestedLevel === "reset" ? "inherit" : requestedLevel;
|
|
1328
|
+
const parsed = parseThinkingLevel(level);
|
|
1329
|
+
if (!parsed) return { status: "error", message: "Invalid reasoning effort." };
|
|
1330
|
+
await api.setThinkingLevelForControl(parsed, global);
|
|
1331
|
+
return { status: "ok", message: formatReasoningSettings(api) };
|
|
1332
|
+
}
|
|
1333
|
+
if (command.action === "show" || command.action === "hide") {
|
|
1334
|
+
await api.setThinkingVisibilityForControl(command.action === "show" ? "visible" : "hidden", global);
|
|
1335
|
+
return { status: "ok", message: formatReasoningSettings(api) };
|
|
1336
|
+
}
|
|
1337
|
+
return { status: "error", message: "Invalid reasoning command." };
|
|
1338
|
+
}
|
|
1339
|
+
case "usage":
|
|
1340
|
+
return { status: "ok", message: await formatUsage(ctx, api) };
|
|
1341
|
+
case "context":
|
|
1342
|
+
return { status: "ok", message: formatContextUsageLine(ctx) };
|
|
1343
|
+
case "model": {
|
|
1344
|
+
const choices = getModelChoices(ctx);
|
|
1345
|
+
if (command.action === "list") {
|
|
1346
|
+
return choices.length > 0
|
|
1347
|
+
? { status: "ok", message: "Select a model.", modelChoices: choices }
|
|
1348
|
+
: { status: "unavailable", message: "No models are available for this session." };
|
|
1349
|
+
}
|
|
1350
|
+
if (command.action !== "set" || typeof command.selector !== "string") {
|
|
1351
|
+
return { status: "error", message: "Invalid model selection." };
|
|
1352
|
+
}
|
|
1353
|
+
const model = ctx.modelRegistry
|
|
1354
|
+
.getAvailable()
|
|
1355
|
+
.find(candidate => `${candidate.provider}/${candidate.id}` === command.selector);
|
|
1356
|
+
if (!model) return { status: "error", message: "Invalid model selection." };
|
|
1357
|
+
if (!(await api.setModelTemporaryForControl(model, expectedSessionId)))
|
|
1358
|
+
return { status: "unavailable", message: "Model unavailable for this session." };
|
|
1359
|
+
return { status: "ok", message: `Model set to ${command.selector}.` };
|
|
1360
|
+
}
|
|
1361
|
+
case "compact": {
|
|
1362
|
+
const before = ctx.getContextUsage()?.tokens;
|
|
1363
|
+
await ctx.compact(typeof command.instructions === "string" ? command.instructions : undefined);
|
|
1364
|
+
const after = ctx.getContextUsage()?.tokens;
|
|
1365
|
+
if (before != null && after != null)
|
|
1366
|
+
return {
|
|
1367
|
+
status: "ok",
|
|
1368
|
+
message: `Compaction complete. Tokens: ${before} -> ${after} (saved ${before - after}).`,
|
|
1369
|
+
};
|
|
1370
|
+
return { status: "ok", message: "Compaction complete." };
|
|
1371
|
+
}
|
|
1372
|
+
default:
|
|
1373
|
+
return { status: "error", message: "Unknown control command." };
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function selectedAckOutcome(value: { status: string; messageId?: number; reason?: string }): AskSelectedAckOutcome {
|
|
1378
|
+
if (value.status === "delivered" && typeof value.messageId === "number") {
|
|
1379
|
+
return { status: "delivered", messageId: value.messageId };
|
|
1380
|
+
}
|
|
1381
|
+
if (value.status === "failed") {
|
|
1382
|
+
switch (value.reason) {
|
|
1383
|
+
case "unsupported":
|
|
1384
|
+
case "no_participant":
|
|
1385
|
+
case "ambiguous_participant":
|
|
1386
|
+
case "route_missing":
|
|
1387
|
+
case "expired":
|
|
1388
|
+
case "cancelled":
|
|
1389
|
+
case "telegram_rejected":
|
|
1390
|
+
case "session_closed":
|
|
1391
|
+
return { status: "failed", reason: value.reason };
|
|
1392
|
+
default:
|
|
1393
|
+
return { status: "failed", reason: "session_closed" };
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
switch (value.reason) {
|
|
1397
|
+
case "transport_ambiguous":
|
|
1398
|
+
case "origin_disconnected":
|
|
1399
|
+
case "host_timeout":
|
|
1400
|
+
case "shutdown":
|
|
1401
|
+
return { status: "unknown", reason: value.reason };
|
|
1402
|
+
default:
|
|
1403
|
+
return { status: "unknown", reason: "host_timeout" };
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
async function requestLiveSelectedAck(
|
|
1408
|
+
native: {
|
|
1409
|
+
requestAskSelectedAck(
|
|
1410
|
+
replyReceiptId: string,
|
|
1411
|
+
requestJson: string,
|
|
1412
|
+
): Promise<{ status: string; messageId?: number; reason?: string }>;
|
|
1413
|
+
},
|
|
1414
|
+
input: { replyReceiptId: string; actionId: string; commitKey: string; deadlineAt: number },
|
|
1415
|
+
): Promise<AskSelectedAckOutcome> {
|
|
1416
|
+
const requestId = `ack:${crypto.randomUUID()}`;
|
|
1417
|
+
try {
|
|
1418
|
+
return selectedAckOutcome(
|
|
1419
|
+
await native.requestAskSelectedAck(
|
|
1420
|
+
input.replyReceiptId,
|
|
1421
|
+
JSON.stringify({
|
|
1422
|
+
mode: "live",
|
|
1423
|
+
requestId,
|
|
1424
|
+
commitKey: input.commitKey,
|
|
1425
|
+
actionId: input.actionId,
|
|
1426
|
+
deadlineAt: input.deadlineAt,
|
|
1427
|
+
}),
|
|
1428
|
+
),
|
|
1429
|
+
);
|
|
1430
|
+
} catch (error) {
|
|
1431
|
+
logger.warn(`notifications: Selected acknowledgement failed: ${String(error)}`);
|
|
1432
|
+
return { status: "unknown", reason: "host_timeout" };
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
async function requestRecoveredSelectedAck(
|
|
1437
|
+
native: {
|
|
1438
|
+
requestRecoveredAskSelectedAck(
|
|
1439
|
+
requestJson: string,
|
|
1440
|
+
): Promise<{ status: string; messageId?: number; reason?: string }>;
|
|
1441
|
+
},
|
|
1442
|
+
input: { sessionId: string; actionId: string; commitKey: string; deadlineAt: number },
|
|
1443
|
+
): Promise<AskSelectedAckOutcome> {
|
|
1444
|
+
try {
|
|
1445
|
+
return selectedAckOutcome(
|
|
1446
|
+
await native.requestRecoveredAskSelectedAck(
|
|
1447
|
+
JSON.stringify({
|
|
1448
|
+
mode: "recovery",
|
|
1449
|
+
requestId: `ack:${crypto.randomUUID()}`,
|
|
1450
|
+
commitKey: input.commitKey,
|
|
1451
|
+
sessionId: input.sessionId,
|
|
1452
|
+
actionId: input.actionId,
|
|
1453
|
+
deadlineAt: input.deadlineAt,
|
|
1454
|
+
}),
|
|
1455
|
+
),
|
|
1456
|
+
);
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
logger.warn(`notifications: recovered Selected acknowledgement failed: ${String(error)}`);
|
|
1459
|
+
return { status: "unknown", reason: "host_timeout" };
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/** Register the interactive `ask` answer source for a session (the ask tool
|
|
1464
|
+
* races the local UI against a remote reply). Returns the deregister disposer. */
|
|
1465
|
+
function registerInteractiveAnswerSource(
|
|
1466
|
+
id: string,
|
|
1467
|
+
pendingInteractive: Map<string, PendingInteractiveAsk>,
|
|
1468
|
+
presentationArbiter: PresentationArbiter,
|
|
1469
|
+
): () => void {
|
|
1470
|
+
return registerAskAnswerSource(id, {
|
|
1471
|
+
awaitAnswer(question, options, signal) {
|
|
1472
|
+
const result = this.awaitAnswerRequest?.({ question, options, interaction: "selector", controls: [] }, signal);
|
|
1473
|
+
if (!result) return Promise.resolve(undefined);
|
|
1474
|
+
return result.then(answer => {
|
|
1475
|
+
if (!answer || typeof answer === "string") return answer;
|
|
1476
|
+
return answer.interaction.kind === "value" ? answer.interaction.value : undefined;
|
|
1477
|
+
});
|
|
1478
|
+
},
|
|
1479
|
+
awaitAnswerRequest(request: AskAnswerRequest, signal?: AbortSignal): Promise<AskAnswerSourceResult> {
|
|
1480
|
+
if (signal?.aborted) return Promise.resolve(undefined);
|
|
1481
|
+
const presentationId = `interactive:${crypto.randomUUID()}`;
|
|
1482
|
+
return new Promise<AskAnswerSourceResult>(resolve => {
|
|
1483
|
+
let settled = false;
|
|
1484
|
+
const settle = (result: AskAnswerSourceResult) => {
|
|
1485
|
+
if (settled) return;
|
|
1486
|
+
settled = true;
|
|
1487
|
+
resolve(result);
|
|
1488
|
+
};
|
|
1489
|
+
const pending: PendingInteractiveAsk = {
|
|
1490
|
+
resolve: settle,
|
|
1491
|
+
options: request.options,
|
|
1492
|
+
controls: request.controls,
|
|
1493
|
+
retireForDirectControl: () => presentationArbiter.retireForDirectControl(presentationId),
|
|
1494
|
+
reissue: () => {
|
|
1495
|
+
if (!pending.actionId) return false;
|
|
1496
|
+
presentationArbiter.reissueAfterFailure(pending.actionId);
|
|
1497
|
+
return true;
|
|
1498
|
+
},
|
|
1499
|
+
complete: actionId => presentationArbiter.completeInteractive(presentationId, actionId),
|
|
1500
|
+
completeDirect: () => presentationArbiter.completeDirect(presentationId),
|
|
1501
|
+
fail: actionId => presentationArbiter.completeInteractive(presentationId, actionId),
|
|
1502
|
+
};
|
|
1503
|
+
presentationArbiter.retain({
|
|
1504
|
+
gateId: presentationId,
|
|
1505
|
+
sessionId: id,
|
|
1506
|
+
question: request.question,
|
|
1507
|
+
options: request.options,
|
|
1508
|
+
controls: request.controls,
|
|
1509
|
+
multi: false,
|
|
1510
|
+
allowEmpty: false,
|
|
1511
|
+
selectedOptions: [],
|
|
1512
|
+
onActivated: (actionId, lease) => {
|
|
1513
|
+
if (pending.actionId && pendingInteractive.get(pending.actionId) === pending)
|
|
1514
|
+
pendingInteractive.delete(pending.actionId);
|
|
1515
|
+
pending.actionId = actionId;
|
|
1516
|
+
pendingInteractive.set(actionId, pending);
|
|
1517
|
+
void lease;
|
|
1518
|
+
},
|
|
1519
|
+
onClosed: () => {
|
|
1520
|
+
if (pending.actionId && pendingInteractive.get(pending.actionId) === pending)
|
|
1521
|
+
pendingInteractive.delete(pending.actionId);
|
|
1522
|
+
settle(undefined);
|
|
1523
|
+
},
|
|
1524
|
+
});
|
|
1525
|
+
signal?.addEventListener("abort", () => {
|
|
1526
|
+
presentationArbiter.cancel(presentationId, "interactive_abort");
|
|
1527
|
+
});
|
|
1528
|
+
});
|
|
1529
|
+
},
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/** Extract the session id from a `<timestamp>_<uuid>.jsonl` session file path. */
|
|
1534
|
+
function sessionIdFromFile(file: string | undefined): string | undefined {
|
|
1535
|
+
if (!file) return undefined;
|
|
1536
|
+
const base = path.basename(file).replace(/\.jsonl$/, "");
|
|
1537
|
+
const underscore = base.indexOf("_");
|
|
1538
|
+
return underscore >= 0 ? base.slice(underscore + 1) : undefined;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function safeLifecycleRequestId(value: string | undefined): string | undefined {
|
|
1542
|
+
return value && /^[A-Za-z0-9._-]{1,128}$/.test(value) ? value : undefined;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function validateProviderDefinitions(capability: string, definitions: unknown): void {
|
|
1546
|
+
if (capability !== "host_tools" && capability !== "host_uri") return;
|
|
1547
|
+
const invalid = (message: string): never => {
|
|
1548
|
+
throw Object.assign(new Error(message), { code: "invalid_input" });
|
|
1549
|
+
};
|
|
1550
|
+
if (!Array.isArray(definitions)) invalid(`${capability} definitions must be an array.`);
|
|
1551
|
+
for (const definition of definitions as unknown[]) {
|
|
1552
|
+
if (!definition || typeof definition !== "object" || Array.isArray(definition))
|
|
1553
|
+
invalid(`${capability} definitions must contain objects.`);
|
|
1554
|
+
const entry = definition as Record<string, unknown>;
|
|
1555
|
+
if (capability === "host_tools") {
|
|
1556
|
+
if (typeof entry.name !== "string" || entry.name.trim() === "")
|
|
1557
|
+
invalid("host_tools definitions require a non-empty string name.");
|
|
1558
|
+
if (typeof entry.description !== "string") invalid("host_tools definitions require a string description.");
|
|
1559
|
+
if (!entry.parameters || typeof entry.parameters !== "object" || Array.isArray(entry.parameters))
|
|
1560
|
+
invalid("host_tools definitions require an object parameters.");
|
|
1561
|
+
} else if (
|
|
1562
|
+
typeof entry.scheme !== "string" ||
|
|
1563
|
+
!/^[a-z][a-z0-9+.-]*$/.test(entry.scheme) ||
|
|
1564
|
+
["http", "https", "file", "ws", "wss"].includes(entry.scheme)
|
|
1565
|
+
) {
|
|
1566
|
+
invalid("host_uri definitions require a non-reserved URI scheme.");
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
const UNINSTALLED_CONTROL_OPERATIONS = new Set(["auth.login", "host_tools.register", "host_uri.register"]);
|
|
1572
|
+
|
|
1573
|
+
const CONTROL_BINDINGS: Readonly<Record<string, string | undefined>> = {
|
|
1574
|
+
"model.cycle": "cycleModel",
|
|
1575
|
+
"thinking.cycle": "cycleThinkingLevel",
|
|
1576
|
+
"queue.steering_mode.set": "setQueueMode",
|
|
1577
|
+
"queue.follow_up_mode.set": "setQueueMode",
|
|
1578
|
+
"queue.interrupt_mode.set": "setQueueMode",
|
|
1579
|
+
"todo.replace": "sdkControl",
|
|
1580
|
+
"permission_mode.set": "sdkControl",
|
|
1581
|
+
"skill.invoke": "invokeSkill",
|
|
1582
|
+
"mode.plan.set": "setPlanMode",
|
|
1583
|
+
"mode.goal.operate": "operateGoal",
|
|
1584
|
+
|
|
1585
|
+
"compaction.auto.set": "sdkControl",
|
|
1586
|
+
"retry.auto.set": "sdkControl",
|
|
1587
|
+
"retry.abort": "sdkControl",
|
|
1588
|
+
"bash.execute": "sdkControl",
|
|
1589
|
+
"bash.abort": "sdkControl",
|
|
1590
|
+
"session.new": "sdkControl",
|
|
1591
|
+
"session.fork": "sdkControl",
|
|
1592
|
+
"session.resume": "sdkControl",
|
|
1593
|
+
"session.close": "sdkControl",
|
|
1594
|
+
"session.switch": "sdkControl",
|
|
1595
|
+
"session.branch": "sdkControl",
|
|
1596
|
+
"session.rename": "sdkControl",
|
|
1597
|
+
"session.handoff": "sdkControl",
|
|
1598
|
+
"session.export_html": "sdkControl",
|
|
1599
|
+
"runtime.reload": "sdkControl",
|
|
1600
|
+
"service_tier.set": "sdkControl",
|
|
1601
|
+
"queue.message.remove": "sdkControl",
|
|
1602
|
+
"queue.message.move": "sdkControl",
|
|
1603
|
+
"queue.message.update": "sdkControl",
|
|
1604
|
+
"extension.set_enabled": "sdkControl",
|
|
1605
|
+
"session.delete": "sdkControl",
|
|
1606
|
+
"session.cwd.move": "sdkControl",
|
|
1607
|
+
"retry.last": "sdkControl",
|
|
1608
|
+
"retry.now": "sdkControl",
|
|
1609
|
+
"bash.background": "sdkControl",
|
|
1610
|
+
};
|
|
1611
|
+
const QUERY_BINDINGS: Readonly<Record<string, string | undefined>> = {
|
|
1612
|
+
"skill.list/state": "getSkillState",
|
|
1613
|
+
"config.list/get": "getConfigItems",
|
|
1614
|
+
"session.branch_candidates": "getBranchCandidates",
|
|
1615
|
+
"extensions.list": "getExtensions",
|
|
1616
|
+
"artifact.read": "getArtifactRange",
|
|
1617
|
+
|
|
1618
|
+
"runtime.jobs.list": "getJobs",
|
|
1619
|
+
};
|
|
1620
|
+
|
|
1621
|
+
function hasTerminalArbitrationCapability(
|
|
1622
|
+
workflowGate: WorkflowGateEmitter | undefined,
|
|
1623
|
+
): workflowGate is WorkflowGateEmitter &
|
|
1624
|
+
Required<
|
|
1625
|
+
Pick<
|
|
1626
|
+
WorkflowGateEmitter,
|
|
1627
|
+
"resolveGate" | "prepareTerminalization" | "clearPreparedTerminalization" | "registerGateTerminalController"
|
|
1628
|
+
>
|
|
1629
|
+
> {
|
|
1630
|
+
return (
|
|
1631
|
+
typeof workflowGate?.resolveGate === "function" &&
|
|
1632
|
+
typeof workflowGate.prepareTerminalization === "function" &&
|
|
1633
|
+
typeof workflowGate.clearPreparedTerminalization === "function" &&
|
|
1634
|
+
typeof workflowGate.registerGateTerminalController === "function"
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function installedOperations(ctx: ExtensionContext, kind: "control" | "query"): Set<string> {
|
|
1639
|
+
const bindings = new Set(ctx.sdkBindings?.() ?? []);
|
|
1640
|
+
const required = kind === "control" ? CONTROL_BINDINGS : QUERY_BINDINGS;
|
|
1641
|
+
const candidates = OPERATIONS.filter(
|
|
1642
|
+
operation =>
|
|
1643
|
+
operation.kind === kind &&
|
|
1644
|
+
(kind !== "control" ||
|
|
1645
|
+
(!UNINSTALLED_CONTROL_OPERATIONS.has(operation.sdkId) &&
|
|
1646
|
+
((operation.sdkId !== "workflow.gate_answer" && operation.sdkId !== "workflow.plan_approve") ||
|
|
1647
|
+
hasTerminalArbitrationCapability(ctx.workflowGate)) &&
|
|
1648
|
+
(!required[operation.sdkId] || bindings.has(required[operation.sdkId]!)))),
|
|
1649
|
+
);
|
|
1650
|
+
return new Set(candidates.map(operation => operation.sdkId));
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
function sdkQuerySurface(
|
|
1654
|
+
ctx: ExtensionContext,
|
|
1655
|
+
id: string,
|
|
1656
|
+
api: ExtensionAPI,
|
|
1657
|
+
getInstalledDefinitions: (capability: string) => unknown | undefined = () => undefined,
|
|
1658
|
+
getLiveState: () => { isStreaming: boolean; steeringQueueDepth: number; followupQueueDepth: number } = () => ({
|
|
1659
|
+
isStreaming: false,
|
|
1660
|
+
steeringQueueDepth: 0,
|
|
1661
|
+
followupQueueDepth: 0,
|
|
1662
|
+
}),
|
|
1663
|
+
configOverrides: ReadonlyMap<string, unknown> = new Map(),
|
|
1664
|
+
): SessionSurface {
|
|
1665
|
+
const metadata = () => ({
|
|
1666
|
+
sessionId: id,
|
|
1667
|
+
name: ctx.sessionManager.getSessionName(),
|
|
1668
|
+
cwd: ctx.cwd,
|
|
1669
|
+
kind: ctx.sessionMetadata?.kind ?? "main",
|
|
1670
|
+
});
|
|
1671
|
+
const lastAssistantText = () => {
|
|
1672
|
+
for (const entry of ctx.sessionManager.getBranch().toReversed()) {
|
|
1673
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
1674
|
+
const { content } = entry.message;
|
|
1675
|
+
if (typeof content === "string") return content;
|
|
1676
|
+
if (Array.isArray(content))
|
|
1677
|
+
return content
|
|
1678
|
+
.filter(
|
|
1679
|
+
(block): block is { type: "text"; text: string } =>
|
|
1680
|
+
block.type === "text" && typeof block.text === "string",
|
|
1681
|
+
)
|
|
1682
|
+
.map(block => block.text)
|
|
1683
|
+
.join("");
|
|
1684
|
+
}
|
|
1685
|
+
return undefined;
|
|
1686
|
+
};
|
|
1687
|
+
const getDiff = async () => {
|
|
1688
|
+
try {
|
|
1689
|
+
const { stdout } = await execFileAsync("git", ["diff", "--no-ext-diff"], {
|
|
1690
|
+
cwd: ctx.cwd,
|
|
1691
|
+
maxBuffer: 1024 * 1024,
|
|
1692
|
+
});
|
|
1693
|
+
return stdout
|
|
1694
|
+
.split(/^diff --git /m)
|
|
1695
|
+
.filter(Boolean)
|
|
1696
|
+
.map(section => {
|
|
1697
|
+
const header = section.split("\n", 1)[0] ?? "";
|
|
1698
|
+
const match = /a\/(.+?) b\/(.+)$/.exec(header);
|
|
1699
|
+
return { id: match?.[2] ?? header, path: match?.[2] ?? header, body: `diff --git ${section}` };
|
|
1700
|
+
});
|
|
1701
|
+
} catch (error) {
|
|
1702
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1703
|
+
const stderr = error && typeof error === "object" && "stderr" in error ? String(error.stderr ?? "") : "";
|
|
1704
|
+
if (/not a git repository/i.test(`${detail}\n${stderr}`))
|
|
1705
|
+
throw new DiffQueryError("not_git_repository", "diff queries require a Git working tree");
|
|
1706
|
+
if (/maxbuffer|ERR_CHILD_PROCESS_STDIO_MAXBUFFER/i.test(detail))
|
|
1707
|
+
throw new DiffQueryError("diff_too_large", "diff exceeds the 1 MiB query limit");
|
|
1708
|
+
throw error;
|
|
1709
|
+
}
|
|
1710
|
+
};
|
|
1711
|
+
return {
|
|
1712
|
+
getTranscriptEntries: () =>
|
|
1713
|
+
typeof (ctx as Partial<ExtensionContext>).getTranscript === "function" ? ctx.getTranscript() : [],
|
|
1714
|
+
getContextSnapshot: () => ({
|
|
1715
|
+
usage: ctx.getContextUsage(),
|
|
1716
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
1717
|
+
...getLiveState(),
|
|
1718
|
+
}),
|
|
1719
|
+
getGoalState: () =>
|
|
1720
|
+
typeof (ctx as Partial<ExtensionContext>).getGoalState === "function" ? ctx.getGoalState() : undefined,
|
|
1721
|
+
getTodoState: () =>
|
|
1722
|
+
typeof (ctx as Partial<ExtensionContext>).getTodoState === "function" ? ctx.getTodoState() : [],
|
|
1723
|
+
getDiff,
|
|
1724
|
+
getUsage: () => ctx.sessionManager.getUsageStatistics(),
|
|
1725
|
+
getModels: () => {
|
|
1726
|
+
const models = ctx.modelRegistry.getAll();
|
|
1727
|
+
const currentModel = ctx.model;
|
|
1728
|
+
const currentThinkingLevel = api.getThinkingLevel();
|
|
1729
|
+
return projectQ10Models({ models, currentModel, currentThinkingLevel });
|
|
1730
|
+
},
|
|
1731
|
+
getSkillState: () => ctx.getSkillState(),
|
|
1732
|
+
getGates: () => {
|
|
1733
|
+
const workflowGate = ctx.workflowGate;
|
|
1734
|
+
if (!workflowGate) return [];
|
|
1735
|
+
return (
|
|
1736
|
+
workflowGate.listWorkflowGateQueryRecords?.() ??
|
|
1737
|
+
workflowGate.listPendingGates?.().map(gate => ({
|
|
1738
|
+
...gate,
|
|
1739
|
+
id: `pending:${gate.gate_id}`,
|
|
1740
|
+
tag: "pending" as const,
|
|
1741
|
+
})) ??
|
|
1742
|
+
[]
|
|
1743
|
+
);
|
|
1744
|
+
},
|
|
1745
|
+
getConfigItems: () => {
|
|
1746
|
+
const items = ctx.getConfigItems();
|
|
1747
|
+
return items && typeof items === "object" && !Array.isArray(items)
|
|
1748
|
+
? { ...(items as Record<string, unknown>), ...Object.fromEntries(configOverrides) }
|
|
1749
|
+
: items;
|
|
1750
|
+
},
|
|
1751
|
+
|
|
1752
|
+
getSessionMetadata: metadata,
|
|
1753
|
+
getStats: () => ctx.sessionManager.getUsageStatistics(),
|
|
1754
|
+
getBranchCandidates: () => ctx.getBranchCandidates(),
|
|
1755
|
+
getLastAssistant: lastAssistantText,
|
|
1756
|
+
|
|
1757
|
+
getCapabilities: () => ({
|
|
1758
|
+
operations: [...installedOperations(ctx, "control"), ...installedOperations(ctx, "query")],
|
|
1759
|
+
hostTools: getInstalledDefinitions("host_tools") !== undefined,
|
|
1760
|
+
}),
|
|
1761
|
+
getAuthProviders: () => [...new Set(ctx.modelRegistry.getAll().map(model => model.provider))],
|
|
1762
|
+
getTools: () => {
|
|
1763
|
+
const tools = typeof (ctx as Partial<ExtensionContext>).getAllTools === "function" ? ctx.getAllTools() : [];
|
|
1764
|
+
return tools.length > 0 ? tools : (getInstalledDefinitions("host_tools") ?? []);
|
|
1765
|
+
},
|
|
1766
|
+
getQueueMessages: () => ctx.getQueuedMessages(),
|
|
1767
|
+
getExtensions: () => ctx.getExtensions(),
|
|
1768
|
+
getArtifactRange: (id, offset, length) => ctx.getArtifactRange?.(id, offset, length),
|
|
1769
|
+
getJobs: () => ctx.getJobs(),
|
|
1770
|
+
installedQueries: installedOperations(ctx, "query"),
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
function containsSecretConfigKey(value: unknown, seen = new Set<object>()): boolean {
|
|
1775
|
+
if (!value || typeof value !== "object") return false;
|
|
1776
|
+
if (seen.has(value)) return false;
|
|
1777
|
+
seen.add(value);
|
|
1778
|
+
if (Array.isArray(value)) return value.some(item => containsSecretConfigKey(item, seen));
|
|
1779
|
+
return Object.entries(value as Record<string, unknown>).some(
|
|
1780
|
+
([key, nested]) =>
|
|
1781
|
+
/(?:token|secret|password|api[_-]?key|credential|authorization)/i.test(key) ||
|
|
1782
|
+
containsSecretConfigKey(nested, seen),
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
function sdkControlSurface(
|
|
1787
|
+
ctx: ExtensionContext,
|
|
1788
|
+
pendingInteractive: Map<string, PendingInteractiveAsk>,
|
|
1789
|
+
gatePresentations: PresentationArbiter | undefined,
|
|
1790
|
+
api: ExtensionAPI,
|
|
1791
|
+
isBusy: () => boolean,
|
|
1792
|
+
onPromptAccepted: (
|
|
1793
|
+
correlation: { commandId: string; turnId: string },
|
|
1794
|
+
requesterConnectionId?: string,
|
|
1795
|
+
) => void = () => {},
|
|
1796
|
+
onPromptFailed: (correlation: { commandId: string; turnId: string }, error: unknown) => void = () => {},
|
|
1797
|
+
acceptGateResolution: () => boolean,
|
|
1798
|
+
trackGateResolution: <T>(resolution: Promise<T>) => Promise<T>,
|
|
1799
|
+
settings?: Settings,
|
|
1800
|
+
configOverrides: Map<string, unknown> = new Map(),
|
|
1801
|
+
configRevision: { current: number } = { current: 0 },
|
|
1802
|
+
): ControlSurface {
|
|
1803
|
+
const unavailable = (operation: string, reason: string) => () => {
|
|
1804
|
+
throw Object.assign(new Error(`${operation} is unavailable: ${reason}`), { code: "unavailable" });
|
|
1805
|
+
};
|
|
1806
|
+
const bindings = new Set(ctx.sdkBindings?.() ?? []);
|
|
1807
|
+
const missingExpectedSessionAudits = new Set<"workflow.gate_answer" | "workflow.plan_approve">();
|
|
1808
|
+
const auditMissingExpectedSessionId = (operation: "workflow.gate_answer" | "workflow.plan_approve") => {
|
|
1809
|
+
if (missingExpectedSessionAudits.has(operation)) return;
|
|
1810
|
+
missingExpectedSessionAudits.add(operation);
|
|
1811
|
+
logger.warn("workflow_control_missing_expected_session_id", { operation });
|
|
1812
|
+
};
|
|
1813
|
+
const reconcileUnknownGateFailure = (gateId: string): "pending" | "terminal" | "unavailable" => {
|
|
1814
|
+
const pending = ctx.workflowGate?.listPendingGates;
|
|
1815
|
+
if (!pending) return "unavailable";
|
|
1816
|
+
try {
|
|
1817
|
+
return pending().some(gate => gate.gate_id === gateId) ? "pending" : "terminal";
|
|
1818
|
+
} catch {
|
|
1819
|
+
logger.warn("workflow_gate_reconciliation_unavailable", { gateId });
|
|
1820
|
+
return "unavailable";
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
const reconcileDirectControlFailure = (gateId: string): DirectControlOutcome => {
|
|
1824
|
+
const durable = reconcileUnknownGateFailure(gateId);
|
|
1825
|
+
if (durable === "pending") return "rejected";
|
|
1826
|
+
if (durable === "terminal") return "accepted";
|
|
1827
|
+
try {
|
|
1828
|
+
ctx.workflowGate?.quarantineGate?.(gateId);
|
|
1829
|
+
} catch {
|
|
1830
|
+
// The local arbiter still fails closed when the durable fence is unavailable.
|
|
1831
|
+
}
|
|
1832
|
+
return "unknown";
|
|
1833
|
+
};
|
|
1834
|
+
const sendSteer = (text: string) => {
|
|
1835
|
+
api.sendUserMessage(text, { deliverAs: "steer" });
|
|
1836
|
+
return { commandId: crypto.randomUUID(), accepted: true };
|
|
1837
|
+
};
|
|
1838
|
+
const resolveModel = (id: string) => {
|
|
1839
|
+
const [provider, ...modelId] = id.split("/");
|
|
1840
|
+
const model =
|
|
1841
|
+
modelId.length > 0
|
|
1842
|
+
? ctx.modelRegistry.find(provider, modelId.join("/"))
|
|
1843
|
+
: ctx.modelRegistry.getAll().find(candidate => candidate.id === id);
|
|
1844
|
+
if (!model) throw Object.assign(new Error(`Model ${id} was not found.`), { code: "invalid_input" });
|
|
1845
|
+
return model;
|
|
1846
|
+
};
|
|
1847
|
+
const unavailablePerSession = (operation: string) =>
|
|
1848
|
+
unavailable(operation, "the registry classifies it outside the per-session extension host");
|
|
1849
|
+
const typed = (operation: string, input: Record<string, unknown> = {}) => {
|
|
1850
|
+
if (!bindings.has("sdkControl") || !ctx.sdkControl)
|
|
1851
|
+
return unavailable(operation, "no typed session seam is installed")();
|
|
1852
|
+
return ctx.sdkControl(operation, input);
|
|
1853
|
+
};
|
|
1854
|
+
const pendingPreflightCancellations = new Set<() => void>();
|
|
1855
|
+
const cancelPendingPreflights = () => {
|
|
1856
|
+
for (const cancel of pendingPreflightCancellations) cancel();
|
|
1857
|
+
};
|
|
1858
|
+
const isSessionBusy = () => isBusy() || ctx.isIdle?.() === false;
|
|
1859
|
+
const awaitAbortReady = async () => {
|
|
1860
|
+
cancelPendingPreflights();
|
|
1861
|
+
await (ctx.abort as () => unknown)();
|
|
1862
|
+
while (isSessionBusy()) {
|
|
1863
|
+
await Bun.sleep(10);
|
|
1864
|
+
}
|
|
1865
|
+
};
|
|
1866
|
+
const submitPrompt = async (
|
|
1867
|
+
text: string,
|
|
1868
|
+
images: unknown,
|
|
1869
|
+
forceFresh = false,
|
|
1870
|
+
deliverAs?: "steer" | "followUp",
|
|
1871
|
+
rejectWhenBusy = false,
|
|
1872
|
+
requesterConnectionId?: string,
|
|
1873
|
+
) => {
|
|
1874
|
+
if (forceFresh && isSessionBusy()) {
|
|
1875
|
+
throw Object.assign(new Error("Previous turn did not finish aborting before replacement prompt submission."), {
|
|
1876
|
+
code: "busy",
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
if (rejectWhenBusy && isSessionBusy())
|
|
1880
|
+
throw Object.assign(
|
|
1881
|
+
new Error("turn.prompt is unavailable while the agent is busy; use turn.steer explicitly."),
|
|
1882
|
+
{
|
|
1883
|
+
code: "busy",
|
|
1884
|
+
},
|
|
1885
|
+
);
|
|
1886
|
+
const promptImages = Array.isArray(images) ? (images as { data: string; mimeType?: string }[]) : [];
|
|
1887
|
+
const content: string | (TextContent | ImageContent)[] =
|
|
1888
|
+
promptImages.length > 0
|
|
1889
|
+
? [
|
|
1890
|
+
...(text ? [{ type: "text", text } as TextContent] : []),
|
|
1891
|
+
...promptImages.map(
|
|
1892
|
+
img => ({ type: "image", data: img.data, mimeType: img.mimeType ?? "image/jpeg" }) as ImageContent,
|
|
1893
|
+
),
|
|
1894
|
+
]
|
|
1895
|
+
: text;
|
|
1896
|
+
const commandId = crypto.randomUUID();
|
|
1897
|
+
const turnId = crypto.randomUUID();
|
|
1898
|
+
type PreflightTerminalResult = { status: "accepted" } | { status: "rejected"; error: unknown };
|
|
1899
|
+
const preflight = Promise.withResolvers<PreflightTerminalResult>();
|
|
1900
|
+
let preflightSettled = false;
|
|
1901
|
+
let accepted = false;
|
|
1902
|
+
const correlation = { commandId, turnId };
|
|
1903
|
+
const settlePreflight = (result: PreflightTerminalResult) => {
|
|
1904
|
+
if (preflightSettled) return;
|
|
1905
|
+
preflightSettled = true;
|
|
1906
|
+
preflight.resolve(result);
|
|
1907
|
+
};
|
|
1908
|
+
const cancelPreflight = () =>
|
|
1909
|
+
settlePreflight({
|
|
1910
|
+
status: "rejected",
|
|
1911
|
+
error: Object.assign(new Error("Prompt preflight was cancelled before execution."), { code: "busy" }),
|
|
1912
|
+
});
|
|
1913
|
+
pendingPreflightCancellations.add(cancelPreflight);
|
|
1914
|
+
const onPreflightAccepted = () => {
|
|
1915
|
+
if (preflightSettled) return;
|
|
1916
|
+
accepted = true;
|
|
1917
|
+
onPromptAccepted(correlation, requesterConnectionId);
|
|
1918
|
+
settlePreflight({ status: "accepted" });
|
|
1919
|
+
};
|
|
1920
|
+
// Do not acknowledge the prompt until AgentSession's async preflight
|
|
1921
|
+
// succeeds. The terminal result records correlation before agent_start can fire.
|
|
1922
|
+
let submission: Promise<void> | undefined;
|
|
1923
|
+
try {
|
|
1924
|
+
submission = Promise.resolve(
|
|
1925
|
+
api.sendUserMessage(content, {
|
|
1926
|
+
...(deliverAs ? { deliverAs } : !forceFresh && isBusy() ? { deliverAs: "steer" as const } : {}),
|
|
1927
|
+
onPreflightAccepted,
|
|
1928
|
+
}),
|
|
1929
|
+
);
|
|
1930
|
+
} catch (error) {
|
|
1931
|
+
if (accepted) onPromptFailed(correlation, error);
|
|
1932
|
+
else settlePreflight({ status: "rejected", error });
|
|
1933
|
+
}
|
|
1934
|
+
if (submission) {
|
|
1935
|
+
void submission.then(
|
|
1936
|
+
() => {
|
|
1937
|
+
if (!accepted)
|
|
1938
|
+
settlePreflight({
|
|
1939
|
+
status: "rejected",
|
|
1940
|
+
error: Object.assign(new Error("Prompt submission completed without preflight acceptance."), {
|
|
1941
|
+
code: "busy",
|
|
1942
|
+
}),
|
|
1943
|
+
});
|
|
1944
|
+
},
|
|
1945
|
+
error => {
|
|
1946
|
+
if (accepted) onPromptFailed(correlation, error);
|
|
1947
|
+
else settlePreflight({ status: "rejected", error });
|
|
1948
|
+
},
|
|
1949
|
+
);
|
|
1950
|
+
}
|
|
1951
|
+
try {
|
|
1952
|
+
const result = await preflight.promise;
|
|
1953
|
+
if (result.status === "rejected") throw result.error;
|
|
1954
|
+
return { commandId, turnId, accepted: true };
|
|
1955
|
+
} finally {
|
|
1956
|
+
pendingPreflightCancellations.delete(cancelPreflight);
|
|
1957
|
+
}
|
|
1958
|
+
};
|
|
1959
|
+
const surface: ControlSurface & { cancelPendingPreflights(): void } = {
|
|
1960
|
+
prompt: (text, images) => submitPrompt(text, images, false, undefined, true, controlRequesterContext.getStore()),
|
|
1961
|
+
steer: text => sendSteer(text),
|
|
1962
|
+
followUp: text => submitPrompt(text, undefined, false, "followUp", false, controlRequesterContext.getStore()),
|
|
1963
|
+
abort: () => {
|
|
1964
|
+
cancelPendingPreflights();
|
|
1965
|
+
ctx.abort();
|
|
1966
|
+
return { aborted: true };
|
|
1967
|
+
},
|
|
1968
|
+
abortAndPrompt: async text => {
|
|
1969
|
+
await awaitAbortReady();
|
|
1970
|
+
return await submitPrompt(text, undefined, true, undefined, false, controlRequesterContext.getStore());
|
|
1971
|
+
},
|
|
1972
|
+
cancelPendingPreflights,
|
|
1973
|
+
answerAsk: (id, answer) => {
|
|
1974
|
+
const pending = pendingInteractive.get(id);
|
|
1975
|
+
if (!pending) throw Object.assign(new Error(`Ask ${id} was not found.`), { code: "resource_gone" });
|
|
1976
|
+
const outcome = pending.retireForDirectControl();
|
|
1977
|
+
if (outcome === "claimed")
|
|
1978
|
+
throw Object.assign(new Error("The active action is already being answered."), { code: "action_claimed" });
|
|
1979
|
+
if (outcome === "stale") throw Object.assign(new Error(`Ask ${id} was not found.`), { code: "resource_gone" });
|
|
1980
|
+
if (pendingInteractive.get(id) === pending) pendingInteractive.delete(id);
|
|
1981
|
+
pending.resolve(mapAnswerToLabel(JSON.stringify(answer), pending.options));
|
|
1982
|
+
pending.completeDirect();
|
|
1983
|
+
return { resolved: true };
|
|
1984
|
+
},
|
|
1985
|
+
answerGate: async (id, response, expectedSessionId) => {
|
|
1986
|
+
if (!acceptGateResolution())
|
|
1987
|
+
throw Object.assign(new Error("Workflow gate is no longer answerable."), { code: "resource_gone" });
|
|
1988
|
+
if (expectedSessionId === undefined) auditMissingExpectedSessionId("workflow.gate_answer");
|
|
1989
|
+
if (expectedSessionId !== undefined && expectedSessionId !== ctx.sessionManager.getSessionId())
|
|
1990
|
+
throw Object.assign(new Error("Workflow gate session does not match this endpoint."), {
|
|
1991
|
+
code: "resource_gone",
|
|
1992
|
+
});
|
|
1993
|
+
const presentations = gatePresentations;
|
|
1994
|
+
if (!presentations)
|
|
1995
|
+
throw Object.assign(new Error("Workflow gates are unavailable for this session."), {
|
|
1996
|
+
code: "resource_gone",
|
|
1997
|
+
});
|
|
1998
|
+
const prepared = presentations.prepareDirectControl(id);
|
|
1999
|
+
if (!prepared || prepared.status === "stale")
|
|
2000
|
+
throw Object.assign(new Error("Workflow gate is no longer answerable."), { code: "resource_gone" });
|
|
2001
|
+
if (prepared.status === "claimed")
|
|
2002
|
+
throw Object.assign(new Error("The active action is already being answered."), { code: "action_claimed" });
|
|
2003
|
+
if (prepared.status !== "queued" && prepared.status !== "retired")
|
|
2004
|
+
throw new Error(`Unexpected direct control preparation: ${prepared.status}`);
|
|
2005
|
+
if (
|
|
2006
|
+
ctx.workflowGate?.prepareTerminalization?.(
|
|
2007
|
+
id,
|
|
2008
|
+
prepared.status === "queued" ? "not_published" : "retired",
|
|
2009
|
+
) !== true
|
|
2010
|
+
) {
|
|
2011
|
+
presentations.finishDirectControl(id, prepared, "rejected");
|
|
2012
|
+
throw Object.assign(new Error("Workflow gate lacks a terminalization proof."), { code: "resource_gone" });
|
|
2013
|
+
}
|
|
2014
|
+
try {
|
|
2015
|
+
const resolution = await trackGateResolution(
|
|
2016
|
+
ctx.workflowGate?.resolveGate?.({
|
|
2017
|
+
gate_id: id,
|
|
2018
|
+
answer: response,
|
|
2019
|
+
idempotency_key: id,
|
|
2020
|
+
}) ?? unavailable("workflow.gate_answer", "workflow gates are unavailable for this session")(),
|
|
2021
|
+
);
|
|
2022
|
+
const status = (resolution as { status?: unknown }).status;
|
|
2023
|
+
if (status === "accepted" || status === "rejected") {
|
|
2024
|
+
if (status === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
|
|
2025
|
+
presentations.finishDirectControl(id, prepared, status);
|
|
2026
|
+
return resolution;
|
|
2027
|
+
}
|
|
2028
|
+
} catch (error) {
|
|
2029
|
+
const outcome = reconcileDirectControlFailure(id);
|
|
2030
|
+
if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
|
|
2031
|
+
presentations.finishDirectControl(id, prepared, outcome);
|
|
2032
|
+
if (outcome === "unknown")
|
|
2033
|
+
throw Object.assign(new Error("Workflow gate resolution outcome is uncertain."), {
|
|
2034
|
+
code: "terminal_uncertain",
|
|
2035
|
+
});
|
|
2036
|
+
throw error;
|
|
2037
|
+
}
|
|
2038
|
+
const outcome = reconcileDirectControlFailure(id);
|
|
2039
|
+
if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
|
|
2040
|
+
presentations.finishDirectControl(id, prepared, outcome);
|
|
2041
|
+
logger.warn("workflow_gate_direct_control_uncertain_outcome", {
|
|
2042
|
+
operation: "workflow.gate_answer",
|
|
2043
|
+
gateId: id,
|
|
2044
|
+
outcome,
|
|
2045
|
+
});
|
|
2046
|
+
throw Object.assign(new Error("Workflow gate resolution outcome is uncertain."), {
|
|
2047
|
+
code: "terminal_uncertain",
|
|
2048
|
+
});
|
|
2049
|
+
},
|
|
2050
|
+
approvePlan: async (id, choice, expectedSessionId) => {
|
|
2051
|
+
if (!acceptGateResolution())
|
|
2052
|
+
throw Object.assign(new Error("Workflow plan is no longer answerable."), { code: "resource_gone" });
|
|
2053
|
+
if (expectedSessionId === undefined) auditMissingExpectedSessionId("workflow.plan_approve");
|
|
2054
|
+
if (expectedSessionId !== undefined && expectedSessionId !== ctx.sessionManager.getSessionId())
|
|
2055
|
+
throw Object.assign(new Error("Workflow plan session does not match this endpoint."), {
|
|
2056
|
+
code: "resource_gone",
|
|
2057
|
+
});
|
|
2058
|
+
const presentations = gatePresentations;
|
|
2059
|
+
if (!presentations)
|
|
2060
|
+
throw Object.assign(new Error("Workflow gates are unavailable for this session."), {
|
|
2061
|
+
code: "resource_gone",
|
|
2062
|
+
});
|
|
2063
|
+
const prepared = presentations.prepareDirectControl(id);
|
|
2064
|
+
if (!prepared || prepared.status === "stale")
|
|
2065
|
+
throw Object.assign(new Error("Workflow plan is no longer answerable."), { code: "resource_gone" });
|
|
2066
|
+
if (prepared.status === "claimed")
|
|
2067
|
+
throw Object.assign(new Error("The active action is already being answered."), { code: "action_claimed" });
|
|
2068
|
+
if (prepared.status !== "queued" && prepared.status !== "retired")
|
|
2069
|
+
throw new Error(`Unexpected direct control preparation: ${prepared.status}`);
|
|
2070
|
+
if (
|
|
2071
|
+
ctx.workflowGate?.prepareTerminalization?.(
|
|
2072
|
+
id,
|
|
2073
|
+
prepared.status === "queued" ? "not_published" : "retired",
|
|
2074
|
+
) !== true
|
|
2075
|
+
) {
|
|
2076
|
+
presentations.finishDirectControl(id, prepared, "rejected");
|
|
2077
|
+
throw Object.assign(new Error("Workflow plan lacks a terminalization proof."), { code: "resource_gone" });
|
|
2078
|
+
}
|
|
2079
|
+
try {
|
|
2080
|
+
const resolution = await trackGateResolution(
|
|
2081
|
+
ctx.workflowGate?.resolveGate?.({
|
|
2082
|
+
gate_id: id,
|
|
2083
|
+
answer: choice,
|
|
2084
|
+
idempotency_key: id,
|
|
2085
|
+
}) ?? unavailable("workflow.plan_approve", "workflow gates are unavailable for this session")(),
|
|
2086
|
+
);
|
|
2087
|
+
const status = (resolution as { status?: unknown }).status;
|
|
2088
|
+
if (status === "accepted" || status === "rejected") {
|
|
2089
|
+
if (status === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
|
|
2090
|
+
presentations.finishDirectControl(id, prepared, status);
|
|
2091
|
+
return resolution;
|
|
2092
|
+
}
|
|
2093
|
+
} catch (error) {
|
|
2094
|
+
const outcome = reconcileDirectControlFailure(id);
|
|
2095
|
+
if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
|
|
2096
|
+
presentations.finishDirectControl(id, prepared, outcome);
|
|
2097
|
+
if (outcome === "unknown")
|
|
2098
|
+
throw Object.assign(new Error("Workflow plan resolution outcome is uncertain."), {
|
|
2099
|
+
code: "terminal_uncertain",
|
|
2100
|
+
});
|
|
2101
|
+
throw error;
|
|
2102
|
+
}
|
|
2103
|
+
const outcome = reconcileDirectControlFailure(id);
|
|
2104
|
+
if (outcome === "rejected") ctx.workflowGate?.clearPreparedTerminalization?.(id);
|
|
2105
|
+
presentations.finishDirectControl(id, prepared, outcome);
|
|
2106
|
+
logger.warn("workflow_gate_direct_control_uncertain_outcome", {
|
|
2107
|
+
operation: "workflow.plan_approve",
|
|
2108
|
+
gateId: id,
|
|
2109
|
+
outcome,
|
|
2110
|
+
});
|
|
2111
|
+
throw Object.assign(new Error("Workflow plan resolution outcome is uncertain."), {
|
|
2112
|
+
code: "terminal_uncertain",
|
|
2113
|
+
});
|
|
2114
|
+
},
|
|
2115
|
+
invokeSkill: (name, args) => {
|
|
2116
|
+
if (!bindings.has("invokeSkill") || !ctx.invokeSkill)
|
|
2117
|
+
return unavailable("skill.invoke", "no skill invocation seam is installed")();
|
|
2118
|
+
|
|
2119
|
+
if (typeof args !== "undefined" && typeof args !== "string")
|
|
2120
|
+
throw Object.assign(new Error("skill.invoke args must be a string."), { code: "invalid_input" });
|
|
2121
|
+
return ctx.invokeSkill(name, args);
|
|
2122
|
+
},
|
|
2123
|
+
setPlanMode: on => {
|
|
2124
|
+
if (!bindings.has("setPlanMode") || !ctx.setPlanMode)
|
|
2125
|
+
return unavailable("mode.plan.set", "no plan-mode seam is installed")();
|
|
2126
|
+
|
|
2127
|
+
if (typeof on !== "boolean")
|
|
2128
|
+
throw Object.assign(new Error("mode.plan.set requires a boolean on value."), { code: "invalid_input" });
|
|
2129
|
+
|
|
2130
|
+
return { state: ctx.setPlanMode(on) };
|
|
2131
|
+
},
|
|
2132
|
+
operateGoal: (op, objective) => {
|
|
2133
|
+
if (!bindings.has("operateGoal") || !ctx.operateGoal)
|
|
2134
|
+
return unavailable("mode.goal.operate", "no goal-mode seam is installed")();
|
|
2135
|
+
if (!["create", "get", "resume", "pause", "complete", "drop"].includes(op))
|
|
2136
|
+
throw Object.assign(new Error("mode.goal.operate requires a supported op."), { code: "invalid_input" });
|
|
2137
|
+
if (objective !== undefined && typeof objective !== "string")
|
|
2138
|
+
throw Object.assign(new Error("mode.goal.operate objective must be a string."), { code: "invalid_input" });
|
|
2139
|
+
return ctx.operateGoal(op as "create" | "get" | "resume" | "pause" | "complete" | "drop", objective);
|
|
2140
|
+
},
|
|
2141
|
+
replaceTodo: items => typed("todo.replace", { items }),
|
|
2142
|
+
setModel: async (id, requestedThinkingLevel) => {
|
|
2143
|
+
const model = resolveModel(id);
|
|
2144
|
+
if (requestedThinkingLevel === undefined) return { changed: await api.setModel(model) };
|
|
2145
|
+
const thinkingLevel =
|
|
2146
|
+
typeof requestedThinkingLevel === "string" ? parseThinkingLevel(requestedThinkingLevel) : undefined;
|
|
2147
|
+
if (!thinkingLevel || thinkingLevel === ThinkingLevel.Inherit)
|
|
2148
|
+
throw Object.assign(
|
|
2149
|
+
new Error("model.set thinkingLevel must be off, minimal, low, medium, high, xhigh, or max."),
|
|
2150
|
+
{ code: "invalid_input" },
|
|
2151
|
+
);
|
|
2152
|
+
return typed("model.set", { id: `${model.provider}/${model.id}`, thinkingLevel });
|
|
2153
|
+
},
|
|
2154
|
+
cycleModel: async () => {
|
|
2155
|
+
if (!bindings.has("cycleModel"))
|
|
2156
|
+
return unavailable("model.cycle", "no session model-cycle seam is installed")();
|
|
2157
|
+
return { changed: (await ctx.cycleModel()) !== undefined };
|
|
2158
|
+
},
|
|
2159
|
+
setThinking: level => {
|
|
2160
|
+
api.setThinkingLevel(level as ThinkingLevel);
|
|
2161
|
+
return { changed: true };
|
|
2162
|
+
},
|
|
2163
|
+
cycleThinking: () => {
|
|
2164
|
+
if (!bindings.has("cycleThinkingLevel"))
|
|
2165
|
+
return unavailable("thinking.cycle", "no session thinking-cycle seam is installed")();
|
|
2166
|
+
return { level: ctx.cycleThinkingLevel() };
|
|
2167
|
+
},
|
|
2168
|
+
setPermissionMode: mode => typed("permission_mode.set", { mode }),
|
|
2169
|
+
setQueueMode: (kind, mode) => {
|
|
2170
|
+
if (!bindings.has("setQueueMode"))
|
|
2171
|
+
return unavailable(`queue.${kind}_mode.set`, "no session queue-mode seam is installed")();
|
|
2172
|
+
if (!ctx.setQueueMode(kind as "steering" | "follow_up" | "interrupt", mode))
|
|
2173
|
+
throw Object.assign(new Error("Invalid queue mode."), { code: "invalid_input" });
|
|
2174
|
+
return { changed: true };
|
|
2175
|
+
},
|
|
2176
|
+
runCompaction: async () => {
|
|
2177
|
+
try {
|
|
2178
|
+
await ctx.compact();
|
|
2179
|
+
return { started: true };
|
|
2180
|
+
} catch (error) {
|
|
2181
|
+
throw Object.assign(
|
|
2182
|
+
new Error(error instanceof Error ? error.message : "Compaction is unavailable for the current state."),
|
|
2183
|
+
{ code: "invalid_request" },
|
|
2184
|
+
);
|
|
2185
|
+
}
|
|
2186
|
+
},
|
|
2187
|
+
setAutoCompaction: on => typed("compaction.auto.set", { on }),
|
|
2188
|
+
setAutoRetry: on => typed("retry.auto.set", { on }),
|
|
2189
|
+
abortRetry: () => typed("retry.abort"),
|
|
2190
|
+
executeBash: cmd => typed("bash.execute", { cmd }),
|
|
2191
|
+
abortBash: () => typed("bash.abort"),
|
|
2192
|
+
newSession: () => typed("session.new"),
|
|
2193
|
+
forkSession: () => typed("session.fork"),
|
|
2194
|
+
resumeSession: id => typed("session.resume", { id }),
|
|
2195
|
+
closeSession: () => typed("session.close"),
|
|
2196
|
+
switchSession: id => typed("session.switch", { id }),
|
|
2197
|
+
branchSession: entryId => typed("session.branch", { entryId }),
|
|
2198
|
+
renameSession: name => typed("session.rename", { name }),
|
|
2199
|
+
handoffSession: target => typed("session.handoff", { target }),
|
|
2200
|
+
exportHtml: () => typed("session.export_html"),
|
|
2201
|
+
patchConfig: patch => {
|
|
2202
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch))
|
|
2203
|
+
throw Object.assign(new Error("config.patch requires an object."), { code: "invalid_input" });
|
|
2204
|
+
if (containsSecretConfigKey(patch))
|
|
2205
|
+
throw Object.assign(new Error("config.patch rejects secret fields at the SDK host."), {
|
|
2206
|
+
code: "invalid_input",
|
|
2207
|
+
});
|
|
2208
|
+
if (!settings) return unavailable("config.patch", "configuration settings are unavailable for this session")();
|
|
2209
|
+
const entries = Object.entries(patch as Record<string, unknown>);
|
|
2210
|
+
for (const [key, value] of entries) settings.set(key as never, value as never);
|
|
2211
|
+
for (const [key, value] of entries) configOverrides.set(key, value);
|
|
2212
|
+
configRevision.current += 1;
|
|
2213
|
+
return { patched: entries.map(([key]) => key), revision: String(configRevision.current) };
|
|
2214
|
+
},
|
|
2215
|
+
|
|
2216
|
+
reloadRuntime: components => typed("runtime.reload", { components }),
|
|
2217
|
+
login: unavailablePerSession("auth.login"),
|
|
2218
|
+
registerHostTools: unavailablePerSession("host_tools.register"),
|
|
2219
|
+
registerHostUri: unavailablePerSession("host_uri.register"),
|
|
2220
|
+
setServiceTier: tier => typed("service_tier.set", { tier }),
|
|
2221
|
+
setActiveTools: async names => {
|
|
2222
|
+
await api.setActiveTools(
|
|
2223
|
+
Array.isArray(names) ? names.filter((name): name is string => typeof name === "string") : [],
|
|
2224
|
+
);
|
|
2225
|
+
return { changed: true };
|
|
2226
|
+
},
|
|
2227
|
+
removeQueueMessage: id => typed("queue.message.remove", { id }),
|
|
2228
|
+
moveQueueMessage: (id, position) => typed("queue.message.move", { id, ...position }),
|
|
2229
|
+
updateQueueMessage: (id, patch) => typed("queue.message.update", { id, patch }),
|
|
2230
|
+
setExtensionEnabled: (id, on) => typed("extension.set_enabled", { id, on }),
|
|
2231
|
+
clearContext: async confirm => {
|
|
2232
|
+
if (!confirm)
|
|
2233
|
+
throw Object.assign(new Error("context.clear requires confirmation."), { code: "confirmation_required" });
|
|
2234
|
+
return { cleared: await ctx.clearContext() };
|
|
2235
|
+
},
|
|
2236
|
+
deleteSession: (id, confirm) => {
|
|
2237
|
+
if (!confirm)
|
|
2238
|
+
throw Object.assign(new Error("session.delete requires confirmation."), { code: "confirmation_required" });
|
|
2239
|
+
return typed("session.delete", { id });
|
|
2240
|
+
},
|
|
2241
|
+
moveCwd: path => typed("session.cwd.move", { path }),
|
|
2242
|
+
retryLast: () => typed("retry.last"),
|
|
2243
|
+
retryNow: () => typed("retry.now"),
|
|
2244
|
+
backgroundBash: () => typed("bash.background"),
|
|
2245
|
+
installedOperations: installedOperations(ctx, "control"),
|
|
2246
|
+
revisionProvider: resource => (resource === "config" ? String(configRevision.current) : undefined),
|
|
2247
|
+
};
|
|
2248
|
+
return surface;
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/** Parse only v3 frames carried through the existing control-command seam. */
|
|
2252
|
+
function sdkInboundFrame(commandJson: string | undefined): Record<string, unknown> | undefined {
|
|
2253
|
+
if (!commandJson) return undefined;
|
|
2254
|
+
try {
|
|
2255
|
+
const frame = JSON.parse(commandJson) as unknown;
|
|
2256
|
+
if (!frame || typeof frame !== "object") return undefined;
|
|
2257
|
+
const type = (frame as Record<string, unknown>).type;
|
|
2258
|
+
return type === "control_request" ||
|
|
2259
|
+
type === "query_request" ||
|
|
2260
|
+
type === "event_replay" ||
|
|
2261
|
+
type === "register_provider" ||
|
|
2262
|
+
type === "provider_heartbeat" ||
|
|
2263
|
+
type === "lease_release" ||
|
|
2264
|
+
type === "reverse_response"
|
|
2265
|
+
? (frame as Record<string, unknown>)
|
|
2266
|
+
: undefined;
|
|
2267
|
+
} catch {
|
|
2268
|
+
return undefined;
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
export function createNotificationsExtension(
|
|
2273
|
+
api: ExtensionAPI,
|
|
2274
|
+
options: {
|
|
2275
|
+
settings?: Settings;
|
|
2276
|
+
ensureTelegramDaemon?: (input: {
|
|
2277
|
+
settings: Settings;
|
|
2278
|
+
cwd: string;
|
|
2279
|
+
sessionId: string;
|
|
2280
|
+
}) => Promise<EnsureDaemonResult>;
|
|
2281
|
+
/** Suppress auto-delivery for a GJC-spawned child under `sessionScope=primary`. */
|
|
2282
|
+
spawnedByGjc?: boolean;
|
|
2283
|
+
controller?: NotificationSessionController;
|
|
2284
|
+
|
|
2285
|
+
onSdkRequest?: (kind: "control" | "query", connectionId: string, frame: Record<string, unknown>) => void;
|
|
2286
|
+
} = {},
|
|
2287
|
+
): void {
|
|
2288
|
+
const lifecycleStartupCapability = lifecycleStartupCapabilityForApi(api);
|
|
2289
|
+
const runtimes = new Map<string, SessionRuntime>();
|
|
2290
|
+
const controller =
|
|
2291
|
+
options.controller ??
|
|
2292
|
+
new NotificationSessionController({
|
|
2293
|
+
eligible: true,
|
|
2294
|
+
getConfig: () => resolveSettings(options.settings).cfg,
|
|
2295
|
+
});
|
|
2296
|
+
|
|
2297
|
+
const sessionStartPromises = new Map<string, Promise<SessionStartResult>>();
|
|
2298
|
+
let activeRuntimeId: string | undefined;
|
|
2299
|
+
let identityControlInFlight = false;
|
|
2300
|
+
let deferredIdentityRotation: { event: { previousSessionFile?: string }; ctx: ExtensionContext } | undefined;
|
|
2301
|
+
|
|
2302
|
+
async function ensureTelegramOwner(
|
|
2303
|
+
settings: Settings,
|
|
2304
|
+
cwd: string,
|
|
2305
|
+
id: string,
|
|
2306
|
+
): Promise<"ready" | "blocked_identity"> {
|
|
2307
|
+
if (options.ensureTelegramDaemon) {
|
|
2308
|
+
return (await options.ensureTelegramDaemon({ settings, cwd, sessionId: id })) === "blocked"
|
|
2309
|
+
? "blocked_identity"
|
|
2310
|
+
: "ready";
|
|
2311
|
+
}
|
|
2312
|
+
return (await ensureTelegramDaemonRunningDetailed({ settings, cwd, sessionId: id })) === "blocked_identity"
|
|
2313
|
+
? "blocked_identity"
|
|
2314
|
+
: "ready";
|
|
2315
|
+
}
|
|
2316
|
+
async function ensureConfiguredDaemons(
|
|
2317
|
+
settings: Settings,
|
|
2318
|
+
cfg: NotificationConfig,
|
|
2319
|
+
cwd: string,
|
|
2320
|
+
id: string,
|
|
2321
|
+
): Promise<boolean> {
|
|
2322
|
+
if (isTelegramConfigured(cfg)) {
|
|
2323
|
+
if ((await ensureTelegramOwner(settings, cwd, id)) === "blocked_identity") return false;
|
|
2324
|
+
}
|
|
2325
|
+
if (isDiscordConfigured(cfg)) await ensureDiscordDaemon(settings);
|
|
2326
|
+
if (isSlackConfigured(cfg)) await ensureSlackDaemon(settings);
|
|
2327
|
+
return true;
|
|
2328
|
+
}
|
|
2329
|
+
const identityControlOperations = new Set([
|
|
2330
|
+
"session.new",
|
|
2331
|
+
"session.fork",
|
|
2332
|
+
"session.resume",
|
|
2333
|
+
"session.switch",
|
|
2334
|
+
"session.branch",
|
|
2335
|
+
]);
|
|
2336
|
+
const sessionId = (ctx: ExtensionContext): string => ctx.sessionManager.getSessionId();
|
|
2337
|
+
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
|
|
2338
|
+
|
|
2339
|
+
async function stopSession(
|
|
2340
|
+
id: string,
|
|
2341
|
+
reason: "session" | "notifications" = "session",
|
|
2342
|
+
expectedRuntime?: SessionRuntime,
|
|
2343
|
+
): Promise<boolean> {
|
|
2344
|
+
const requestedRuntime = runtimes.get(id);
|
|
2345
|
+
if (expectedRuntime && requestedRuntime !== expectedRuntime) return false;
|
|
2346
|
+
if (reason === "session" && requestedRuntime) requestedRuntime.stopping = true;
|
|
2347
|
+
if (reason === "session" && requestedRuntime) {
|
|
2348
|
+
// Fence the exact runtime before awaiting its startup promise: a late start
|
|
2349
|
+
// must observe removal and clean itself up rather than becoming reachable.
|
|
2350
|
+
runtimes.delete(id);
|
|
2351
|
+
if (activeRuntimeId === id) activeRuntimeId = undefined;
|
|
2352
|
+
}
|
|
2353
|
+
const pendingStart = sessionStartPromises.get(id);
|
|
2354
|
+
if (pendingStart)
|
|
2355
|
+
void pendingStart
|
|
2356
|
+
.catch(() => {})
|
|
2357
|
+
.then(() => {
|
|
2358
|
+
if (runtimes.get(id) === requestedRuntime) void stopSession(id, reason, requestedRuntime);
|
|
2359
|
+
});
|
|
2360
|
+
const rt = requestedRuntime;
|
|
2361
|
+
|
|
2362
|
+
if (expectedRuntime && rt !== expectedRuntime) return false;
|
|
2363
|
+
|
|
2364
|
+
if (!rt) {
|
|
2365
|
+
if (activeRuntimeId === id) activeRuntimeId = undefined;
|
|
2366
|
+
return false;
|
|
2367
|
+
}
|
|
2368
|
+
if (reason === "notifications" && rt.host.started) {
|
|
2369
|
+
rt.notificationsActive = false;
|
|
2370
|
+
try {
|
|
2371
|
+
rt.disposeAnswerSource();
|
|
2372
|
+
} catch {}
|
|
2373
|
+
try {
|
|
2374
|
+
rt.disposeFileSink();
|
|
2375
|
+
} catch {}
|
|
2376
|
+
rt.gatePresentations?.cancelInteractive();
|
|
2377
|
+
for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
|
|
2378
|
+
rt.pendingInteractive.clear();
|
|
2379
|
+
return true;
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
try {
|
|
2383
|
+
rt.cancelPostmortemCleanup();
|
|
2384
|
+
} catch {}
|
|
2385
|
+
try {
|
|
2386
|
+
rt.stopBrokerHeartbeat();
|
|
2387
|
+
} catch {}
|
|
2388
|
+
try {
|
|
2389
|
+
rt.disposeAnswerSource();
|
|
2390
|
+
} catch {}
|
|
2391
|
+
try {
|
|
2392
|
+
rt.disposeFileSink();
|
|
2393
|
+
} catch {}
|
|
2394
|
+
try {
|
|
2395
|
+
rt.disposeGateListener();
|
|
2396
|
+
} catch {}
|
|
2397
|
+
await rt.waitForGateResolutionQuiescence();
|
|
2398
|
+
try {
|
|
2399
|
+
rt.disposeAckRecoveryParticipant();
|
|
2400
|
+
} catch {}
|
|
2401
|
+
try {
|
|
2402
|
+
rt.disposeGateEmitterListener();
|
|
2403
|
+
} catch {}
|
|
2404
|
+
rt.gatePresentations?.dispose();
|
|
2405
|
+
try {
|
|
2406
|
+
rt.disposeGateTerminalController();
|
|
2407
|
+
} catch {}
|
|
2408
|
+
let hostStopped = false;
|
|
2409
|
+
let brokerRegistrationReleased = false;
|
|
2410
|
+
|
|
2411
|
+
try {
|
|
2412
|
+
const stopped = await rt.host.stop();
|
|
2413
|
+
hostStopped = stopped === "stopped";
|
|
2414
|
+
brokerRegistrationReleased = !rt.brokerRegistrationActive || stopped === "stopped";
|
|
2415
|
+
if (rt.brokerRegistrationActive && stopped === "stopped") rt.brokerRegistrationActive = false;
|
|
2416
|
+
} catch (e) {
|
|
2417
|
+
logger.warn(`sdk host: stop failed: ${String(e)}`);
|
|
2418
|
+
}
|
|
2419
|
+
rt.host.reverse.dispose();
|
|
2420
|
+
// Resolve any still-pending interactive asks so the ask tool is not left hanging.
|
|
2421
|
+
for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
|
|
2422
|
+
rt.pendingInteractive.clear();
|
|
2423
|
+
try {
|
|
2424
|
+
rt.cursors.close();
|
|
2425
|
+
await rt.revisions.close();
|
|
2426
|
+
} catch (e) {
|
|
2427
|
+
logger.warn(`sdk query snapshots: close failed: ${String(e)}`);
|
|
2428
|
+
}
|
|
2429
|
+
let closeFrameSent = false;
|
|
2430
|
+
try {
|
|
2431
|
+
pushSessionFrame(rt, { type: "session_closed", sessionId: id });
|
|
2432
|
+
closeFrameSent = true;
|
|
2433
|
+
} catch (e) {
|
|
2434
|
+
logger.warn(`notifications: session_closed failed: ${String(e)}`);
|
|
2435
|
+
}
|
|
2436
|
+
if (closeFrameSent) await sleep(100);
|
|
2437
|
+
let serverStopped = false;
|
|
2438
|
+
try {
|
|
2439
|
+
rt.server.stop();
|
|
2440
|
+
serverStopped = true;
|
|
2441
|
+
} catch (e) {
|
|
2442
|
+
logger.warn(`notifications: stop failed: ${String(e)}`);
|
|
2443
|
+
}
|
|
2444
|
+
lifecycleStartupCapability?.rollback?.recordStop(rt.host.generation, {
|
|
2445
|
+
runtimeRemoved: true,
|
|
2446
|
+
hostStopped: hostStopped && serverStopped,
|
|
2447
|
+
brokerRegistrationReleased,
|
|
2448
|
+
});
|
|
2449
|
+
return true;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
function isNotificationEligibleContext(ctx: ExtensionContext): boolean {
|
|
2453
|
+
return ctx.sessionMetadata?.kind !== "sub";
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
async function startSession(ctx: ExtensionContext): Promise<SessionStartResult> {
|
|
2457
|
+
const id = sessionId(ctx);
|
|
2458
|
+
const lifecycleRequestId = safeLifecycleRequestId(process.env.GJC_LIFECYCLE_REQUEST_ID);
|
|
2459
|
+
const { settings, cfg, settingsAvailable } = resolveSettings(options.settings);
|
|
2460
|
+
const notificationsEnabledForSession = controller.query(ctx).effectiveEnabled;
|
|
2461
|
+
const sdkEnabledForSession = shouldHostSdk(settings, isNotificationEligibleContext(ctx));
|
|
2462
|
+
const lifecycleRequired = lifecycleStartupCapability !== undefined;
|
|
2463
|
+
const failLifecycleStartup = (
|
|
2464
|
+
reason: "disabled" | "ineligible" | "failed",
|
|
2465
|
+
error?: unknown,
|
|
2466
|
+
): SessionStartResult => {
|
|
2467
|
+
const failure =
|
|
2468
|
+
lifecycleStartupCapability?.normalizeFailure("startup", reason, error) ??
|
|
2469
|
+
normalizeSdkStartupFailure("startup", reason, error);
|
|
2470
|
+
|
|
2471
|
+
lifecycleStartupCapability?.settleFailure(failure);
|
|
2472
|
+
return { status: reason === "disabled" ? "disabled" : "failed", failure };
|
|
2473
|
+
};
|
|
2474
|
+
const throwIfLifecycleStopped = (): void => {
|
|
2475
|
+
if (lifecycleStartupCapability?.cancelled || runtime?.stopping || runtimes.get(id) !== runtime)
|
|
2476
|
+
throw new Error("Lifecycle SDK startup was cancelled.");
|
|
2477
|
+
};
|
|
2478
|
+
|
|
2479
|
+
if (
|
|
2480
|
+
!lifecycleRequired &&
|
|
2481
|
+
(!isNotificationEligibleContext(ctx) || (!notificationsEnabledForSession && !sdkEnabledForSession))
|
|
2482
|
+
)
|
|
2483
|
+
return { status: "disabled" };
|
|
2484
|
+
if (lifecycleRequired && !isNotificationEligibleContext(ctx)) return failLifecycleStartup("ineligible");
|
|
2485
|
+
const pendingStart = sessionStartPromises.get(id);
|
|
2486
|
+
if (pendingStart) return pendingStart;
|
|
2487
|
+
const existingRuntime = runtimes.get(id);
|
|
2488
|
+
if (existingRuntime) {
|
|
2489
|
+
activeRuntimeId = id;
|
|
2490
|
+
if (lifecycleRequired) {
|
|
2491
|
+
if (existingRuntime.host.started) lifecycleStartupCapability?.settleStarted();
|
|
2492
|
+
else return failLifecycleStartup("failed", "SDK host is not started.");
|
|
2493
|
+
}
|
|
2494
|
+
return { status: "already", runtime: existingRuntime };
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
const stateRoot = path.join(ctx.cwd, ".gjc", "state");
|
|
2498
|
+
const lifecycleAgentDir = lifecycleRequired ? settings?.getAgentDir?.() : undefined;
|
|
2499
|
+
if (lifecycleRequired && !lifecycleAgentDir)
|
|
2500
|
+
return failLifecycleStartup("failed", "Lifecycle SDK startup requires an agent directory.");
|
|
2501
|
+
|
|
2502
|
+
const gateOptions = new Map<string, string[]>();
|
|
2503
|
+
const pendingInteractive = new Map<string, PendingInteractiveAsk>();
|
|
2504
|
+
const pendingPromptCorrelations: Array<{ commandId: string; turnId: string }> = [];
|
|
2505
|
+
const tag = sessionTag(id);
|
|
2506
|
+
const redact = cfg.redact;
|
|
2507
|
+
const verbosity = cfg.verbosity;
|
|
2508
|
+
let runtime: SessionRuntime | undefined;
|
|
2509
|
+
|
|
2510
|
+
// The SDK can always answer now (interactive via the answer source, or the
|
|
2511
|
+
// workflow gate), so the endpoint advertises a resolver. Validate the native
|
|
2512
|
+
// build information and required capability while lifecycle startup can settle
|
|
2513
|
+
// a structured failure instead of leaving the lifecycle caller pending.
|
|
2514
|
+
const token = resolveToken();
|
|
2515
|
+
let server: NotificationServer;
|
|
2516
|
+
try {
|
|
2517
|
+
assertNativeRuntimeCompatibility({
|
|
2518
|
+
runtimeVersion: VERSION,
|
|
2519
|
+
nativeVersion: nativeBuildInfo().version,
|
|
2520
|
+
notificationServer: NotificationServer.prototype,
|
|
2521
|
+
});
|
|
2522
|
+
server = new NotificationServer(id, token, stateRoot, true);
|
|
2523
|
+
} catch (error) {
|
|
2524
|
+
if (lifecycleRequired) return failLifecycleStartup("failed", error);
|
|
2525
|
+
throw error;
|
|
2526
|
+
}
|
|
2527
|
+
const gatePresentations = new PresentationArbiter(server, () => runtime?.redact ?? redact, tag);
|
|
2528
|
+
let inboundSdkFrame: ((connectionId: string, frame: Record<string, unknown>) => void) | undefined;
|
|
2529
|
+
let stopBrokerHeartbeat = () => {};
|
|
2530
|
+
const inFlightGateResolutions = new Set<Promise<void>>();
|
|
2531
|
+
const trackGateResolution = <T>(resolution: Promise<T>): Promise<T> => {
|
|
2532
|
+
const quiesced = resolution.then(
|
|
2533
|
+
() => {},
|
|
2534
|
+
() => {},
|
|
2535
|
+
);
|
|
2536
|
+
inFlightGateResolutions.add(quiesced);
|
|
2537
|
+
void quiesced.finally(() => inFlightGateResolutions.delete(quiesced));
|
|
2538
|
+
return resolution;
|
|
2539
|
+
};
|
|
2540
|
+
|
|
2541
|
+
const revisions = new RevisionStore(id, Date.now, { storageDir: stateRoot });
|
|
2542
|
+
let host: SessionSdkHost | undefined;
|
|
2543
|
+
const installProviderDefinitions = (capability: string, definitions: unknown) => {
|
|
2544
|
+
validateProviderDefinitions(capability, definitions);
|
|
2545
|
+
if (capability !== "permission") return;
|
|
2546
|
+
ctx.setSdkPermissionProvider?.(async (toolCall, permissionOptions, signal) => {
|
|
2547
|
+
const result = await host!.reverse.request("permission", "request", {
|
|
2548
|
+
toolCall,
|
|
2549
|
+
options: permissionOptions,
|
|
2550
|
+
aborted: signal?.aborted === true,
|
|
2551
|
+
});
|
|
2552
|
+
if (!result || typeof result !== "object")
|
|
2553
|
+
throw new Error("permission provider returned an invalid response");
|
|
2554
|
+
const response = result as { outcome?: unknown; optionId?: unknown; kind?: unknown };
|
|
2555
|
+
if (response.outcome === "cancelled") return { outcome: "cancelled" };
|
|
2556
|
+
if (response.outcome === "selected" && typeof response.optionId === "string")
|
|
2557
|
+
return {
|
|
2558
|
+
outcome: "selected",
|
|
2559
|
+
optionId: response.optionId,
|
|
2560
|
+
...(typeof response.kind === "string"
|
|
2561
|
+
? { kind: response.kind as "allow_once" | "allow_always" | "reject_once" | "reject_always" }
|
|
2562
|
+
: {}),
|
|
2563
|
+
};
|
|
2564
|
+
throw new Error("permission provider returned an invalid response");
|
|
2565
|
+
});
|
|
2566
|
+
};
|
|
2567
|
+
const removeProviderDefinitions = (capability: string) => {
|
|
2568
|
+
if (capability === "permission") ctx.setSdkPermissionProvider?.(undefined);
|
|
2569
|
+
};
|
|
2570
|
+
|
|
2571
|
+
const configOverrides = new Map<string, unknown>();
|
|
2572
|
+
const configRevision = { current: 0 };
|
|
2573
|
+
const PROMPT_SUBMISSION_CAPACITY = 128;
|
|
2574
|
+
const PROMPT_SUBMISSION_TTL_MS = 5 * 60_000;
|
|
2575
|
+
const PROMPT_TERMINAL_TOMBSTONE_CAPACITY = 256;
|
|
2576
|
+
const PROMPT_TERMINAL_TOMBSTONE_TTL_MS = 15 * 60_000;
|
|
2577
|
+
const promptSubmissionKey = (correlation: { commandId: string; turnId: string }) =>
|
|
2578
|
+
`${correlation.commandId}:${correlation.turnId}`;
|
|
2579
|
+
type PromptLifecycleFrame =
|
|
2580
|
+
| { type: "agent_start" | "agent_end"; sessionId: string; commandId?: string; turnId?: string }
|
|
2581
|
+
| {
|
|
2582
|
+
type: "agent_failed";
|
|
2583
|
+
sessionId: string;
|
|
2584
|
+
commandId: string;
|
|
2585
|
+
turnId: string;
|
|
2586
|
+
error: { code: string; message: string };
|
|
2587
|
+
};
|
|
2588
|
+
type PromptSubmission = {
|
|
2589
|
+
acknowledged: boolean;
|
|
2590
|
+
connectionId: string;
|
|
2591
|
+
abandoned: boolean;
|
|
2592
|
+
failed: boolean;
|
|
2593
|
+
error: unknown;
|
|
2594
|
+
terminal: boolean;
|
|
2595
|
+
createdAt: number;
|
|
2596
|
+
bufferedLifecycle: PromptLifecycleFrame[];
|
|
2597
|
+
};
|
|
2598
|
+
const promptSubmissions = new Map<string, PromptSubmission>();
|
|
2599
|
+
const promptTerminalTombstones = new Map<string, number>();
|
|
2600
|
+
const removePendingPromptCorrelation = (correlation: { commandId: string; turnId: string }) => {
|
|
2601
|
+
const pendingIndex = pendingPromptCorrelations.findIndex(
|
|
2602
|
+
candidate => candidate.commandId === correlation.commandId && candidate.turnId === correlation.turnId,
|
|
2603
|
+
);
|
|
2604
|
+
if (pendingIndex !== -1) pendingPromptCorrelations.splice(pendingIndex, 1);
|
|
2605
|
+
};
|
|
2606
|
+
const addTerminalTombstone = (key: string, now = Date.now()) => {
|
|
2607
|
+
promptTerminalTombstones.delete(key);
|
|
2608
|
+
promptTerminalTombstones.set(key, now + PROMPT_TERMINAL_TOMBSTONE_TTL_MS);
|
|
2609
|
+
while (promptTerminalTombstones.size > PROMPT_TERMINAL_TOMBSTONE_CAPACITY)
|
|
2610
|
+
promptTerminalTombstones.delete(promptTerminalTombstones.keys().next().value!);
|
|
2611
|
+
};
|
|
2612
|
+
const finalizePrompt = (key: string, correlation: { commandId: string; turnId: string }) => {
|
|
2613
|
+
promptSubmissions.delete(key);
|
|
2614
|
+
removePendingPromptCorrelation(correlation);
|
|
2615
|
+
addTerminalTombstone(key);
|
|
2616
|
+
};
|
|
2617
|
+
const cleanupPromptRecords = (now = Date.now()) => {
|
|
2618
|
+
for (const [key, expiresAt] of promptTerminalTombstones)
|
|
2619
|
+
if (expiresAt <= now) promptTerminalTombstones.delete(key);
|
|
2620
|
+
for (const [key, submission] of promptSubmissions)
|
|
2621
|
+
if (submission.createdAt + PROMPT_SUBMISSION_TTL_MS <= now) {
|
|
2622
|
+
const [commandId, turnId] = key.split(":", 2);
|
|
2623
|
+
if (commandId && turnId) finalizePrompt(key, { commandId, turnId });
|
|
2624
|
+
}
|
|
2625
|
+
};
|
|
2626
|
+
const abandonPrompt = (submission: PromptSubmission) => {
|
|
2627
|
+
submission.abandoned = true;
|
|
2628
|
+
submission.bufferedLifecycle.length = 0;
|
|
2629
|
+
};
|
|
2630
|
+
const emitPromptLifecycle = (
|
|
2631
|
+
correlation: { commandId: string; turnId: string } | undefined,
|
|
2632
|
+
frame: PromptLifecycleFrame,
|
|
2633
|
+
) => {
|
|
2634
|
+
cleanupPromptRecords();
|
|
2635
|
+
if (!correlation || !runtime) {
|
|
2636
|
+
emitAgentLifecycle(runtime!, frame as Extract<PromptLifecycleFrame, { type: "agent_start" | "agent_end" }>);
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
const key = promptSubmissionKey(correlation);
|
|
2640
|
+
const submission = promptSubmissions.get(key);
|
|
2641
|
+
if (!submission) return;
|
|
2642
|
+
runtime.host.emitEvent({ kind: frame.type, payload: frame });
|
|
2643
|
+
if (submission.abandoned) {
|
|
2644
|
+
if (submission.terminal) finalizePrompt(key, correlation);
|
|
2645
|
+
return;
|
|
2646
|
+
}
|
|
2647
|
+
if (!submission.acknowledged) {
|
|
2648
|
+
submission.bufferedLifecycle.push(frame);
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2651
|
+
try {
|
|
2652
|
+
runtime.server.sendTo(submission.connectionId, JSON.stringify(frame));
|
|
2653
|
+
} catch (error) {
|
|
2654
|
+
logger.warn(`sdk: correlated lifecycle delivery failed: ${String(error)}`);
|
|
2655
|
+
abandonPrompt(submission);
|
|
2656
|
+
}
|
|
2657
|
+
if (submission.terminal) finalizePrompt(key, correlation);
|
|
2658
|
+
};
|
|
2659
|
+
const flushPromptLifecycle = (key: string, submission: PromptSubmission) => {
|
|
2660
|
+
for (const frame of submission.bufferedLifecycle.splice(0)) {
|
|
2661
|
+
try {
|
|
2662
|
+
server.sendTo(submission.connectionId, JSON.stringify(frame));
|
|
2663
|
+
} catch (error) {
|
|
2664
|
+
logger.warn(`sdk: buffered correlated lifecycle delivery failed: ${String(error)}`);
|
|
2665
|
+
abandonPrompt(submission);
|
|
2666
|
+
break;
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
if (submission.terminal) {
|
|
2670
|
+
const [commandId, turnId] = key.split(":", 2);
|
|
2671
|
+
if (commandId && turnId) finalizePrompt(key, { commandId, turnId });
|
|
2672
|
+
}
|
|
2673
|
+
};
|
|
2674
|
+
const recordPromptAccepted = (
|
|
2675
|
+
correlation: { commandId: string; turnId: string },
|
|
2676
|
+
requesterConnectionId?: string,
|
|
2677
|
+
) => {
|
|
2678
|
+
if (!requesterConnectionId) return;
|
|
2679
|
+
cleanupPromptRecords();
|
|
2680
|
+
while (promptSubmissions.size >= PROMPT_SUBMISSION_CAPACITY) {
|
|
2681
|
+
const oldest = promptSubmissions.entries().next().value as [string, PromptSubmission] | undefined;
|
|
2682
|
+
if (!oldest) break;
|
|
2683
|
+
const [key] = oldest;
|
|
2684
|
+
const [commandId, turnId] = key.split(":", 2);
|
|
2685
|
+
if (commandId && turnId) finalizePrompt(key, { commandId, turnId });
|
|
2686
|
+
}
|
|
2687
|
+
pendingPromptCorrelations.push(correlation);
|
|
2688
|
+
promptSubmissions.set(promptSubmissionKey(correlation), {
|
|
2689
|
+
acknowledged: false,
|
|
2690
|
+
connectionId: requesterConnectionId,
|
|
2691
|
+
abandoned: false,
|
|
2692
|
+
failed: false,
|
|
2693
|
+
error: undefined,
|
|
2694
|
+
terminal: false,
|
|
2695
|
+
createdAt: Date.now(),
|
|
2696
|
+
bufferedLifecycle: [],
|
|
2697
|
+
});
|
|
2698
|
+
};
|
|
2699
|
+
const recordPromptTerminal = (correlation: { commandId: string; turnId: string } | undefined) => {
|
|
2700
|
+
if (!correlation) return false;
|
|
2701
|
+
cleanupPromptRecords();
|
|
2702
|
+
const key = promptSubmissionKey(correlation);
|
|
2703
|
+
if (promptTerminalTombstones.has(key)) return false;
|
|
2704
|
+
const submission = promptSubmissions.get(key);
|
|
2705
|
+
if (!submission || submission.terminal) return false;
|
|
2706
|
+
submission.terminal = true;
|
|
2707
|
+
return true;
|
|
2708
|
+
};
|
|
2709
|
+
const emitPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
|
|
2710
|
+
const submission = promptSubmissions.get(promptSubmissionKey(correlation));
|
|
2711
|
+
if (!submission || !runtime || !recordPromptTerminal(correlation)) return;
|
|
2712
|
+
const candidate = error as { code?: unknown; message?: unknown };
|
|
2713
|
+
emitPromptLifecycle(correlation, {
|
|
2714
|
+
type: "agent_failed",
|
|
2715
|
+
sessionId: runtime.id,
|
|
2716
|
+
...correlation,
|
|
2717
|
+
error: {
|
|
2718
|
+
code: typeof candidate.code === "string" ? candidate.code : "internal",
|
|
2719
|
+
message: typeof candidate.message === "string" ? candidate.message : "Prompt submission failed.",
|
|
2720
|
+
},
|
|
2721
|
+
});
|
|
2722
|
+
};
|
|
2723
|
+
const recordPromptFailure = (correlation: { commandId: string; turnId: string }, error: unknown) => {
|
|
2724
|
+
const submission = promptSubmissions.get(promptSubmissionKey(correlation));
|
|
2725
|
+
if (!submission) return;
|
|
2726
|
+
submission.failed = true;
|
|
2727
|
+
submission.error = error;
|
|
2728
|
+
removePendingPromptCorrelation(correlation);
|
|
2729
|
+
emitPromptFailure(correlation, error);
|
|
2730
|
+
};
|
|
2731
|
+
const acknowledgePrompt = (connectionId: string, correlation: { commandId: string; turnId: string }) => {
|
|
2732
|
+
const key = promptSubmissionKey(correlation);
|
|
2733
|
+
const submission = promptSubmissions.get(key);
|
|
2734
|
+
if (!submission || submission.abandoned || submission.connectionId !== connectionId) return;
|
|
2735
|
+
submission.acknowledged = true;
|
|
2736
|
+
flushPromptLifecycle(key, submission);
|
|
2737
|
+
};
|
|
2738
|
+
|
|
2739
|
+
const cursors = new CursorRegistry(token, revisions);
|
|
2740
|
+
const queryHandlers = new QueryHandlers(
|
|
2741
|
+
sdkQuerySurface(
|
|
2742
|
+
ctx,
|
|
2743
|
+
id,
|
|
2744
|
+
api,
|
|
2745
|
+
capability => host?.reverse.getInstalledDefinitions(capability),
|
|
2746
|
+
() => {
|
|
2747
|
+
// Live session truth: the agent loop drives rt.busy on
|
|
2748
|
+
// agent_start/agent_end regardless of whether notifications are
|
|
2749
|
+
// active, and ctx.isIdle() is the session's own idle signal.
|
|
2750
|
+
const counts = ctx.getPendingMessageCounts();
|
|
2751
|
+
return {
|
|
2752
|
+
isStreaming: runtime?.busy === true || !ctx.isIdle(),
|
|
2753
|
+
steeringQueueDepth: counts.steering,
|
|
2754
|
+
followupQueueDepth: counts.followUp,
|
|
2755
|
+
};
|
|
2756
|
+
},
|
|
2757
|
+
configOverrides,
|
|
2758
|
+
),
|
|
2759
|
+
id,
|
|
2760
|
+
revisions,
|
|
2761
|
+
cursors,
|
|
2762
|
+
);
|
|
2763
|
+
const controlSurface = sdkControlSurface(
|
|
2764
|
+
ctx,
|
|
2765
|
+
pendingInteractive,
|
|
2766
|
+
gatePresentations,
|
|
2767
|
+
api,
|
|
2768
|
+
() => runtime?.busy === true || pendingPromptCorrelations.length > 0,
|
|
2769
|
+
recordPromptAccepted,
|
|
2770
|
+
recordPromptFailure,
|
|
2771
|
+
() => runtime?.stopping !== true,
|
|
2772
|
+
trackGateResolution,
|
|
2773
|
+
settings,
|
|
2774
|
+
configOverrides,
|
|
2775
|
+
configRevision,
|
|
2776
|
+
);
|
|
2777
|
+
const abandonPromptResponse = (connectionId: string, frame: Record<string, unknown>) => {
|
|
2778
|
+
if (
|
|
2779
|
+
frame.type !== "control_response" ||
|
|
2780
|
+
frame.ok !== true ||
|
|
2781
|
+
!frame.result ||
|
|
2782
|
+
typeof frame.result !== "object"
|
|
2783
|
+
)
|
|
2784
|
+
return;
|
|
2785
|
+
const result = frame.result as { accepted?: unknown; commandId?: unknown; turnId?: unknown };
|
|
2786
|
+
if (result.accepted !== true || typeof result.commandId !== "string" || typeof result.turnId !== "string")
|
|
2787
|
+
return;
|
|
2788
|
+
const submission = promptSubmissions.get(
|
|
2789
|
+
promptSubmissionKey({ commandId: result.commandId, turnId: result.turnId }),
|
|
2790
|
+
);
|
|
2791
|
+
if (!submission || submission.acknowledged || submission.connectionId !== connectionId) return;
|
|
2792
|
+
abandonPrompt(submission);
|
|
2793
|
+
};
|
|
2794
|
+
|
|
2795
|
+
const sendSdkFrame = (connectionId: string, frame: Record<string, unknown>) => {
|
|
2796
|
+
const json = JSON.stringify(frame);
|
|
2797
|
+
if (connectionId.startsWith("seam:")) {
|
|
2798
|
+
try {
|
|
2799
|
+
pushSessionFrame(runtime!, {
|
|
2800
|
+
type: "control_command_result",
|
|
2801
|
+
sessionId: runtime!.id,
|
|
2802
|
+
requestId: connectionId.slice("seam:".length),
|
|
2803
|
+
status: "ok",
|
|
2804
|
+
message: json,
|
|
2805
|
+
});
|
|
2806
|
+
} catch (error) {
|
|
2807
|
+
logger.warn(`sdk: seam response delivery failed for ${connectionId}: ${String(error)}`);
|
|
2808
|
+
abandonPromptResponse(connectionId, frame);
|
|
2809
|
+
throw error;
|
|
2810
|
+
}
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
try {
|
|
2814
|
+
server.sendTo(connectionId, json);
|
|
2815
|
+
} catch (error) {
|
|
2816
|
+
logger.warn(`sdk: directed response delivery failed for ${connectionId}: ${String(error)}`);
|
|
2817
|
+
abandonPromptResponse(connectionId, frame);
|
|
2818
|
+
throw error;
|
|
2819
|
+
}
|
|
2820
|
+
};
|
|
2821
|
+
|
|
2822
|
+
host = new SessionSdkHost({
|
|
2823
|
+
sessionId: id,
|
|
2824
|
+
stateRoot,
|
|
2825
|
+
token,
|
|
2826
|
+
sendFrame: (connectionId, frame) => sendSdkFrame(connectionId, frame),
|
|
2827
|
+
installProviderDefinitions,
|
|
2828
|
+
onProviderDefinitionsRemoved: removeProviderDefinitions,
|
|
2829
|
+
onFrame: handler => {
|
|
2830
|
+
inboundSdkFrame = handler;
|
|
2831
|
+
return () => {
|
|
2832
|
+
inboundSdkFrame = undefined;
|
|
2833
|
+
};
|
|
2834
|
+
},
|
|
2835
|
+
onRequest: options.onSdkRequest,
|
|
2836
|
+
afterControlResponse: async (connectionId, request, response) => {
|
|
2837
|
+
if (
|
|
2838
|
+
(request.operation === "turn.prompt" ||
|
|
2839
|
+
request.operation === "turn.follow_up" ||
|
|
2840
|
+
request.operation === "turn.abort_and_prompt") &&
|
|
2841
|
+
response.ok === true &&
|
|
2842
|
+
response.result &&
|
|
2843
|
+
typeof response.result === "object" &&
|
|
2844
|
+
!Array.isArray(response.result)
|
|
2845
|
+
) {
|
|
2846
|
+
const result = response.result as { accepted?: unknown; commandId?: unknown; turnId?: unknown };
|
|
2847
|
+
if (
|
|
2848
|
+
result.accepted === true &&
|
|
2849
|
+
typeof result.commandId === "string" &&
|
|
2850
|
+
typeof result.turnId === "string"
|
|
2851
|
+
)
|
|
2852
|
+
acknowledgePrompt(connectionId, { commandId: result.commandId, turnId: result.turnId });
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
if (request.operation === "session.close" && response.ok === true) ctx.shutdown();
|
|
2856
|
+
if (typeof request.operation === "string" && identityControlOperations.has(request.operation)) {
|
|
2857
|
+
const pending = deferredIdentityRotation;
|
|
2858
|
+
deferredIdentityRotation = undefined;
|
|
2859
|
+
identityControlInFlight = false;
|
|
2860
|
+
if (response.ok === true && pending) await rotateSessionAuthority(pending.event, pending.ctx);
|
|
2861
|
+
}
|
|
2862
|
+
},
|
|
2863
|
+
control: async (connectionId, frame) => {
|
|
2864
|
+
const request = frame as {
|
|
2865
|
+
id?: unknown;
|
|
2866
|
+
operation?: unknown;
|
|
2867
|
+
input?: unknown;
|
|
2868
|
+
expectedRevision?: unknown;
|
|
2869
|
+
idempotencyKey?: unknown;
|
|
2870
|
+
confirm?: unknown;
|
|
2871
|
+
};
|
|
2872
|
+
const requestId = typeof request.id === "string" ? request.id : "";
|
|
2873
|
+
const operation = typeof request.operation === "string" ? request.operation : "";
|
|
2874
|
+
const rotatesIdentity = identityControlOperations.has(operation);
|
|
2875
|
+
if (rotatesIdentity && identityControlInFlight)
|
|
2876
|
+
return {
|
|
2877
|
+
id: requestId,
|
|
2878
|
+
ok: false,
|
|
2879
|
+
error: { code: "conflict", message: "session identity mutation is already active" },
|
|
2880
|
+
};
|
|
2881
|
+
if (rotatesIdentity) identityControlInFlight = true;
|
|
2882
|
+
const response = await controlRequesterContext.run(connectionId, () =>
|
|
2883
|
+
dispatchControl(
|
|
2884
|
+
controlSurface,
|
|
2885
|
+
OPERATIONS.find(row => row.kind === "control" && row.sdkId === operation),
|
|
2886
|
+
{
|
|
2887
|
+
id: requestId,
|
|
2888
|
+
operation,
|
|
2889
|
+
input: request.input,
|
|
2890
|
+
expectedRevision:
|
|
2891
|
+
typeof request.expectedRevision === "string" ? request.expectedRevision : undefined,
|
|
2892
|
+
idempotencyKey: typeof request.idempotencyKey === "string" ? request.idempotencyKey : undefined,
|
|
2893
|
+
confirm: request.confirm === true,
|
|
2894
|
+
},
|
|
2895
|
+
),
|
|
2896
|
+
);
|
|
2897
|
+
if (rotatesIdentity && response.ok !== true) {
|
|
2898
|
+
identityControlInFlight = false;
|
|
2899
|
+
deferredIdentityRotation = undefined;
|
|
2900
|
+
}
|
|
2901
|
+
return response;
|
|
2902
|
+
},
|
|
2903
|
+
query: async (connectionId, frame) => {
|
|
2904
|
+
const request = frame as { id?: unknown; query?: unknown; input?: unknown; cursor?: unknown };
|
|
2905
|
+
const response = await queryHandlers.dispatch({
|
|
2906
|
+
id: typeof request.id === "string" ? request.id : undefined,
|
|
2907
|
+
query: typeof request.query === "string" ? request.query : "",
|
|
2908
|
+
input:
|
|
2909
|
+
request.input && typeof request.input === "object" && !Array.isArray(request.input)
|
|
2910
|
+
? (request.input as Record<string, unknown>)
|
|
2911
|
+
: undefined,
|
|
2912
|
+
cursor: typeof request.cursor === "string" ? request.cursor : undefined,
|
|
2913
|
+
connectionId,
|
|
2914
|
+
});
|
|
2915
|
+
return { type: "query_response", ...response };
|
|
2916
|
+
},
|
|
2917
|
+
});
|
|
2918
|
+
|
|
2919
|
+
// Install the runtime before either transport can expose the host. session_start
|
|
2920
|
+
// is deliberately fire-and-forget, so agent lifecycle events and direct v3
|
|
2921
|
+
// seam replies can otherwise arrive between server.start() and the old
|
|
2922
|
+
// registration below. Keeping this state live first makes those frames
|
|
2923
|
+
// replayable rather than dropping them (or dereferencing an absent runtime).
|
|
2924
|
+
runtime = {
|
|
2925
|
+
server,
|
|
2926
|
+
host,
|
|
2927
|
+
revisions,
|
|
2928
|
+
cursors,
|
|
2929
|
+
id,
|
|
2930
|
+
idleSeq: 0,
|
|
2931
|
+
pendingInteractive,
|
|
2932
|
+
brokerRegistrationActive: false,
|
|
2933
|
+
disposeAnswerSource: () => {},
|
|
2934
|
+
disposeFileSink: () => {},
|
|
2935
|
+
disposeGateListener: () => {},
|
|
2936
|
+
notificationsActive: false,
|
|
2937
|
+
enableNotifications: () => {},
|
|
2938
|
+
disposeGateTerminalController: () => {},
|
|
2939
|
+
disposeAckRecoveryParticipant: () => {},
|
|
2940
|
+
disposeGateEmitterListener: () => {},
|
|
2941
|
+
waitForGateResolutionQuiescence: async () => {
|
|
2942
|
+
await Promise.allSettled(inFlightGateResolutions);
|
|
2943
|
+
},
|
|
2944
|
+
workflowGate: undefined,
|
|
2945
|
+
gatePresentations,
|
|
2946
|
+
stopping: false,
|
|
2947
|
+
cancelPostmortemCleanup: () => {},
|
|
2948
|
+
stopBrokerHeartbeat,
|
|
2949
|
+
|
|
2950
|
+
redact,
|
|
2951
|
+
verbosity,
|
|
2952
|
+
stream: streamingEnabled(),
|
|
2953
|
+
sessionTag: tag,
|
|
2954
|
+
busy: false,
|
|
2955
|
+
pendingPromptCorrelations,
|
|
2956
|
+
activePromptCorrelation: undefined,
|
|
2957
|
+
recordPromptTerminal,
|
|
2958
|
+
emitPromptLifecycle,
|
|
2959
|
+
pendingInbound: new Set<number>(),
|
|
2960
|
+
};
|
|
2961
|
+
runtimes.set(id, runtime);
|
|
2962
|
+
activeRuntimeId = id;
|
|
2963
|
+
const startSettled = Promise.withResolvers<SessionStartResult>();
|
|
2964
|
+
sessionStartPromises.set(id, startSettled.promise);
|
|
2965
|
+
const finishStartup = (result: SessionStartResult): void => {
|
|
2966
|
+
if (lifecycleRequired) {
|
|
2967
|
+
if (result.status === "started") lifecycleStartupCapability?.settleStarted();
|
|
2968
|
+
else
|
|
2969
|
+
lifecycleStartupCapability?.settleFailure(
|
|
2970
|
+
result.failure ??
|
|
2971
|
+
lifecycleStartupCapability?.normalizeFailure(
|
|
2972
|
+
"startup",
|
|
2973
|
+
result.status === "disabled" ? "disabled" : "failed",
|
|
2974
|
+
) ??
|
|
2975
|
+
normalizeSdkStartupFailure("startup", result.status === "disabled" ? "disabled" : "failed"),
|
|
2976
|
+
);
|
|
2977
|
+
}
|
|
2978
|
+
if (sessionStartPromises.get(id) === startSettled.promise) sessionStartPromises.delete(id);
|
|
2979
|
+
startSettled.resolve(result);
|
|
2980
|
+
};
|
|
2981
|
+
const cleanupAbandonedStartup = async (): Promise<void> => {
|
|
2982
|
+
try {
|
|
2983
|
+
server.stop();
|
|
2984
|
+
} catch {}
|
|
2985
|
+
try {
|
|
2986
|
+
await host.stop();
|
|
2987
|
+
host.reverse.dispose();
|
|
2988
|
+
} catch {}
|
|
2989
|
+
try {
|
|
2990
|
+
cursors.close();
|
|
2991
|
+
await revisions.close();
|
|
2992
|
+
} catch {}
|
|
2993
|
+
};
|
|
2994
|
+
|
|
2995
|
+
try {
|
|
2996
|
+
server.onSdkFrame((err, inbound) => {
|
|
2997
|
+
if (err || !inbound) return;
|
|
2998
|
+
try {
|
|
2999
|
+
inboundSdkFrame?.(inbound.connectionId, JSON.parse(inbound.json) as Record<string, unknown>);
|
|
3000
|
+
} catch {}
|
|
3001
|
+
});
|
|
3002
|
+
server.onConnectionClose((_err, connectionId) => {
|
|
3003
|
+
if (!connectionId) return;
|
|
3004
|
+
host.handleDisconnect(connectionId);
|
|
3005
|
+
for (const submission of promptSubmissions.values())
|
|
3006
|
+
if (submission.connectionId === connectionId) abandonPrompt(submission);
|
|
3007
|
+
});
|
|
3008
|
+
|
|
3009
|
+
server.onReply((err, reply) => {
|
|
3010
|
+
if (err || !reply) return;
|
|
3011
|
+
if (runtime?.stopping || runtimes.get(id) !== runtime) {
|
|
3012
|
+
try {
|
|
3013
|
+
server.closeClaimInvalid(reply.replyReceiptId, "session_stopping");
|
|
3014
|
+
} catch {}
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
const native = server as unknown as {
|
|
3018
|
+
resolveClaim(receiptId: string, answerJson?: string, idempotencyKey?: string): void;
|
|
3019
|
+
closeClaimInvalid(receiptId: string, reason: string): void;
|
|
3020
|
+
requestAskSelectedAck(
|
|
3021
|
+
receiptId: string,
|
|
3022
|
+
requestJson: string,
|
|
3023
|
+
): Promise<{ status: string; messageId?: number; reason?: string }>;
|
|
3024
|
+
};
|
|
3025
|
+
const pending = pendingInteractive.get(reply.id);
|
|
3026
|
+
if (pending) {
|
|
3027
|
+
if (pendingInteractive.get(reply.id) === pending) pendingInteractive.delete(reply.id);
|
|
3028
|
+
let interaction: AskRemoteInteraction | undefined;
|
|
3029
|
+
try {
|
|
3030
|
+
const answer = JSON.parse(reply.answerJson) as unknown;
|
|
3031
|
+
if (typeof answer === "object" && answer && "controlId" in answer) {
|
|
3032
|
+
const controlId = (answer as { controlId?: unknown }).controlId;
|
|
3033
|
+
if (
|
|
3034
|
+
controlId === "navigation_forward" &&
|
|
3035
|
+
pending.controls.some(control => control.id === controlId && control.enabled)
|
|
3036
|
+
) {
|
|
3037
|
+
interaction = { kind: "control", controlId };
|
|
3038
|
+
}
|
|
3039
|
+
} else {
|
|
3040
|
+
const value = mapAnswerToLabel(reply.answerJson, pending.options);
|
|
3041
|
+
if (value !== undefined) interaction = { kind: "value", value };
|
|
3042
|
+
}
|
|
3043
|
+
} catch {}
|
|
3044
|
+
if (!interaction) {
|
|
3045
|
+
try {
|
|
3046
|
+
native.closeClaimInvalid(reply.replyReceiptId, "invalid_answer");
|
|
3047
|
+
} catch {}
|
|
3048
|
+
if (!pending.reissue()) pending.resolve(undefined);
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
let settled: Promise<AskSettlementResult> | undefined;
|
|
3052
|
+
const receipt: AskRemoteReceipt = {
|
|
3053
|
+
source: "remote",
|
|
3054
|
+
interaction,
|
|
3055
|
+
settle(settlement: AskSettlement): Promise<AskSettlementResult> {
|
|
3056
|
+
if (settled) return settled;
|
|
3057
|
+
settled = Promise.resolve().then(async () => {
|
|
3058
|
+
if (settlement.kind === "invalid") {
|
|
3059
|
+
try {
|
|
3060
|
+
native.closeClaimInvalid(reply.replyReceiptId, settlement.reason);
|
|
3061
|
+
} catch (error) {
|
|
3062
|
+
pending.fail(reply.id);
|
|
3063
|
+
throw error;
|
|
3064
|
+
}
|
|
3065
|
+
pending.reissue();
|
|
3066
|
+
return { kind: "invalid_closed" };
|
|
3067
|
+
}
|
|
3068
|
+
try {
|
|
3069
|
+
if (settlement.kind === "resolve_without_commit") {
|
|
3070
|
+
native.resolveClaim(
|
|
3071
|
+
reply.replyReceiptId,
|
|
3072
|
+
reply.answerJson,
|
|
3073
|
+
reply.idempotencyKey ?? undefined,
|
|
3074
|
+
);
|
|
3075
|
+
pending.complete(reply.id);
|
|
3076
|
+
return { kind: "resolved_without_commit" };
|
|
3077
|
+
}
|
|
3078
|
+
const ack = await requestLiveSelectedAck(native, {
|
|
3079
|
+
replyReceiptId: reply.replyReceiptId,
|
|
3080
|
+
actionId: reply.id,
|
|
3081
|
+
commitKey: `${reply.id}:${reply.idempotencyKey ?? reply.replyReceiptId}`,
|
|
3082
|
+
deadlineAt: Date.now() + 8_000,
|
|
3083
|
+
});
|
|
3084
|
+
native.resolveClaim(
|
|
3085
|
+
reply.replyReceiptId,
|
|
3086
|
+
reply.answerJson,
|
|
3087
|
+
reply.idempotencyKey ?? undefined,
|
|
3088
|
+
);
|
|
3089
|
+
pending.complete(reply.id);
|
|
3090
|
+
return { kind: "committed", ack };
|
|
3091
|
+
} catch (error) {
|
|
3092
|
+
try {
|
|
3093
|
+
native.closeClaimInvalid(reply.replyReceiptId, "settlement_failed");
|
|
3094
|
+
} catch {}
|
|
3095
|
+
pending.fail(reply.id);
|
|
3096
|
+
throw error;
|
|
3097
|
+
}
|
|
3098
|
+
});
|
|
3099
|
+
return settled;
|
|
3100
|
+
},
|
|
3101
|
+
};
|
|
3102
|
+
pending.resolve(receipt);
|
|
3103
|
+
return;
|
|
3104
|
+
}
|
|
3105
|
+
const gate = runtime?.workflowGate;
|
|
3106
|
+
const workflowGateActive =
|
|
3107
|
+
gate?.isUnattended?.() === true &&
|
|
3108
|
+
typeof gate.onGateEmitted === "function" &&
|
|
3109
|
+
typeof gate.resolveGateFromNotification === "function";
|
|
3110
|
+
const gateId = gatePresentations.routeFor(reply.id);
|
|
3111
|
+
if (gate && workflowGateActive && gateId && gate.resolveGateFromNotification) {
|
|
3112
|
+
const presentation = gatePresentations.presentationFor(reply.id);
|
|
3113
|
+
const rawAnswer = parseAnswer(reply.answerJson);
|
|
3114
|
+
if (presentation?.multi) {
|
|
3115
|
+
const option =
|
|
3116
|
+
typeof rawAnswer === "number"
|
|
3117
|
+
? presentation.options[rawAnswer]
|
|
3118
|
+
: typeof rawAnswer === "string" && presentation.options.includes(rawAnswer)
|
|
3119
|
+
? rawAnswer
|
|
3120
|
+
: undefined;
|
|
3121
|
+
if (option !== undefined) {
|
|
3122
|
+
native.resolveClaim(reply.replyReceiptId, reply.answerJson, reply.idempotencyKey ?? undefined);
|
|
3123
|
+
if (!gatePresentations.toggle(reply.id, option)) gatePresentations.reissue(gateId);
|
|
3124
|
+
return;
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
let answer: unknown;
|
|
3128
|
+
if (
|
|
3129
|
+
presentation?.multi &&
|
|
3130
|
+
typeof rawAnswer === "object" &&
|
|
3131
|
+
rawAnswer !== null &&
|
|
3132
|
+
(rawAnswer as { controlId?: unknown }).controlId === "navigation_forward"
|
|
3133
|
+
) {
|
|
3134
|
+
if (!presentation.allowEmpty && presentation.selectedOptions.length === 0) {
|
|
3135
|
+
native.closeClaimInvalid(reply.replyReceiptId, "invalid_control");
|
|
3136
|
+
gatePresentations.closeInteraction(reply.id, "invalid_control");
|
|
3137
|
+
gatePresentations.reissue(gateId);
|
|
3138
|
+
return;
|
|
3139
|
+
}
|
|
3140
|
+
answer = { selected: presentation.selectedOptions };
|
|
3141
|
+
} else if (
|
|
3142
|
+
typeof rawAnswer === "object" &&
|
|
3143
|
+
rawAnswer !== null &&
|
|
3144
|
+
(rawAnswer as { action?: unknown }).action === "clarify"
|
|
3145
|
+
) {
|
|
3146
|
+
answer = rawAnswer;
|
|
3147
|
+
} else if (presentation?.multi && typeof rawAnswer === "string") {
|
|
3148
|
+
answer = { selected: presentation.selectedOptions, other: true, custom: rawAnswer };
|
|
3149
|
+
} else {
|
|
3150
|
+
const mapped = mapAnswerToGate(reply.answerJson, gateOptions.get(gateId) ?? []);
|
|
3151
|
+
if (!mapped.ok) {
|
|
3152
|
+
// A numeric selector outside options is invalid (issue #2030): close the
|
|
3153
|
+
// exact claim/receipt and reissue the interaction — never a success ack.
|
|
3154
|
+
native.closeClaimInvalid(reply.replyReceiptId, mapped.reason);
|
|
3155
|
+
gatePresentations.closeInteraction(reply.id, mapped.reason);
|
|
3156
|
+
gatePresentations.reissue(gateId);
|
|
3157
|
+
return;
|
|
3158
|
+
}
|
|
3159
|
+
answer = mapped.answer;
|
|
3160
|
+
}
|
|
3161
|
+
const resolution = gate
|
|
3162
|
+
.resolveGateFromNotification(
|
|
3163
|
+
{ gate_id: gateId, answer, idempotency_key: reply.idempotencyKey ?? undefined },
|
|
3164
|
+
{
|
|
3165
|
+
interactionActionId: reply.id,
|
|
3166
|
+
replyReceiptId: reply.replyReceiptId,
|
|
3167
|
+
answerJson: reply.answerJson,
|
|
3168
|
+
idempotencyKey: reply.idempotencyKey ?? undefined,
|
|
3169
|
+
resolveClaim: () =>
|
|
3170
|
+
native.resolveClaim(
|
|
3171
|
+
reply.replyReceiptId,
|
|
3172
|
+
reply.answerJson,
|
|
3173
|
+
reply.idempotencyKey ?? undefined,
|
|
3174
|
+
),
|
|
3175
|
+
closeClaimInvalid: reason => {
|
|
3176
|
+
native.closeClaimInvalid(reply.replyReceiptId, reason);
|
|
3177
|
+
gatePresentations.closeInteraction(reply.id, reason);
|
|
3178
|
+
gatePresentations.reconcile();
|
|
3179
|
+
},
|
|
3180
|
+
requestSelectedAck: input =>
|
|
3181
|
+
requestLiveSelectedAck(native, {
|
|
3182
|
+
replyReceiptId: input.replyReceiptId,
|
|
3183
|
+
actionId: input.actionId,
|
|
3184
|
+
commitKey: input.commitKey,
|
|
3185
|
+
deadlineAt: input.daemonDeadlineAt,
|
|
3186
|
+
}),
|
|
3187
|
+
},
|
|
3188
|
+
)
|
|
3189
|
+
.catch(() => {
|
|
3190
|
+
let durable: "pending" | "terminal" | "unavailable" = "unavailable";
|
|
3191
|
+
try {
|
|
3192
|
+
if (gate.listPendingGates)
|
|
3193
|
+
durable = gate.listPendingGates().some(candidate => candidate.gate_id === gateId)
|
|
3194
|
+
? "pending"
|
|
3195
|
+
: "terminal";
|
|
3196
|
+
} catch {
|
|
3197
|
+
// Durable state is unavailable; remain fail-closed.
|
|
3198
|
+
}
|
|
3199
|
+
if (durable === "pending") gatePresentations.reconcile();
|
|
3200
|
+
else {
|
|
3201
|
+
if (durable === "unavailable") {
|
|
3202
|
+
try {
|
|
3203
|
+
gate.quarantineGate?.(gateId);
|
|
3204
|
+
} catch {
|
|
3205
|
+
// The presentation remains fail-closed when the durable fence is unavailable.
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
gatePresentations.complete(gateId);
|
|
3209
|
+
}
|
|
3210
|
+
logger.warn("workflow_gate_notification_resolution_failed", { gateId, durable });
|
|
3211
|
+
});
|
|
3212
|
+
trackGateResolution(resolution);
|
|
3213
|
+
return;
|
|
3214
|
+
}
|
|
3215
|
+
try {
|
|
3216
|
+
server.closeClaimInvalid(reply.replyReceiptId, "unknown_action");
|
|
3217
|
+
} catch (error) {
|
|
3218
|
+
logger.warn(`notifications: closeClaimInvalid failed: ${String(error)}`);
|
|
3219
|
+
}
|
|
3220
|
+
});
|
|
3221
|
+
|
|
3222
|
+
// Inbound free-text injection / in-thread config command from a session
|
|
3223
|
+
// thread (forwarded by the daemon over the WS, fail-closed at the daemon).
|
|
3224
|
+
server.onInbound((err, inbound) => {
|
|
3225
|
+
if (err || !inbound) return;
|
|
3226
|
+
if (inbound.kind === "control_command") {
|
|
3227
|
+
const frame = sdkInboundFrame(inbound.commandJson);
|
|
3228
|
+
if (frame) {
|
|
3229
|
+
inboundSdkFrame?.(`seam:${inbound.requestId ?? "notification"}`, frame);
|
|
3230
|
+
return;
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
if (inbound.kind === "user_message") {
|
|
3235
|
+
// Inject as a user turn (steers/continues the agent; the resulting
|
|
3236
|
+
// turn streams back via the turn_end handler even when not idle).
|
|
3237
|
+
// Record the update id so it can be acked as "consumed" on the next
|
|
3238
|
+
// turn_start, and steer (vs start a fresh turn) when already busy.
|
|
3239
|
+
const text = inbound.text ?? "";
|
|
3240
|
+
const images = inbound.images ?? [];
|
|
3241
|
+
if (!text && images.length === 0) return;
|
|
3242
|
+
if (runtime && typeof inbound.updateId === "number") runtime.pendingInbound.add(inbound.updateId);
|
|
3243
|
+
const content: string | (TextContent | ImageContent)[] =
|
|
3244
|
+
images.length > 0
|
|
3245
|
+
? [
|
|
3246
|
+
...(text ? [{ type: "text", text } as TextContent] : []),
|
|
3247
|
+
...images.map(
|
|
3248
|
+
img =>
|
|
3249
|
+
({
|
|
3250
|
+
type: "image",
|
|
3251
|
+
data: img.data,
|
|
3252
|
+
mimeType: img.mime ?? "image/jpeg",
|
|
3253
|
+
}) as ImageContent,
|
|
3254
|
+
),
|
|
3255
|
+
]
|
|
3256
|
+
: text;
|
|
3257
|
+
try {
|
|
3258
|
+
api.sendUserMessage(content, runtime?.busy ? { deliverAs: "steer" } : undefined);
|
|
3259
|
+
} catch (e) {
|
|
3260
|
+
logger.warn(`notifications: sendUserMessage failed: ${String(e)}`);
|
|
3261
|
+
}
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
if (inbound.kind === "config_command") {
|
|
3265
|
+
if (!runtime) return;
|
|
3266
|
+
const update: {
|
|
3267
|
+
type: "config_update";
|
|
3268
|
+
sessionId: string;
|
|
3269
|
+
verbosity?: "lean" | "verbose";
|
|
3270
|
+
redact?: boolean;
|
|
3271
|
+
} = {
|
|
3272
|
+
type: "config_update",
|
|
3273
|
+
sessionId: runtime.id,
|
|
3274
|
+
};
|
|
3275
|
+
if (inbound.verbosity === "lean" || inbound.verbosity === "verbose") {
|
|
3276
|
+
runtime.verbosity = inbound.verbosity;
|
|
3277
|
+
update.verbosity = inbound.verbosity;
|
|
3278
|
+
}
|
|
3279
|
+
if (typeof inbound.redact === "boolean") {
|
|
3280
|
+
runtime.redact = inbound.redact;
|
|
3281
|
+
update.redact = inbound.redact;
|
|
3282
|
+
}
|
|
3283
|
+
if (update.verbosity !== undefined || update.redact !== undefined) {
|
|
3284
|
+
try {
|
|
3285
|
+
pushSessionFrame(runtime, update);
|
|
3286
|
+
} catch (error) {
|
|
3287
|
+
logger.warn(`notifications: config_update failed: ${String(error)}`);
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
if (inbound.kind === "control_command") {
|
|
3292
|
+
if (!runtime || !inbound.requestId) return;
|
|
3293
|
+
const activeRuntime = runtime;
|
|
3294
|
+
if (inbound.sessionId !== activeRuntime.id) {
|
|
3295
|
+
pushSessionFrame(activeRuntime, {
|
|
3296
|
+
type: "control_command_result",
|
|
3297
|
+
sessionId: activeRuntime.id,
|
|
3298
|
+
requestId: inbound.requestId,
|
|
3299
|
+
updateId: inbound.updateId,
|
|
3300
|
+
status: "error",
|
|
3301
|
+
message: STALE_MODEL_BUTTON_MESSAGE,
|
|
3302
|
+
});
|
|
3303
|
+
return;
|
|
3304
|
+
}
|
|
3305
|
+
void executeNotificationControlCommand(
|
|
3306
|
+
parseControlCommandPayload(inbound.commandJson),
|
|
3307
|
+
ctx,
|
|
3308
|
+
api,
|
|
3309
|
+
inbound.sessionId,
|
|
3310
|
+
).then(result => {
|
|
3311
|
+
if (runtime !== activeRuntime) return;
|
|
3312
|
+
pushSessionFrame(activeRuntime, {
|
|
3313
|
+
type: "control_command_result",
|
|
3314
|
+
sessionId: activeRuntime.id,
|
|
3315
|
+
requestId: inbound.requestId,
|
|
3316
|
+
updateId: inbound.updateId,
|
|
3317
|
+
status: result.status,
|
|
3318
|
+
message: result.message,
|
|
3319
|
+
modelChoices: result.modelChoices,
|
|
3320
|
+
});
|
|
3321
|
+
});
|
|
3322
|
+
}
|
|
3323
|
+
});
|
|
3324
|
+
|
|
3325
|
+
await host.start();
|
|
3326
|
+
lifecycleStartupCapability?.rollback?.recordGeneration(host.generation);
|
|
3327
|
+
throwIfLifecycleStopped();
|
|
3328
|
+
if (runtimes.get(id) !== runtime) {
|
|
3329
|
+
finishStartup({ status: "failed" });
|
|
3330
|
+
await cleanupAbandonedStartup();
|
|
3331
|
+
return { status: "failed" };
|
|
3332
|
+
}
|
|
3333
|
+
if (notificationsEnabledForSession && settingsAvailable && settings) {
|
|
3334
|
+
try {
|
|
3335
|
+
if (!(await ensureConfiguredDaemons(settings, cfg, ctx.cwd, id))) {
|
|
3336
|
+
const result = failLifecycleStartup("failed", "Telegram daemon ownership is blocked.");
|
|
3337
|
+
finishStartup(result);
|
|
3338
|
+
await cleanupAbandonedStartup();
|
|
3339
|
+
return result;
|
|
3340
|
+
}
|
|
3341
|
+
} catch (error) {
|
|
3342
|
+
const result = failLifecycleStartup("failed", error);
|
|
3343
|
+
finishStartup(result);
|
|
3344
|
+
await cleanupAbandonedStartup();
|
|
3345
|
+
return result;
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
// Startup contract: identity is committed to the replayable SDK event log
|
|
3350
|
+
// immediately after the host starts, before awaiting notification transport
|
|
3351
|
+
// startup. Native frames are ephemeral, so ensure configured daemon owners
|
|
3352
|
+
// before broadcasting identity; late SDK consumers still recover it from
|
|
3353
|
+
// event_replay.
|
|
3354
|
+
const identityHeader = {
|
|
3355
|
+
type: "identity_header",
|
|
3356
|
+
sessionId: id,
|
|
3357
|
+
...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
|
|
3358
|
+
};
|
|
3359
|
+
host.emitEvent({ kind: identityHeader.type, payload: identityHeader });
|
|
3360
|
+
const endpoint = await server.start();
|
|
3361
|
+
throwIfLifecycleStopped();
|
|
3362
|
+
if (runtimes.get(id) !== runtime) {
|
|
3363
|
+
finishStartup({ status: "failed" });
|
|
3364
|
+
await cleanupAbandonedStartup();
|
|
3365
|
+
return { status: "failed" };
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
server.pushFrame(JSON.stringify(identityHeader));
|
|
3369
|
+
const agentDir = lifecycleAgentDir ?? settings?.getAgentDir?.();
|
|
3370
|
+
if (lifecycleRequired && !agentDir) throw new Error("Lifecycle SDK host requires an agent directory.");
|
|
3371
|
+
|
|
3372
|
+
if (agentDir) {
|
|
3373
|
+
try {
|
|
3374
|
+
await ensureBroker({ agentDir });
|
|
3375
|
+
throwIfLifecycleStopped();
|
|
3376
|
+
const index = await new SessionIndex(agentDir).open();
|
|
3377
|
+
throwIfLifecycleStopped();
|
|
3378
|
+
const locator = { repo: path.resolve(ctx.cwd), stateRoot };
|
|
3379
|
+
const endpointMtimeMs = fs.statSync(path.join(stateRoot, "sdk", `${id}.json`)).mtimeMs;
|
|
3380
|
+
await host.registerWithBroker({
|
|
3381
|
+
// The endpoint is written before registration. Its exact mtime
|
|
3382
|
+
// binds this index generation to that discovery record.
|
|
3383
|
+
register: async input => {
|
|
3384
|
+
await index.append({
|
|
3385
|
+
type: "host_registered",
|
|
3386
|
+
...input,
|
|
3387
|
+
locator,
|
|
3388
|
+
pid: process.pid,
|
|
3389
|
+
endpointMtimeMs,
|
|
3390
|
+
...(lifecycleRequestId ? { lifecycleRequestId } : {}),
|
|
3391
|
+
});
|
|
3392
|
+
},
|
|
3393
|
+
unregister: async input => {
|
|
3394
|
+
await index.append({
|
|
3395
|
+
type: "host_unregistered",
|
|
3396
|
+
...input,
|
|
3397
|
+
locator,
|
|
3398
|
+
pid: process.pid,
|
|
3399
|
+
...(lifecycleRequestId ? { lifecycleRequestId } : {}),
|
|
3400
|
+
});
|
|
3401
|
+
},
|
|
3402
|
+
});
|
|
3403
|
+
throwIfLifecycleStopped();
|
|
3404
|
+
runtime.brokerRegistrationActive = true;
|
|
3405
|
+
const timer = setInterval(() => {
|
|
3406
|
+
void index
|
|
3407
|
+
.append({
|
|
3408
|
+
type: "host_heartbeat",
|
|
3409
|
+
sessionId: id,
|
|
3410
|
+
locator,
|
|
3411
|
+
endpointGeneration: host.generation,
|
|
3412
|
+
pid: process.pid,
|
|
3413
|
+
})
|
|
3414
|
+
.catch(error => logger.warn(`sdk broker heartbeat failed: ${String(error)}`));
|
|
3415
|
+
}, 5_000);
|
|
3416
|
+
stopBrokerHeartbeat = () => clearInterval(timer);
|
|
3417
|
+
} catch (brokerError) {
|
|
3418
|
+
if (lifecycleRequired) throw brokerError;
|
|
3419
|
+
logger.warn(`sdk broker registration skipped: ${String(brokerError)}`);
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
runtime.stopBrokerHeartbeat = stopBrokerHeartbeat;
|
|
3424
|
+
const startedRuntime = runtime;
|
|
3425
|
+
runtime.enableNotifications = () => {
|
|
3426
|
+
const runtime = startedRuntime;
|
|
3427
|
+
if (runtime.notificationsActive) return;
|
|
3428
|
+
runtime.notificationsActive = true;
|
|
3429
|
+
runtime.disposeAnswerSource = registerInteractiveAnswerSource(
|
|
3430
|
+
runtime.id,
|
|
3431
|
+
pendingInteractive,
|
|
3432
|
+
gatePresentations,
|
|
3433
|
+
);
|
|
3434
|
+
runtime.disposeFileSink = registerTelegramFileSink(runtime.id, async file => {
|
|
3435
|
+
if (runtime.redact) return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
|
|
3436
|
+
try {
|
|
3437
|
+
const data = await fs.promises.readFile(file.path);
|
|
3438
|
+
pushSessionFrame(runtime, {
|
|
3439
|
+
type: "file_attachment",
|
|
3440
|
+
sessionId: runtime.id,
|
|
3441
|
+
name: path.basename(file.path),
|
|
3442
|
+
data: data.toString("base64"),
|
|
3443
|
+
caption: file.caption,
|
|
3444
|
+
});
|
|
3445
|
+
return { ok: true };
|
|
3446
|
+
} catch (e) {
|
|
3447
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
3448
|
+
}
|
|
3449
|
+
});
|
|
3450
|
+
};
|
|
3451
|
+
const activeRuntime = runtime;
|
|
3452
|
+
// A native terminal close (SIGHUP), SIGTERM, Ctrl+C exit, or fatal error
|
|
3453
|
+
// skips AgentSession.dispose(), so the `session_shutdown` extension event
|
|
3454
|
+
// never fires and the daemon-side topic would be orphaned. postmortem
|
|
3455
|
+
// awaits registered cleanups on those paths, so send the graceful
|
|
3456
|
+
// `session_closed` frame from there too. stopSession() cancels this
|
|
3457
|
+
// registration on every other teardown path, so it never double-fires.
|
|
3458
|
+
runtime.cancelPostmortemCleanup = postmortem.register(`notifications-session-closed:${id}`, async () => {
|
|
3459
|
+
await stopSession(runtime!.id);
|
|
3460
|
+
});
|
|
3461
|
+
logger.info(`notifications: serving session ${id} at ${endpoint.url}`);
|
|
3462
|
+
// A workflow-gate emitter can be installed after session startup.
|
|
3463
|
+
// Attach dynamically so the SDK bus presents every durable gate.
|
|
3464
|
+
const attachWorkflowGate = (gate: WorkflowGateEmitter | undefined): void => {
|
|
3465
|
+
if (activeRuntime.workflowGate === gate) return;
|
|
3466
|
+
activeRuntime.disposeGateListener();
|
|
3467
|
+
activeRuntime.disposeAckRecoveryParticipant();
|
|
3468
|
+
gatePresentations.dispose();
|
|
3469
|
+
activeRuntime.disposeGateTerminalController();
|
|
3470
|
+
activeRuntime.disposeGateListener = () => {};
|
|
3471
|
+
activeRuntime.disposeGateTerminalController = () => {};
|
|
3472
|
+
activeRuntime.disposeAckRecoveryParticipant = () => {};
|
|
3473
|
+
activeRuntime.workflowGate = undefined;
|
|
3474
|
+
gateOptions.clear();
|
|
3475
|
+
if (typeof gate?.onGateEmitted !== "function" || typeof gate.resolveGateFromNotification !== "function") {
|
|
3476
|
+
return;
|
|
3477
|
+
}
|
|
3478
|
+
activeRuntime.workflowGate = gate;
|
|
3479
|
+
if (hasTerminalArbitrationCapability(gate)) {
|
|
3480
|
+
const controller: WorkflowGateTerminalController = {
|
|
3481
|
+
completeGateInteractions: gateId => gatePresentations.complete(gateId),
|
|
3482
|
+
cancelGateInteractions: (gateId, reason) => gatePresentations.cancel(gateId, reason),
|
|
3483
|
+
};
|
|
3484
|
+
try {
|
|
3485
|
+
activeRuntime.disposeGateTerminalController = gate.registerGateTerminalController(controller);
|
|
3486
|
+
} catch (error) {
|
|
3487
|
+
logger.warn(`notifications: gate terminal controller unavailable: ${String(error)}`);
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
activeRuntime.disposeGateListener = gate.onGateEmitted(g => {
|
|
3491
|
+
const options = (g.options ?? []).map(o => String((o as { label?: unknown }).label ?? ""));
|
|
3492
|
+
gateOptions.set(g.gate_id, options);
|
|
3493
|
+
const promptCtx = g.context as { prompt?: unknown; title?: unknown } | undefined;
|
|
3494
|
+
const question =
|
|
3495
|
+
(typeof promptCtx?.prompt === "string" && promptCtx.prompt) ||
|
|
3496
|
+
(typeof promptCtx?.title === "string" && promptCtx.title) ||
|
|
3497
|
+
"Question";
|
|
3498
|
+
const stageState =
|
|
3499
|
+
typeof g.context?.stage_state === "object" && g.context.stage_state !== null
|
|
3500
|
+
? (g.context.stage_state as Record<string, unknown>)
|
|
3501
|
+
: {};
|
|
3502
|
+
gatePresentations.retain({
|
|
3503
|
+
gateId: g.gate_id,
|
|
3504
|
+
workflowGateId: g.gate_id,
|
|
3505
|
+
sessionId: id,
|
|
3506
|
+
question,
|
|
3507
|
+
options,
|
|
3508
|
+
controls: [],
|
|
3509
|
+
multi: stageState.multi === true,
|
|
3510
|
+
allowEmpty: stageState.allow_empty === true,
|
|
3511
|
+
navigationLabel: stageState.navigation_label === "Next" ? "Next" : "Done",
|
|
3512
|
+
selectedOptions: [],
|
|
3513
|
+
});
|
|
3514
|
+
});
|
|
3515
|
+
if (gate.setAckRecoveryParticipant) {
|
|
3516
|
+
const native = server as unknown as {
|
|
3517
|
+
requestRecoveredAskSelectedAck(
|
|
3518
|
+
requestJson: string,
|
|
3519
|
+
): Promise<{ status: string; messageId?: number; reason?: string }>;
|
|
3520
|
+
};
|
|
3521
|
+
gate.setAckRecoveryParticipant({
|
|
3522
|
+
requestRecoveredAskSelectedAck: input =>
|
|
3523
|
+
requestRecoveredSelectedAck(native, {
|
|
3524
|
+
sessionId: input.sessionId,
|
|
3525
|
+
actionId: input.actionId,
|
|
3526
|
+
commitKey: input.commitKey,
|
|
3527
|
+
deadlineAt: input.deadlineAt,
|
|
3528
|
+
}),
|
|
3529
|
+
});
|
|
3530
|
+
activeRuntime.disposeAckRecoveryParticipant = () => gate.setAckRecoveryParticipant?.(null);
|
|
3531
|
+
}
|
|
3532
|
+
void gate.recoverAcceptedGates?.();
|
|
3533
|
+
};
|
|
3534
|
+
activeRuntime.disposeGateEmitterListener = registerWorkflowGateEmitterListener(id, attachWorkflowGate);
|
|
3535
|
+
if (ctx.workflowGate) attachWorkflowGate(ctx.workflowGate);
|
|
3536
|
+
if (notificationsEnabledForSession) runtime.enableNotifications();
|
|
3537
|
+
finishStartup({ status: "started", runtime });
|
|
3538
|
+
return { status: "started", runtime };
|
|
3539
|
+
} catch (e) {
|
|
3540
|
+
logger.warn(`notifications: failed to start server: ${String(e)}`);
|
|
3541
|
+
const result = failLifecycleStartup("failed", e);
|
|
3542
|
+
finishStartup(result);
|
|
3543
|
+
if (!(await stopSession(id, "session", runtime))) await cleanupAbandonedStartup();
|
|
3544
|
+
return { ...result, runtime };
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
const sessionRuntime: NotificationSessionRuntime<ExtensionContext> = {
|
|
3549
|
+
isRunning: binding => runtimes.get(binding.sessionId)?.notificationsActive === true,
|
|
3550
|
+
start: async binding => {
|
|
3551
|
+
if (sessionStartPromises.has(binding.sessionId)) {
|
|
3552
|
+
const result = await startSession(binding.context);
|
|
3553
|
+
if (result.status !== "started" && result.status !== "already") return result.status;
|
|
3554
|
+
const runtime = runtimes.get(binding.sessionId);
|
|
3555
|
+
if (!runtime || sessionId(binding.context) !== binding.sessionId || activeRuntimeId !== binding.sessionId) {
|
|
3556
|
+
return "failed";
|
|
3557
|
+
}
|
|
3558
|
+
runtime.enableNotifications();
|
|
3559
|
+
return "started";
|
|
3560
|
+
}
|
|
3561
|
+
const runtime = runtimes.get(binding.sessionId);
|
|
3562
|
+
if (runtime) {
|
|
3563
|
+
runtime.enableNotifications();
|
|
3564
|
+
return "started";
|
|
3565
|
+
}
|
|
3566
|
+
const result = await startSession(binding.context);
|
|
3567
|
+
return result.status === "started" || result.status === "already" ? "started" : result.status;
|
|
3568
|
+
},
|
|
3569
|
+
stop: async binding => await stopSession(binding.sessionId, "notifications"),
|
|
3570
|
+
ensureTelegramDaemon: async binding => {
|
|
3571
|
+
const { settings, settingsAvailable } = resolveSettings(options.settings);
|
|
3572
|
+
if (!settingsAvailable || !settings) return "blocked_identity";
|
|
3573
|
+
try {
|
|
3574
|
+
return await ensureTelegramOwner(settings, binding.cwd, binding.sessionId);
|
|
3575
|
+
} catch {
|
|
3576
|
+
return "blocked_identity";
|
|
3577
|
+
}
|
|
3578
|
+
},
|
|
3579
|
+
};
|
|
3580
|
+
controller.attachRuntime(sessionRuntime);
|
|
3581
|
+
|
|
3582
|
+
api.registerCommand("notify", {
|
|
3583
|
+
description: "Control notifications for this session (on, off, status).",
|
|
3584
|
+
async handler(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
3585
|
+
const id = sessionId(ctx);
|
|
3586
|
+
const command = args.trim().split(/\s+/, 1)[0]?.toLowerCase() || "status";
|
|
3587
|
+
const resolved = resolveSettings(options.settings);
|
|
3588
|
+
const enabledWithoutLocalOff = isSessionNotificationsEnabled({
|
|
3589
|
+
cfg: resolved.cfg,
|
|
3590
|
+
env: process.env,
|
|
3591
|
+
sessionDisabled: false,
|
|
3592
|
+
});
|
|
3593
|
+
|
|
3594
|
+
if (command === "off") {
|
|
3595
|
+
const result = await controller.setLocalEnabled(ctx, false);
|
|
3596
|
+
ctx.ui.notify(
|
|
3597
|
+
result.outcome === "stopped"
|
|
3598
|
+
? "Notifications disabled for this session."
|
|
3599
|
+
: "Notifications already disabled for this session.",
|
|
3600
|
+
"info",
|
|
3601
|
+
);
|
|
3602
|
+
return;
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
if (command === "on") {
|
|
3606
|
+
if (!isNotificationEligibleContext(ctx)) {
|
|
3607
|
+
ctx.ui.notify("Notifications are disabled for subagent sessions.", "warning");
|
|
3608
|
+
return;
|
|
3609
|
+
}
|
|
3610
|
+
if (process.env.GJC_NOTIFICATIONS === "0") {
|
|
3611
|
+
ctx.ui.notify(
|
|
3612
|
+
"Notifications remain disabled: GJC_NOTIFICATIONS=0 is an authoritative opt-out.",
|
|
3613
|
+
"warning",
|
|
3614
|
+
);
|
|
3615
|
+
return;
|
|
3616
|
+
}
|
|
3617
|
+
if (!enabledWithoutLocalOff) {
|
|
3618
|
+
ctx.ui.notify(
|
|
3619
|
+
"Notifications are not configured. Run `gjc notify setup` or set GJC_NOTIFICATIONS=1.",
|
|
3620
|
+
"warning",
|
|
3621
|
+
);
|
|
3622
|
+
return;
|
|
3623
|
+
}
|
|
3624
|
+
const result = await controller.setLocalEnabled(ctx, true);
|
|
3625
|
+
const enabled = result.status.running && result.status.effectiveEnabled;
|
|
3626
|
+
const rotated = sessionId(ctx) !== id;
|
|
3627
|
+
const failed = result.outcome === "failed" || (!enabled && !rotated && activeRuntimeId !== id);
|
|
3628
|
+
ctx.ui.notify(
|
|
3629
|
+
enabled
|
|
3630
|
+
? "Notifications enabled for this session."
|
|
3631
|
+
: rotated
|
|
3632
|
+
? "Notifications were not enabled because the active session changed during startup."
|
|
3633
|
+
: failed
|
|
3634
|
+
? "Notifications failed to start for this session."
|
|
3635
|
+
: "Notifications were not enabled because daemon ownership could not be proved.",
|
|
3636
|
+
enabled ? "info" : rotated ? "warning" : failed ? "error" : "warning",
|
|
3637
|
+
);
|
|
3638
|
+
return;
|
|
3639
|
+
}
|
|
3640
|
+
|
|
3641
|
+
if (command !== "status") {
|
|
3642
|
+
ctx.ui.notify("Usage: /notify status | /notify on | /notify off", "warning");
|
|
3643
|
+
return;
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
const status = controller.query(ctx);
|
|
3647
|
+
const runtime = runtimes.get(id);
|
|
3648
|
+
ctx.ui.notify(
|
|
3649
|
+
`Notifications ${status.running ? "running" : status.effectiveEnabled ? "enabled" : "disabled"} for this session; redaction ${(runtime?.redact ?? resolved.cfg.redact) ? "on" : "off"}; verbosity ${runtime?.verbosity ?? resolved.cfg.verbosity}${status.locallyEnabled ? "" : "; locally off"}.`,
|
|
3650
|
+
"info",
|
|
3651
|
+
);
|
|
3652
|
+
},
|
|
3653
|
+
});
|
|
3654
|
+
|
|
3655
|
+
api.on("session_start", async (_event, ctx) => {
|
|
3656
|
+
await startSession(ctx);
|
|
3657
|
+
await controller.reconcileCurrentSession(ctx);
|
|
3658
|
+
});
|
|
3659
|
+
|
|
3660
|
+
// A session endpoint's token and generation are authority for exactly one
|
|
3661
|
+
// session id. `/new`, fork, and resume must all tear down A before publishing
|
|
3662
|
+
// B. Chat implementations may preserve a topic as metadata, but it must never
|
|
3663
|
+
// preserve A's endpoint or credentials as B's control/viewing authority.
|
|
3664
|
+
const rotateSessionAuthority = async (
|
|
3665
|
+
event: { previousSessionFile?: string },
|
|
3666
|
+
ctx: ExtensionContext,
|
|
3667
|
+
): Promise<void> => {
|
|
3668
|
+
const newId = sessionId(ctx);
|
|
3669
|
+
const prevId = activeRuntimeId ?? sessionIdFromFile(event.previousSessionFile);
|
|
3670
|
+
if (prevId && prevId !== newId) {
|
|
3671
|
+
controller.rekeySession(prevId, newId);
|
|
3672
|
+
await stopSession(prevId);
|
|
3673
|
+
}
|
|
3674
|
+
await startSession(ctx);
|
|
3675
|
+
await controller.reconcileCurrentSession(ctx);
|
|
3676
|
+
};
|
|
3677
|
+
api.on("session_switch", async (event, ctx) => {
|
|
3678
|
+
if (identityControlInFlight) {
|
|
3679
|
+
deferredIdentityRotation = { event, ctx };
|
|
3680
|
+
return;
|
|
3681
|
+
}
|
|
3682
|
+
await rotateSessionAuthority(event, ctx);
|
|
3683
|
+
});
|
|
3684
|
+
api.on("session_branch", async (event, ctx) => {
|
|
3685
|
+
if (identityControlInFlight) {
|
|
3686
|
+
deferredIdentityRotation = { event, ctx };
|
|
3687
|
+
return;
|
|
3688
|
+
}
|
|
3689
|
+
await rotateSessionAuthority(event, ctx);
|
|
3690
|
+
});
|
|
3691
|
+
|
|
3692
|
+
// Drive the live typing indicator: mark busy when the agent loop starts so
|
|
3693
|
+
// the daemon shows "typing…" in the thread while the agent is thinking,
|
|
3694
|
+
// before any turn output exists. Cleared on `agent_end` below.
|
|
3695
|
+
api.on("agent_start", (_event, ctx) => {
|
|
3696
|
+
const id = sessionId(ctx);
|
|
3697
|
+
const rt = runtimes.get(id);
|
|
3698
|
+
if (!rt) return;
|
|
3699
|
+
// Streaming state is SDK-visible session truth (context.get isStreaming);
|
|
3700
|
+
// it is tracked regardless of whether notifications are active.
|
|
3701
|
+
rt.busy = true;
|
|
3702
|
+
const correlation = rt.pendingPromptCorrelations.shift();
|
|
3703
|
+
rt.activePromptCorrelation = correlation;
|
|
3704
|
+
rt.emitPromptLifecycle(correlation, { type: "agent_start", sessionId: id, ...correlation });
|
|
3705
|
+
try {
|
|
3706
|
+
// `activity` is the native live-host lifecycle surface. The separately
|
|
3707
|
+
// emitted agent_start above is replayable with command/turn correlation.
|
|
3708
|
+
pushSessionFrame(rt, { type: "activity", sessionId: id, state: "busy" });
|
|
3709
|
+
} catch (e) {
|
|
3710
|
+
logger.warn(`notifications: activity (busy) failed: ${String(e)}`);
|
|
3711
|
+
}
|
|
3712
|
+
});
|
|
3713
|
+
|
|
3714
|
+
// Each turn that starts has absorbed any messages injected from the thread,
|
|
3715
|
+
// so ack them as "consumed": the daemon flips the queued reaction on the
|
|
3716
|
+
// originating Telegram message to the consumed (double-check) reaction.
|
|
3717
|
+
api.on("turn_start", (_event, ctx) => {
|
|
3718
|
+
const id = sessionId(ctx);
|
|
3719
|
+
const rt = runtimes.get(id);
|
|
3720
|
+
if (!rt?.notificationsActive) return;
|
|
3721
|
+
// A new turn is live: re-open the live-stream window (see turnClosed).
|
|
3722
|
+
rt.turnClosed = false;
|
|
3723
|
+
if (rt.pendingInbound.size === 0) return;
|
|
3724
|
+
for (const updateId of rt.pendingInbound) {
|
|
3725
|
+
try {
|
|
3726
|
+
pushSessionFrame(rt, { type: "inbound_ack", sessionId: id, updateId, state: "consumed" });
|
|
3727
|
+
} catch (e) {
|
|
3728
|
+
logger.warn(`notifications: inbound_ack failed: ${String(e)}`);
|
|
3729
|
+
}
|
|
3730
|
+
}
|
|
3731
|
+
rt.pendingInbound.clear();
|
|
3732
|
+
});
|
|
3733
|
+
|
|
3734
|
+
// Idle fires on `agent_end` (the agent loop settling to await the user), NOT
|
|
3735
|
+
// per `turn_end`. turn_end fires once per turn iteration, so a single
|
|
3736
|
+
// user-visible idle previously produced many idle pings (the flood); agent_end
|
|
3737
|
+
// fires exactly once per settle, yielding exactly one idle notification.
|
|
3738
|
+
api.on("agent_end", (_event, ctx) => {
|
|
3739
|
+
const id = sessionId(ctx);
|
|
3740
|
+
const rt = runtimes.get(id);
|
|
3741
|
+
if (!rt) return;
|
|
3742
|
+
// Clear the streaming flag for SDK consumers even when notifications are off.
|
|
3743
|
+
rt.busy = false;
|
|
3744
|
+
const correlation = rt.activePromptCorrelation;
|
|
3745
|
+
if (correlation) {
|
|
3746
|
+
if (rt.recordPromptTerminal(correlation))
|
|
3747
|
+
rt.emitPromptLifecycle(correlation, { type: "agent_end", sessionId: id, ...correlation });
|
|
3748
|
+
} else {
|
|
3749
|
+
rt.emitPromptLifecycle(undefined, { type: "agent_end", sessionId: id });
|
|
3750
|
+
}
|
|
3751
|
+
try {
|
|
3752
|
+
pushSessionFrame(rt, { type: "activity", sessionId: id, state: "idle" });
|
|
3753
|
+
} catch (e) {
|
|
3754
|
+
logger.warn(`notifications: activity (idle) failed: ${String(e)}`);
|
|
3755
|
+
}
|
|
3756
|
+
if (!rt.notificationsActive) return;
|
|
3757
|
+
void rt.workflowGate?.recoverAcceptedGates?.();
|
|
3758
|
+
const seq = rt.idleSeq++;
|
|
3759
|
+
// Re-assert the identity header so the daemon renames the topic once the
|
|
3760
|
+
// session title has been auto-generated ("{repo}/{branch} - {title}"). The
|
|
3761
|
+
// daemon only renames when the title actually changed.
|
|
3762
|
+
try {
|
|
3763
|
+
pushSessionFrame(rt, {
|
|
3764
|
+
type: "identity_header",
|
|
3765
|
+
sessionId: id,
|
|
3766
|
+
...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
|
|
3767
|
+
});
|
|
3768
|
+
} catch {}
|
|
3769
|
+
try {
|
|
3770
|
+
rt.server.noteIdle(
|
|
3771
|
+
JSON.stringify(
|
|
3772
|
+
notificationActionPayload(
|
|
3773
|
+
{
|
|
3774
|
+
id: `idle:${id}#${seq}`,
|
|
3775
|
+
kind: "idle",
|
|
3776
|
+
sessionId: id,
|
|
3777
|
+
summary: undefined,
|
|
3778
|
+
},
|
|
3779
|
+
{ redact: rt.redact, sessionTag: rt.sessionTag },
|
|
3780
|
+
),
|
|
3781
|
+
),
|
|
3782
|
+
);
|
|
3783
|
+
} catch (e) {
|
|
3784
|
+
logger.warn(`notifications: noteIdle failed: ${String(e)}`);
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3787
|
+
// On idle, stream a context update with metadata (token/model usage +
|
|
3788
|
+
// working-tree diff) unless redaction is on. The agent's last message is
|
|
3789
|
+
// NOT repeated here — it is already streamed once via `turn_stream`.
|
|
3790
|
+
if (!rt.redact && rt.verbosity === "verbose") {
|
|
3791
|
+
const usage = (
|
|
3792
|
+
ctx as { getContextUsage?: () => { tokens: number | null; contextWindow: number } | undefined }
|
|
3793
|
+
).getContextUsage?.();
|
|
3794
|
+
const model = (ctx as { getModel?: () => { id?: string } | undefined }).getModel?.();
|
|
3795
|
+
const tokenUsage = usage && usage.tokens != null ? `${usage.tokens}/${usage.contextWindow}` : undefined;
|
|
3796
|
+
const modelId = model?.id;
|
|
3797
|
+
void readGitDiffStat(ctx.cwd).then(diff => {
|
|
3798
|
+
const cwd = compactCwd(ctx.cwd);
|
|
3799
|
+
if (!diff && !tokenUsage && !modelId && !cwd) return;
|
|
3800
|
+
try {
|
|
3801
|
+
pushSessionFrame(rt, {
|
|
3802
|
+
type: "context_update",
|
|
3803
|
+
sessionId: id,
|
|
3804
|
+
tokenUsage,
|
|
3805
|
+
model: modelId,
|
|
3806
|
+
diff,
|
|
3807
|
+
cwd,
|
|
3808
|
+
});
|
|
3809
|
+
} catch (e) {
|
|
3810
|
+
logger.warn(`notifications: context_update failed: ${String(e)}`);
|
|
3811
|
+
}
|
|
3812
|
+
});
|
|
3813
|
+
}
|
|
3814
|
+
});
|
|
3815
|
+
|
|
3816
|
+
// Stream viable agent output per turn (the live thread mirror). Unlike idle,
|
|
3817
|
+
// turn output is expected to be multiple messages — one per turn that
|
|
3818
|
+
// produced assistant text. Tool-only turns yield no text and are skipped.
|
|
3819
|
+
// Redaction suppresses streamed content (only the one-time identity header
|
|
3820
|
+
// survives redaction). The daemon coalesces/throttles these via its shared
|
|
3821
|
+
// rate-limit pool before sending to Telegram.
|
|
3822
|
+
// Push the in-flight turn's assistant text as a finalized turn_stream, deduped
|
|
3823
|
+
// against what was already flushed for this turn (the pre-ask lead-in).
|
|
3824
|
+
const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined, finalAnswer: boolean): void => {
|
|
3825
|
+
if (!text || text === rt.preAskFlushedText || !rt.notificationsActive) return;
|
|
3826
|
+
rt.preAskFlushedText = text;
|
|
3827
|
+
// Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
|
|
3828
|
+
// live message, never a fresh (rich-promotable) send. If live frames were
|
|
3829
|
+
// async-queued and none landed before this flush, allocate the per-turn ref
|
|
3830
|
+
// now so the finalized frame always carries a messageRef → the daemon keeps it
|
|
3831
|
+
// editable (HTML edit) and never rich-promotes a streamed final.
|
|
3832
|
+
if (finalAnswer && rt.stream && rt.liveRef === undefined) {
|
|
3833
|
+
rt.turnSeq = (rt.turnSeq ?? 0) + 1;
|
|
3834
|
+
rt.liveRef = String(rt.turnSeq);
|
|
3835
|
+
}
|
|
3836
|
+
try {
|
|
3837
|
+
pushSessionFrame(rt, {
|
|
3838
|
+
type: "turn_stream",
|
|
3839
|
+
sessionId: id,
|
|
3840
|
+
phase: "finalized",
|
|
3841
|
+
finalAnswer,
|
|
3842
|
+
text,
|
|
3843
|
+
...(rt.liveRef ? { messageRef: rt.liveRef } : {}),
|
|
3844
|
+
});
|
|
3845
|
+
} catch (e) {
|
|
3846
|
+
logger.warn(`notifications: pushFrame (turn) failed: ${String(e)}`);
|
|
3847
|
+
}
|
|
3848
|
+
};
|
|
3849
|
+
|
|
3850
|
+
// Emit the assistant text that precedes an ask BEFORE the ask's action_needed
|
|
3851
|
+
// is broadcast, so the remote (e.g. Telegram) shows the lead-in first instead
|
|
3852
|
+
// of only after the ask resolves at turn_end. The text is captured on
|
|
3853
|
+
// message_end (which, like tool_execution_start, is on the awaited extension
|
|
3854
|
+
// path and ordered before it — unlike message_update, which is queued async),
|
|
3855
|
+
// then flushed here before the ask tool's execute calls registerAsk.
|
|
3856
|
+
api.on("tool_execution_start", (event, ctx) => {
|
|
3857
|
+
if (event.toolName !== "ask") return;
|
|
3858
|
+
const id = sessionId(ctx);
|
|
3859
|
+
const rt = runtimes.get(id);
|
|
3860
|
+
if (!rt?.notificationsActive || rt.redact) return;
|
|
3861
|
+
flushTurnText(rt, id, rt.currentTurnText, false);
|
|
3862
|
+
});
|
|
3863
|
+
|
|
3864
|
+
api.on("turn_end", (event, ctx) => {
|
|
3865
|
+
const id = sessionId(ctx);
|
|
3866
|
+
const rt = runtimes.get(id);
|
|
3867
|
+
if (!rt?.notificationsActive) return;
|
|
3868
|
+
const text = rt.redact ? undefined : summaryFromMessage(event.message, turnTextMax());
|
|
3869
|
+
if (text) flushTurnText(rt, id, text, true);
|
|
3870
|
+
// Reset per-turn streaming state so the next turn starts fresh and a later
|
|
3871
|
+
// turn with identical text is not falsely deduped.
|
|
3872
|
+
rt.currentTurnText = undefined;
|
|
3873
|
+
rt.preAskFlushedText = undefined;
|
|
3874
|
+
rt.liveRef = undefined;
|
|
3875
|
+
// Close the live-stream window: any message_update queued after turn_end is
|
|
3876
|
+
// dropped so it can never emit a stale live edit past the finalized turn.
|
|
3877
|
+
rt.turnClosed = true;
|
|
3878
|
+
rt.lastLiveAt = undefined;
|
|
3879
|
+
rt.lastLiveText = undefined;
|
|
3880
|
+
});
|
|
3881
|
+
|
|
3882
|
+
// Live streaming (opt-in): push throttled in-progress assistant text as
|
|
3883
|
+
// non-finalized turn_stream frames so remote clients edit one message as the
|
|
3884
|
+
// turn streams. The finalized frame (turn_end) carries the same messageRef and
|
|
3885
|
+
// lands the authoritative text. Suppressed under redaction.
|
|
3886
|
+
api.on("message_update", (event, ctx) => {
|
|
3887
|
+
const id = sessionId(ctx);
|
|
3888
|
+
const rt = runtimes.get(id);
|
|
3889
|
+
if (!rt?.notificationsActive || !rt.stream || rt.redact || rt.turnClosed) return;
|
|
3890
|
+
if ((event.message as { role?: unknown }).role !== "assistant") return;
|
|
3891
|
+
if (rt.liveRef === undefined) {
|
|
3892
|
+
rt.turnSeq = (rt.turnSeq ?? 0) + 1;
|
|
3893
|
+
rt.liveRef = String(rt.turnSeq);
|
|
3894
|
+
}
|
|
3895
|
+
const now = Date.now();
|
|
3896
|
+
if (now - (rt.lastLiveAt ?? 0) < streamIntervalMs()) return;
|
|
3897
|
+
const text = summaryFromMessage(event.message, 3500);
|
|
3898
|
+
if (!text || text === rt.lastLiveText) return;
|
|
3899
|
+
rt.lastLiveAt = now;
|
|
3900
|
+
rt.lastLiveText = text;
|
|
3901
|
+
try {
|
|
3902
|
+
pushSessionFrame(rt, { type: "turn_stream", sessionId: id, phase: "live", text, messageRef: rt.liveRef });
|
|
3903
|
+
} catch (e) {
|
|
3904
|
+
logger.warn(`notifications: pushFrame (live) failed: ${String(e)}`);
|
|
3905
|
+
}
|
|
3906
|
+
});
|
|
3907
|
+
|
|
3908
|
+
// Stream agent-produced images (computer/browser/tool screenshots) as
|
|
3909
|
+
// image_attachment frames; suppressed when redaction is on.
|
|
3910
|
+
api.on("message_end", (event, ctx) => {
|
|
3911
|
+
const id = sessionId(ctx);
|
|
3912
|
+
const rt = runtimes.get(id);
|
|
3913
|
+
if (!rt?.notificationsActive || rt.redact) return;
|
|
3914
|
+
// Capture the in-flight ASSISTANT text here (message_end is on the awaited
|
|
3915
|
+
// extension path and ordered before tool_execution_start) so the pre-ask
|
|
3916
|
+
// flush can emit it before the ask prompt. Role-scoped: message_end also
|
|
3917
|
+
// fires for the user prompt, which must never be mirrored back as turn output.
|
|
3918
|
+
if ((event.message as { role?: unknown }).role === "assistant") {
|
|
3919
|
+
const turnText = summaryFromMessage(event.message, turnTextMax());
|
|
3920
|
+
if (turnText) rt.currentTurnText = turnText;
|
|
3921
|
+
}
|
|
3922
|
+
for (const img of imageAttachmentsFromMessage(event.message)) {
|
|
3923
|
+
try {
|
|
3924
|
+
pushSessionFrame(rt, {
|
|
3925
|
+
type: "image_attachment",
|
|
3926
|
+
sessionId: id,
|
|
3927
|
+
source: img.source,
|
|
3928
|
+
mime: img.mime,
|
|
3929
|
+
data: img.data,
|
|
3930
|
+
});
|
|
3931
|
+
} catch (e) {
|
|
3932
|
+
logger.warn(`notifications: image_attachment failed: ${String(e)}`);
|
|
3933
|
+
}
|
|
3934
|
+
}
|
|
3935
|
+
});
|
|
3936
|
+
|
|
3937
|
+
api.on("session_shutdown", async (_event, ctx) => {
|
|
3938
|
+
const controllerStop =
|
|
3939
|
+
typeof ctx.sessionManager.getCwd === "function" ? controller.stopCurrentSession(ctx) : Promise.resolve(false);
|
|
3940
|
+
void controllerStop.catch(error => logger.warn(`notifications: controller shutdown failed: ${String(error)}`));
|
|
3941
|
+
await stopSession(sessionId(ctx));
|
|
3942
|
+
});
|
|
3943
|
+
}
|