@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
|
@@ -1,1835 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Notifications extension.
|
|
3
|
-
*
|
|
4
|
-
* Hosts a per-session loopback WebSocket notification server (the Rust core via
|
|
5
|
-
* N-API) and bridges GJC session events + the `ask` tool to it so a remote client
|
|
6
|
-
* (e.g. a Telegram bot) can both see action-needed signals and ANSWER them —
|
|
7
|
-
* without requiring RPC/unattended mode:
|
|
8
|
-
*
|
|
9
|
-
* - `ask` (interactive): registers an {@link AskAnswerSource}; the ask tool races
|
|
10
|
-
* the local UI against a remote reply. First valid answer wins; a local answer
|
|
11
|
-
* aborts the remote wait (and broadcasts `action_resolved` resolvedBy=local).
|
|
12
|
-
* - `ask` (unattended/RPC): observes emitted workflow gates and resolves the real
|
|
13
|
-
* gate on a remote reply via `ctx.workflowGate`.
|
|
14
|
-
* - `turn_end` -> `action_needed` (kind `idle`, deduped per turn).
|
|
15
|
-
* - `session_shutdown` -> `session_closed` frame, stop server, deregister answer source.
|
|
16
|
-
*
|
|
17
|
-
* Enable with Settings notifications config, `GJC_NOTIFICATIONS=1` (a token is
|
|
18
|
-
* generated), or `GJC_NOTIFICATIONS_TOKEN`.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { execFile } from "node:child_process";
|
|
22
|
-
import * as crypto from "node:crypto";
|
|
23
|
-
import * as fs from "node:fs";
|
|
24
|
-
import * as os from "node:os";
|
|
25
|
-
import * as path from "node:path";
|
|
26
|
-
import { promisify } from "node:util";
|
|
27
|
-
import { ThinkingLevel } from "@gajae-code/agent-core";
|
|
28
|
-
import type { ImageContent, TextContent } from "@gajae-code/ai";
|
|
29
|
-
import { NotificationServer } from "@gajae-code/natives";
|
|
30
|
-
import { logger, postmortem } from "@gajae-code/utils";
|
|
31
|
-
import { Settings } from "../config/settings";
|
|
32
|
-
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../extensibility/extensions";
|
|
33
|
-
import type {
|
|
34
|
-
WorkflowGateEmitter,
|
|
35
|
-
WorkflowGateTerminalController,
|
|
36
|
-
} from "../modes/shared/agent-wire/unattended-session";
|
|
37
|
-
import { parseThinkingLevel } from "../thinking";
|
|
38
|
-
import type {
|
|
39
|
-
AskAnswerRequest,
|
|
40
|
-
AskAnswerSourceResult,
|
|
41
|
-
AskRemoteControl,
|
|
42
|
-
AskRemoteInteraction,
|
|
43
|
-
AskRemoteReceipt,
|
|
44
|
-
AskSelectedAckOutcome,
|
|
45
|
-
AskSettlement,
|
|
46
|
-
AskSettlementResult,
|
|
47
|
-
} from "../tools";
|
|
48
|
-
import { registerAskAnswerSource, registerWorkflowGateEmitterListener } from "../tools/ask-answer-registry";
|
|
49
|
-
import { registerTelegramFileSink } from "./attachment-registry";
|
|
50
|
-
import {
|
|
51
|
-
getNotificationConfig,
|
|
52
|
-
isSessionNotificationsEnabled,
|
|
53
|
-
isTelegramConfigured,
|
|
54
|
-
type NotificationConfig,
|
|
55
|
-
sessionTag,
|
|
56
|
-
} from "./config";
|
|
57
|
-
import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMessage } from "./helpers";
|
|
58
|
-
import { ensureTelegramDaemonRunning } from "./telegram-daemon";
|
|
59
|
-
|
|
60
|
-
// ===========================================================================
|
|
61
|
-
// Session lifecycle control protocol (TypeScript mirror of the Rust wire
|
|
62
|
-
// contract in `crates/gjc-notifications/src/lifecycle.rs`).
|
|
63
|
-
//
|
|
64
|
-
// These describe the frames exchanged over the daemon-owned, session-independent
|
|
65
|
-
// control endpoint for remote session create / close / resume. Field names are
|
|
66
|
-
// camelCase on the wire; `type`/`kind` discriminators are snake_case. The Rust
|
|
67
|
-
// ingress authenticates and forwards; the daemon (TypeScript) owns all policy,
|
|
68
|
-
// spawn orchestration, idempotency, rate limiting, audit, and UX.
|
|
69
|
-
// ===========================================================================
|
|
70
|
-
|
|
71
|
-
/** Where a `session_create` should run. Discriminated by `kind`. */
|
|
72
|
-
export type SessionCreateTarget =
|
|
73
|
-
| { kind: "existing_path"; path: string }
|
|
74
|
-
| { kind: "worktree"; repo: string; branch: string }
|
|
75
|
-
| { kind: "plain_dir"; path: string };
|
|
76
|
-
|
|
77
|
-
/** Identifies the session a `session_close` targets. */
|
|
78
|
-
export interface SessionCloseTarget {
|
|
79
|
-
sessionId: string;
|
|
80
|
-
/** Expected GJC-managed tmux session name (defense-in-depth match). */
|
|
81
|
-
tmuxSession?: string;
|
|
82
|
-
/** Expected `@gjc-session-state-file` tag (defense-in-depth match). */
|
|
83
|
-
sessionStateFile?: string;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Identifies the session a `session_resume` targets. */
|
|
87
|
-
export interface SessionResumeTarget {
|
|
88
|
-
sessionIdOrPrefix: string;
|
|
89
|
-
/** Optional repo/working-dir hint to disambiguate matches. */
|
|
90
|
-
path?: string;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Create a new session. */
|
|
94
|
-
export interface SessionCreateFrame {
|
|
95
|
-
type: "session_create";
|
|
96
|
-
requestId: string;
|
|
97
|
-
/** Deterministic lifecycle marker preallocated by the daemon before spawn. */
|
|
98
|
-
lifecycleRequestId: string;
|
|
99
|
-
/** Session id the daemon preallocated and propagates to the child. */
|
|
100
|
-
intendedSessionId: string;
|
|
101
|
-
/** Telegram update id (idempotency key on the daemon side). */
|
|
102
|
-
updateId: number;
|
|
103
|
-
chatId: string;
|
|
104
|
-
/** Control-endpoint token authorizing this frame. */
|
|
105
|
-
token: string;
|
|
106
|
-
target: SessionCreateTarget;
|
|
107
|
-
/** Reference to the daemon-written, once-consumed startup-prompt file. */
|
|
108
|
-
startupPromptRef?: string;
|
|
109
|
-
/** Model profile preset to activate for the spawned session (--mpreset). */
|
|
110
|
-
modelPreset?: string;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Close (hard-kill, history preserved) a session. */
|
|
114
|
-
export interface SessionCloseFrame {
|
|
115
|
-
type: "session_close";
|
|
116
|
-
requestId: string;
|
|
117
|
-
updateId: number;
|
|
118
|
-
chatId: string;
|
|
119
|
-
token: string;
|
|
120
|
-
target: SessionCloseTarget;
|
|
121
|
-
/** Required force-only close flag; false/omitted is rejected by daemon policy. */
|
|
122
|
-
force?: boolean;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Resume a session (reattach if alive, else cold-restart from history). */
|
|
126
|
-
export interface SessionResumeFrame {
|
|
127
|
-
type: "session_resume";
|
|
128
|
-
requestId: string;
|
|
129
|
-
updateId: number;
|
|
130
|
-
chatId: string;
|
|
131
|
-
token: string;
|
|
132
|
-
target: SessionResumeTarget;
|
|
133
|
-
startupPromptRef?: string;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Any client -> ingress lifecycle request frame. */
|
|
137
|
-
export type SessionLifecycleRequest = SessionCreateFrame | SessionCloseFrame | SessionResumeFrame;
|
|
138
|
-
|
|
139
|
-
/** Terminal status of a lifecycle request. */
|
|
140
|
-
export type LifecycleStatus = "ok" | "error";
|
|
141
|
-
|
|
142
|
-
/** A connected session's per-session endpoint, returned to the control client. */
|
|
143
|
-
export interface LifecycleEndpoint {
|
|
144
|
-
url: string;
|
|
145
|
-
token: string;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** The Telegram topic/thread a session is surfaced in. */
|
|
149
|
-
export interface LifecycleTopic {
|
|
150
|
-
chatId: string;
|
|
151
|
-
threadId: string;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** How a create request was correlated to its spawned session. */
|
|
155
|
-
export type MatchedBy = "spawn_marker" | "session_ready";
|
|
156
|
-
|
|
157
|
-
/** Response to a successful `session_create`. */
|
|
158
|
-
export interface SessionCreateResponseFrame {
|
|
159
|
-
type: "session_create_response";
|
|
160
|
-
requestId: string;
|
|
161
|
-
status: LifecycleStatus;
|
|
162
|
-
lifecycleRequestId: string;
|
|
163
|
-
sessionId: string;
|
|
164
|
-
matchedBy: MatchedBy;
|
|
165
|
-
endpoint: LifecycleEndpoint;
|
|
166
|
-
topic: LifecycleTopic;
|
|
167
|
-
target: SessionCreateTarget;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Response to a successful `session_close`. */
|
|
171
|
-
export interface SessionCloseResponseFrame {
|
|
172
|
-
type: "session_close_response";
|
|
173
|
-
requestId: string;
|
|
174
|
-
status: LifecycleStatus;
|
|
175
|
-
sessionId: string;
|
|
176
|
-
processGone: boolean;
|
|
177
|
-
historyPreserved: boolean;
|
|
178
|
-
endpointStale: boolean;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Whether a resume reattached to a live session or cold-restarted a dead one. */
|
|
182
|
-
export type ResumeMode = "reattached" | "cold_restarted";
|
|
183
|
-
|
|
184
|
-
/** Response to a successful `session_resume`. */
|
|
185
|
-
export interface SessionResumeResponseFrame {
|
|
186
|
-
type: "session_resume_response";
|
|
187
|
-
requestId: string;
|
|
188
|
-
status: LifecycleStatus;
|
|
189
|
-
sessionId: string;
|
|
190
|
-
mode: ResumeMode;
|
|
191
|
-
endpoint: LifecycleEndpoint;
|
|
192
|
-
topic: LifecycleTopic;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** Machine-readable reason a lifecycle request failed. */
|
|
196
|
-
export type LifecycleErrorReason =
|
|
197
|
-
| "unauthorized"
|
|
198
|
-
| "rate_limited"
|
|
199
|
-
| "duplicate_conflict"
|
|
200
|
-
| "invalid_target"
|
|
201
|
-
| "ambiguous_target"
|
|
202
|
-
| "spawn_failed"
|
|
203
|
-
| "discovery_timeout"
|
|
204
|
-
| "readiness_timeout"
|
|
205
|
-
| "close_refused"
|
|
206
|
-
| "not_found"
|
|
207
|
-
| "terminal_uncertain";
|
|
208
|
-
|
|
209
|
-
/** A candidate returned with an `ambiguous_target` resume error. */
|
|
210
|
-
export interface ResumeCandidate {
|
|
211
|
-
sessionId: string;
|
|
212
|
-
path?: string;
|
|
213
|
-
/** Last-activity epoch-millis (session history file mtime), if known. */
|
|
214
|
-
mtimeMs?: number;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** A structured lifecycle error frame. */
|
|
218
|
-
export interface SessionLifecycleErrorFrame {
|
|
219
|
-
type: "session_lifecycle_error";
|
|
220
|
-
requestId: string;
|
|
221
|
-
status: LifecycleStatus;
|
|
222
|
-
reason: LifecycleErrorReason;
|
|
223
|
-
message: string;
|
|
224
|
-
candidates?: ResumeCandidate[];
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** Any ingress -> client lifecycle response frame. */
|
|
228
|
-
export type SessionLifecycleResponse =
|
|
229
|
-
| SessionCreateResponseFrame
|
|
230
|
-
| SessionCloseResponseFrame
|
|
231
|
-
| SessionResumeResponseFrame
|
|
232
|
-
| SessionLifecycleErrorFrame;
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Replayable per-session readiness signal (mirror of the Rust `session_ready`
|
|
236
|
-
* frame). Buffered and replayed to late clients so WS-open alone never implies
|
|
237
|
-
* the session is live and surfaced.
|
|
238
|
-
*/
|
|
239
|
-
export interface SessionReadyFrame {
|
|
240
|
-
type: "session_ready";
|
|
241
|
-
sessionId: string;
|
|
242
|
-
lifecycleRequestId?: string;
|
|
243
|
-
startupPromptRef?: string;
|
|
244
|
-
repo?: string;
|
|
245
|
-
branch?: string;
|
|
246
|
-
title?: string;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/** Resolve the git dir for `cwd`, handling worktrees where `.git` is a file. */
|
|
250
|
-
function gitDir(cwd: string): string | undefined {
|
|
251
|
-
const dot = path.join(cwd, ".git");
|
|
252
|
-
try {
|
|
253
|
-
if (fs.statSync(dot).isDirectory()) return dot;
|
|
254
|
-
const m = fs
|
|
255
|
-
.readFileSync(dot, "utf8")
|
|
256
|
-
.trim()
|
|
257
|
-
.match(/^gitdir:\s*(.+)$/);
|
|
258
|
-
if (m) return path.resolve(cwd, m[1]);
|
|
259
|
-
} catch {}
|
|
260
|
-
return undefined;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/** Best-effort current branch from `.git/HEAD` (no git spawn). */
|
|
264
|
-
function readGitBranch(cwd: string): string | undefined {
|
|
265
|
-
const gd = gitDir(cwd);
|
|
266
|
-
if (!gd) return undefined;
|
|
267
|
-
try {
|
|
268
|
-
const head = fs.readFileSync(path.join(gd, "HEAD"), "utf8").trim();
|
|
269
|
-
const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
|
|
270
|
-
return m ? m[1] : head.slice(0, 12);
|
|
271
|
-
} catch {
|
|
272
|
-
return undefined;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/** Resolve the shared git dir (the main repo's `.git`) for a possibly-linked worktree. */
|
|
277
|
-
function gitCommonDir(gd: string): string {
|
|
278
|
-
try {
|
|
279
|
-
const raw = fs.readFileSync(path.join(gd, "commondir"), "utf8").trim();
|
|
280
|
-
if (raw) return path.resolve(gd, raw);
|
|
281
|
-
} catch {}
|
|
282
|
-
return gd;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Best-effort real repository name (no git spawn): resolves the main worktree
|
|
287
|
-
* root directory so linked worktrees report the repo (e.g. `gajae-code`)
|
|
288
|
-
* instead of the worktree directory (e.g. `feat-foo-01047f11`).
|
|
289
|
-
*/
|
|
290
|
-
export function readGitRepoName(cwd: string): string | undefined {
|
|
291
|
-
const gd = gitDir(cwd);
|
|
292
|
-
if (!gd) return undefined;
|
|
293
|
-
const commonDir = gitCommonDir(gd);
|
|
294
|
-
// Strip the trailing `.git` to land on the main worktree root directory.
|
|
295
|
-
const repoRoot = path.basename(commonDir) === ".git" ? path.dirname(commonDir) : commonDir;
|
|
296
|
-
const name = path.basename(repoRoot);
|
|
297
|
-
return name && name !== ".git" ? name : undefined;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** Build the one-time identity header fields for a session thread. */
|
|
301
|
-
function buildIdentity(
|
|
302
|
-
cwd: string,
|
|
303
|
-
sessionName?: string,
|
|
304
|
-
): {
|
|
305
|
-
repo: string;
|
|
306
|
-
branch: string;
|
|
307
|
-
machine: string;
|
|
308
|
-
title?: string;
|
|
309
|
-
} {
|
|
310
|
-
const repo = readGitRepoName(cwd) ?? (path.basename(cwd) || cwd);
|
|
311
|
-
const branch = readGitBranch(cwd) ?? "(detached)";
|
|
312
|
-
// Send repo/branch and the raw session title separately; the consumer
|
|
313
|
-
// composes the topic name ("{repo}/{branch}" before the session title is
|
|
314
|
-
// auto-generated, then "{repo}/{branch} - {session title}" once it exists).
|
|
315
|
-
return { repo, branch, machine: os.hostname(), title: sessionName };
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/** Compact cwd label for remote session identity; never emits the full host path by default. */
|
|
319
|
-
function compactCwd(cwd: string): string | undefined {
|
|
320
|
-
const home = os.homedir();
|
|
321
|
-
const resolved = path.resolve(cwd);
|
|
322
|
-
if (resolved === home) return "~";
|
|
323
|
-
const base = path.basename(resolved);
|
|
324
|
-
return base || path.parse(resolved).root || undefined;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const execFileAsync = promisify(execFile);
|
|
328
|
-
|
|
329
|
-
/** Best-effort working-tree diff stat for the context update (no throw). */
|
|
330
|
-
async function readGitDiffStat(cwd: string): Promise<string | undefined> {
|
|
331
|
-
try {
|
|
332
|
-
const { stdout } = await execFileAsync("git", ["-C", cwd, "diff", "--stat", "--no-color"], {
|
|
333
|
-
timeout: 3000,
|
|
334
|
-
maxBuffer: 256 * 1024,
|
|
335
|
-
});
|
|
336
|
-
const trimmed = stdout.trim();
|
|
337
|
-
return trimmed ? trimmed.slice(0, 1500) : undefined;
|
|
338
|
-
} catch {
|
|
339
|
-
return undefined;
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
interface PendingInteractiveAsk {
|
|
344
|
-
resolve: (result: AskAnswerSourceResult) => void;
|
|
345
|
-
options: string[];
|
|
346
|
-
controls: readonly AskRemoteControl[];
|
|
347
|
-
reissue: () => boolean;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
interface UnattendedGatePresentation {
|
|
351
|
-
gateId: string;
|
|
352
|
-
sessionId: string;
|
|
353
|
-
question: string;
|
|
354
|
-
options: string[];
|
|
355
|
-
controls: readonly AskRemoteControl[];
|
|
356
|
-
multi: boolean;
|
|
357
|
-
allowEmpty: boolean;
|
|
358
|
-
navigationLabel?: "Next" | "Done";
|
|
359
|
-
selectedOptions: string[];
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/** Keeps transport interaction ids separate from durable workflow gate ids. */
|
|
363
|
-
class GatePresentationRegistry {
|
|
364
|
-
private readonly presentations = new Map<string, UnattendedGatePresentation>();
|
|
365
|
-
private readonly routes = new Map<string, string>();
|
|
366
|
-
|
|
367
|
-
constructor(
|
|
368
|
-
private readonly server: NotificationServer,
|
|
369
|
-
private readonly redact: () => boolean,
|
|
370
|
-
private readonly tag: string,
|
|
371
|
-
) {}
|
|
372
|
-
|
|
373
|
-
retain(presentation: UnattendedGatePresentation): void {
|
|
374
|
-
this.presentations.set(presentation.gateId, presentation);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
routeFor(actionId: string): string | undefined {
|
|
378
|
-
return this.routes.get(actionId);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
presentationFor(actionId: string): UnattendedGatePresentation | undefined {
|
|
382
|
-
const gateId = this.routes.get(actionId);
|
|
383
|
-
return gateId ? this.presentations.get(gateId) : undefined;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
toggle(actionId: string, label: string): boolean {
|
|
387
|
-
const presentation = this.presentationFor(actionId);
|
|
388
|
-
if (!presentation?.multi || !presentation.options.includes(label)) return false;
|
|
389
|
-
this.closeInteraction(actionId, "toggle");
|
|
390
|
-
const selected = new Set(presentation.selectedOptions);
|
|
391
|
-
if (selected.has(label)) selected.delete(label);
|
|
392
|
-
else selected.add(label);
|
|
393
|
-
presentation.selectedOptions = [...selected];
|
|
394
|
-
this.reissue(presentation.gateId);
|
|
395
|
-
return true;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
reissue(gateId: string): string | undefined {
|
|
399
|
-
const presentation = this.presentations.get(gateId);
|
|
400
|
-
if (!presentation) return undefined;
|
|
401
|
-
const actionId = `gate-interaction:${crypto.randomUUID()}`;
|
|
402
|
-
this.routes.set(actionId, gateId);
|
|
403
|
-
try {
|
|
404
|
-
this.server.registerAsk(
|
|
405
|
-
JSON.stringify(
|
|
406
|
-
notificationActionPayload(
|
|
407
|
-
{
|
|
408
|
-
id: actionId,
|
|
409
|
-
kind: "ask",
|
|
410
|
-
sessionId: presentation.sessionId,
|
|
411
|
-
question:
|
|
412
|
-
presentation.selectedOptions.length > 0
|
|
413
|
-
? `(${presentation.selectedOptions.length} selected) ${presentation.question}`
|
|
414
|
-
: presentation.question,
|
|
415
|
-
options: presentation.options,
|
|
416
|
-
controls: presentation.multi
|
|
417
|
-
? [
|
|
418
|
-
{
|
|
419
|
-
id: "navigation_forward",
|
|
420
|
-
kind: "navigation",
|
|
421
|
-
label: presentation.navigationLabel ?? "Done",
|
|
422
|
-
enabled: presentation.allowEmpty || presentation.selectedOptions.length > 0,
|
|
423
|
-
},
|
|
424
|
-
]
|
|
425
|
-
: [],
|
|
426
|
-
},
|
|
427
|
-
{ redact: this.redact(), sessionTag: this.tag },
|
|
428
|
-
),
|
|
429
|
-
),
|
|
430
|
-
true,
|
|
431
|
-
);
|
|
432
|
-
return actionId;
|
|
433
|
-
} catch (error) {
|
|
434
|
-
this.routes.delete(actionId);
|
|
435
|
-
logger.warn(`notifications: registerAsk (gate interaction) failed: ${String(error)}`);
|
|
436
|
-
return undefined;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
closeInteraction(actionId: string, reason: string): void {
|
|
441
|
-
this.routes.delete(actionId);
|
|
442
|
-
try {
|
|
443
|
-
this.server.resolveLocal(actionId, undefined);
|
|
444
|
-
} catch {
|
|
445
|
-
// The native claim close already terminalizes this action when applicable.
|
|
446
|
-
}
|
|
447
|
-
void reason;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
complete(gateId: string): void {
|
|
451
|
-
for (const [actionId, routeGateId] of this.routes) {
|
|
452
|
-
if (routeGateId !== gateId) continue;
|
|
453
|
-
this.closeInteraction(actionId, "gate_complete");
|
|
454
|
-
}
|
|
455
|
-
this.presentations.delete(gateId);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
cancel(gateId: string, reason: string): void {
|
|
459
|
-
this.complete(gateId);
|
|
460
|
-
void reason;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
dispose(): void {
|
|
464
|
-
for (const gateId of [...this.presentations.keys()]) this.cancel(gateId, "session_shutdown");
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
interface SessionRuntime {
|
|
469
|
-
server: NotificationServer;
|
|
470
|
-
idleSeq: number;
|
|
471
|
-
/** Interactive asks awaiting a remote answer, by action id. */
|
|
472
|
-
pendingInteractive: Map<string, PendingInteractiveAsk>;
|
|
473
|
-
/** Deregisters this session's ask answer source. */
|
|
474
|
-
disposeAnswerSource: () => void;
|
|
475
|
-
/** Deregisters this session's Telegram file sink. */
|
|
476
|
-
disposeFileSink: () => void;
|
|
477
|
-
/** Deregisters this session's unattended workflow-gate listener. */
|
|
478
|
-
disposeGateListener: () => void;
|
|
479
|
-
/** Deregisters canonical workflow-gate terminal cleanup. */
|
|
480
|
-
disposeGateTerminalController: () => void;
|
|
481
|
-
disposeAckRecoveryParticipant: () => void;
|
|
482
|
-
disposeGateEmitterListener: () => void;
|
|
483
|
-
workflowGate?: WorkflowGateEmitter;
|
|
484
|
-
gatePresentations?: GatePresentationRegistry;
|
|
485
|
-
redact: boolean;
|
|
486
|
-
verbosity: "lean" | "verbose";
|
|
487
|
-
sessionTag: string;
|
|
488
|
-
/** Whether the agent loop is currently running (drives the typing indicator). */
|
|
489
|
-
busy: boolean;
|
|
490
|
-
/** Inbound Telegram update ids injected but not yet consumed by a turn. */
|
|
491
|
-
pendingInbound: Set<number>;
|
|
492
|
-
/** Latest assistant text of the in-flight turn (from message_update). */
|
|
493
|
-
currentTurnText?: string;
|
|
494
|
-
/** Assistant text already flushed before an ask this turn (turn-scoped dedupe
|
|
495
|
-
* so turn_end does not re-emit the pre-ask lead-in). Reset each turn. */
|
|
496
|
-
preAskFlushedText?: string;
|
|
497
|
-
/** Live streaming: opt-in flag, monotonic per-turn ref, and emit throttle state. */
|
|
498
|
-
stream: boolean;
|
|
499
|
-
turnSeq?: number;
|
|
500
|
-
liveRef?: string;
|
|
501
|
-
lastLiveAt?: number;
|
|
502
|
-
lastLiveText?: string;
|
|
503
|
-
/** True between turn_end and the next turn_start: drops late async message_update
|
|
504
|
-
* frames so a stale live edit can never be emitted after the finalized turn. */
|
|
505
|
-
turnClosed?: boolean;
|
|
506
|
-
/** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */
|
|
507
|
-
cancelPostmortemCleanup: () => void;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
interface ResolvedSettings {
|
|
511
|
-
settings: Settings | undefined;
|
|
512
|
-
cfg: NotificationConfig;
|
|
513
|
-
settingsAvailable: boolean;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const TELEGRAM_FILE_REDACTION_ERROR = "Telegram file attachments are disabled while notifications redaction is on.";
|
|
517
|
-
|
|
518
|
-
const defaultConfig: NotificationConfig = {
|
|
519
|
-
enabled: false,
|
|
520
|
-
botToken: undefined,
|
|
521
|
-
chatId: undefined,
|
|
522
|
-
discord: {
|
|
523
|
-
botToken: undefined,
|
|
524
|
-
channelId: undefined,
|
|
525
|
-
},
|
|
526
|
-
slack: {
|
|
527
|
-
botToken: undefined,
|
|
528
|
-
channelId: undefined,
|
|
529
|
-
},
|
|
530
|
-
redact: false,
|
|
531
|
-
verbosity: "lean",
|
|
532
|
-
sessionScope: "all",
|
|
533
|
-
idleTimeoutMs: 60_000,
|
|
534
|
-
rich: { enabled: true },
|
|
535
|
-
richDraft: { enabled: false },
|
|
536
|
-
topics: {},
|
|
537
|
-
};
|
|
538
|
-
|
|
539
|
-
export function notificationsEnabled(): boolean {
|
|
540
|
-
return process.env.GJC_NOTIFICATIONS === "1" || Boolean(process.env.GJC_NOTIFICATIONS_TOKEN);
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// Live streaming (opt-in): emit throttled non-finalized `turn_stream` frames as
|
|
544
|
-
// the assistant message streams so remote clients can edit ONE message live. The
|
|
545
|
-
// finalized frame (turn_end) carries the same messageRef and stays authoritative,
|
|
546
|
-
// so a dropped live frame self-heals. Off unless GJC_NOTIFICATIONS_STREAM=1.
|
|
547
|
-
function streamingEnabled(): boolean {
|
|
548
|
-
return process.env.GJC_NOTIFICATIONS_STREAM === "1";
|
|
549
|
-
}
|
|
550
|
-
function streamIntervalMs(): number {
|
|
551
|
-
return Math.max(200, Number(process.env.GJC_NOTIFICATIONS_STREAM_INTERVAL_MS) || 500);
|
|
552
|
-
}
|
|
553
|
-
// Max chars of a turn's assistant text carried by the FINALIZED turn_stream (and
|
|
554
|
-
// the pre-ask capture). Finalized turns default to the bounded full-turn ceiling
|
|
555
|
-
// because split-capable clients such as the Telegram daemon schedule each
|
|
556
|
-
// splitTelegramHtml chunk through the shared rate-limit pool. Operators who want
|
|
557
|
-
// glanceable summaries can lower this with GJC_NOTIFICATIONS_TURN_MAX. The value
|
|
558
|
-
// is always clamped to a finite [280, TURN_TEXT_MAX_CEILING] range so the cap can
|
|
559
|
-
// never be unbounded. Live frames are intentionally NOT raised — they stay one
|
|
560
|
-
// editable preview message rather than fanning a long in-progress turn across
|
|
561
|
-
// sends.
|
|
562
|
-
const TURN_TEXT_MAX_CEILING = 40_000;
|
|
563
|
-
function turnTextMax(): number {
|
|
564
|
-
const raw = Number(process.env.GJC_NOTIFICATIONS_TURN_MAX);
|
|
565
|
-
if (!Number.isFinite(raw) || raw <= 0) return TURN_TEXT_MAX_CEILING;
|
|
566
|
-
return Math.min(TURN_TEXT_MAX_CEILING, Math.max(280, raw));
|
|
567
|
-
}
|
|
568
|
-
function resolveSettings(settingsOverride?: Settings): ResolvedSettings {
|
|
569
|
-
if (settingsOverride)
|
|
570
|
-
return { settings: settingsOverride, cfg: getNotificationConfig(settingsOverride), settingsAvailable: true };
|
|
571
|
-
try {
|
|
572
|
-
const settings = Settings.instance;
|
|
573
|
-
return { settings, cfg: getNotificationConfig(settings), settingsAvailable: true };
|
|
574
|
-
} catch {
|
|
575
|
-
return { settings: undefined, cfg: defaultConfig, settingsAvailable: false };
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
function resolveToken(): string {
|
|
580
|
-
return process.env.GJC_NOTIFICATIONS_TOKEN ?? crypto.randomBytes(24).toString("base64url");
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
function parseAnswer(answerJson: string): unknown {
|
|
584
|
-
try {
|
|
585
|
-
return JSON.parse(answerJson);
|
|
586
|
-
} catch {
|
|
587
|
-
return answerJson;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
/** Map a client answer to the option LABEL the local UI would return (or free text). */
|
|
592
|
-
function mapAnswerToLabel(answerJson: string, options: string[]): string | undefined {
|
|
593
|
-
const answer = parseAnswer(answerJson);
|
|
594
|
-
if (typeof answer === "number") return options[answer];
|
|
595
|
-
if (typeof answer === "string") return answer;
|
|
596
|
-
if (answer && typeof answer === "object") {
|
|
597
|
-
const sel = (answer as { selected?: unknown; custom?: unknown }).selected;
|
|
598
|
-
if (Array.isArray(sel) && sel.length > 0) {
|
|
599
|
-
const first = sel[0];
|
|
600
|
-
return typeof first === "number" ? options[first] : String(first);
|
|
601
|
-
}
|
|
602
|
-
const custom = (answer as { custom?: unknown }).custom;
|
|
603
|
-
if (typeof custom === "string") return custom;
|
|
604
|
-
}
|
|
605
|
-
return undefined;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
/** Workflow-gate answer shape (unattended mode). */
|
|
609
|
-
interface GateAnswer {
|
|
610
|
-
selected: string[];
|
|
611
|
-
other?: boolean;
|
|
612
|
-
custom?: string;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/**
|
|
616
|
-
* Discriminated result of mapping a client answer to a workflow-gate answer.
|
|
617
|
-
* `ok: false` means the reply is invalid and the caller must close the exact
|
|
618
|
-
* claim/receipt and reissue the interaction rather than durably accepting it.
|
|
619
|
-
*/
|
|
620
|
-
type GateAnswerResult = { ok: true; answer: GateAnswer } | { ok: false; reason: string };
|
|
621
|
-
|
|
622
|
-
/**
|
|
623
|
-
* Map a client answer to the workflow-gate answer shape (unattended mode).
|
|
624
|
-
*
|
|
625
|
-
* The protocol defines a numeric reply as an option index, so a number outside
|
|
626
|
-
* `options` is invalid (issue #2030): it must NOT be converted into a free-text
|
|
627
|
-
* `Other` that passes the ask schema and triggers a misleading success ack.
|
|
628
|
-
* Only JSON strings enter the free-text/Other path.
|
|
629
|
-
*/
|
|
630
|
-
export function mapAnswerToGate(answerJson: string, options: string[]): GateAnswerResult {
|
|
631
|
-
const answer = parseAnswer(answerJson);
|
|
632
|
-
if (typeof answer === "number") {
|
|
633
|
-
const label = options[answer];
|
|
634
|
-
return label === undefined
|
|
635
|
-
? { ok: false, reason: "numeric_selector_out_of_range" }
|
|
636
|
-
: { ok: true, answer: { selected: [label] } };
|
|
637
|
-
}
|
|
638
|
-
if (typeof answer === "string") {
|
|
639
|
-
return {
|
|
640
|
-
ok: true,
|
|
641
|
-
answer: options.includes(answer) ? { selected: [answer] } : { selected: [], other: true, custom: answer },
|
|
642
|
-
};
|
|
643
|
-
}
|
|
644
|
-
if (answer && typeof answer === "object") {
|
|
645
|
-
const obj = answer as { selected?: unknown; custom?: unknown };
|
|
646
|
-
const selected = Array.isArray(obj.selected)
|
|
647
|
-
? obj.selected.map(s => (typeof s === "number" ? (options[s] ?? String(s)) : String(s)))
|
|
648
|
-
: [];
|
|
649
|
-
const custom = typeof obj.custom === "string" ? obj.custom : undefined;
|
|
650
|
-
return { ok: true, answer: { selected, other: custom !== undefined, custom } };
|
|
651
|
-
}
|
|
652
|
-
return { ok: true, answer: { selected: [] } };
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
interface NotificationControlCommandPayload {
|
|
656
|
-
name?: unknown;
|
|
657
|
-
action?: unknown;
|
|
658
|
-
level?: unknown;
|
|
659
|
-
instructions?: unknown;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
function parseControlCommandPayload(json: string | undefined): NotificationControlCommandPayload | undefined {
|
|
663
|
-
if (!json) return undefined;
|
|
664
|
-
try {
|
|
665
|
-
const parsed = JSON.parse(json) as unknown;
|
|
666
|
-
return parsed && typeof parsed === "object" ? (parsed as NotificationControlCommandPayload) : undefined;
|
|
667
|
-
} catch {
|
|
668
|
-
return undefined;
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
function formatCompactTokenCount(value: number | null | undefined): string {
|
|
673
|
-
if (value == null) return "unknown";
|
|
674
|
-
if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1))}m`;
|
|
675
|
-
if (value >= 1_000) return `${Number((value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1))}k`;
|
|
676
|
-
return value.toLocaleString();
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
function formatContextUsageLine(ctx: ExtensionContext): string {
|
|
680
|
-
const usage = ctx.getContextUsage();
|
|
681
|
-
if (!usage) return "Context usage unavailable.";
|
|
682
|
-
const tokens = formatCompactTokenCount(usage.tokens);
|
|
683
|
-
const window = formatCompactTokenCount(usage.contextWindow);
|
|
684
|
-
const pct = usage.percent == null ? "unknown" : `${usage.percent.toFixed(1)}%`;
|
|
685
|
-
return `Context: ${tokens}/${window} ${pct}`;
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
function formatLocalUsage(ctx: ExtensionContext): string {
|
|
689
|
-
const stats = ctx.sessionManager.getUsageStatistics();
|
|
690
|
-
return [
|
|
691
|
-
"Usage",
|
|
692
|
-
`Input tokens: ${stats.input}`,
|
|
693
|
-
`Output tokens: ${stats.output}`,
|
|
694
|
-
`Cache read tokens: ${stats.cacheRead}`,
|
|
695
|
-
`Cache write tokens: ${stats.cacheWrite}`,
|
|
696
|
-
`Premium requests: ${stats.premiumRequests}`,
|
|
697
|
-
`Cost: $${stats.cost.toFixed(6)}`,
|
|
698
|
-
].join("\n");
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
function cycleTelegramThinking(api: ExtensionAPI): ThinkingLevel | undefined {
|
|
702
|
-
const levels = [
|
|
703
|
-
ThinkingLevel.Off,
|
|
704
|
-
ThinkingLevel.Minimal,
|
|
705
|
-
ThinkingLevel.Low,
|
|
706
|
-
ThinkingLevel.Medium,
|
|
707
|
-
ThinkingLevel.High,
|
|
708
|
-
ThinkingLevel.XHigh,
|
|
709
|
-
ThinkingLevel.Max,
|
|
710
|
-
];
|
|
711
|
-
const current = api.getThinkingLevel() ?? ThinkingLevel.Off;
|
|
712
|
-
const currentIndex = levels.indexOf(current as (typeof levels)[number]);
|
|
713
|
-
const next = levels[(currentIndex + 1) % levels.length];
|
|
714
|
-
if (!next) return undefined;
|
|
715
|
-
api.setThinkingLevel(next);
|
|
716
|
-
return api.getThinkingLevel() ?? next;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
export async function executeNotificationControlCommand(
|
|
720
|
-
command: NotificationControlCommandPayload | undefined,
|
|
721
|
-
ctx: ExtensionContext,
|
|
722
|
-
api: ExtensionAPI,
|
|
723
|
-
): Promise<{ status: "ok" | "error" | "unavailable"; message: string }> {
|
|
724
|
-
if (!command || typeof command.name !== "string") return { status: "error", message: "Invalid control command." };
|
|
725
|
-
switch (command.name) {
|
|
726
|
-
case "reasoning": {
|
|
727
|
-
const current = api.getThinkingLevel() ?? ThinkingLevel.Off;
|
|
728
|
-
if (command.action === "status") return { status: "ok", message: `Reasoning effort: ${current}` };
|
|
729
|
-
if (command.action === "cycle") {
|
|
730
|
-
const next = cycleTelegramThinking(api);
|
|
731
|
-
return next
|
|
732
|
-
? { status: "ok", message: `Reasoning effort set to ${next}.` }
|
|
733
|
-
: { status: "unavailable", message: "Reasoning effort unavailable for this session." };
|
|
734
|
-
}
|
|
735
|
-
if (command.action === "set" && typeof command.level === "string") {
|
|
736
|
-
const parsed = parseThinkingLevel(command.level);
|
|
737
|
-
if (!parsed) return { status: "error", message: "Invalid reasoning effort." };
|
|
738
|
-
api.setThinkingLevel(parsed);
|
|
739
|
-
return { status: "ok", message: `Reasoning effort set to ${api.getThinkingLevel() ?? ThinkingLevel.Off}.` };
|
|
740
|
-
}
|
|
741
|
-
return { status: "error", message: "Invalid reasoning command." };
|
|
742
|
-
}
|
|
743
|
-
case "usage":
|
|
744
|
-
return { status: "ok", message: formatLocalUsage(ctx) };
|
|
745
|
-
case "context":
|
|
746
|
-
return { status: "ok", message: formatContextUsageLine(ctx) };
|
|
747
|
-
case "compact": {
|
|
748
|
-
const before = ctx.getContextUsage()?.tokens;
|
|
749
|
-
try {
|
|
750
|
-
await ctx.compact(typeof command.instructions === "string" ? command.instructions : undefined);
|
|
751
|
-
} catch (err) {
|
|
752
|
-
return {
|
|
753
|
-
status: "error",
|
|
754
|
-
message: `Compaction failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
755
|
-
};
|
|
756
|
-
}
|
|
757
|
-
const after = ctx.getContextUsage()?.tokens;
|
|
758
|
-
if (before != null && after != null)
|
|
759
|
-
return {
|
|
760
|
-
status: "ok",
|
|
761
|
-
message: `Compaction complete. Tokens: ${before} -> ${after} (saved ${before - after}).`,
|
|
762
|
-
};
|
|
763
|
-
return { status: "ok", message: "Compaction complete." };
|
|
764
|
-
}
|
|
765
|
-
default:
|
|
766
|
-
return { status: "error", message: "Unknown control command." };
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
function selectedAckOutcome(value: { status: string; messageId?: number; reason?: string }): AskSelectedAckOutcome {
|
|
771
|
-
if (value.status === "delivered" && typeof value.messageId === "number") {
|
|
772
|
-
return { status: "delivered", messageId: value.messageId };
|
|
773
|
-
}
|
|
774
|
-
if (value.status === "failed") {
|
|
775
|
-
switch (value.reason) {
|
|
776
|
-
case "unsupported":
|
|
777
|
-
case "no_participant":
|
|
778
|
-
case "ambiguous_participant":
|
|
779
|
-
case "route_missing":
|
|
780
|
-
case "expired":
|
|
781
|
-
case "cancelled":
|
|
782
|
-
case "telegram_rejected":
|
|
783
|
-
case "session_closed":
|
|
784
|
-
return { status: "failed", reason: value.reason };
|
|
785
|
-
default:
|
|
786
|
-
return { status: "failed", reason: "session_closed" };
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
switch (value.reason) {
|
|
790
|
-
case "transport_ambiguous":
|
|
791
|
-
case "origin_disconnected":
|
|
792
|
-
case "host_timeout":
|
|
793
|
-
case "shutdown":
|
|
794
|
-
return { status: "unknown", reason: value.reason };
|
|
795
|
-
default:
|
|
796
|
-
return { status: "unknown", reason: "host_timeout" };
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
async function requestLiveSelectedAck(
|
|
801
|
-
native: {
|
|
802
|
-
requestAskSelectedAck(
|
|
803
|
-
replyReceiptId: string,
|
|
804
|
-
requestJson: string,
|
|
805
|
-
): Promise<{ status: string; messageId?: number; reason?: string }>;
|
|
806
|
-
},
|
|
807
|
-
input: { replyReceiptId: string; actionId: string; commitKey: string; deadlineAt: number },
|
|
808
|
-
): Promise<AskSelectedAckOutcome> {
|
|
809
|
-
const requestId = `ack:${crypto.randomUUID()}`;
|
|
810
|
-
try {
|
|
811
|
-
return selectedAckOutcome(
|
|
812
|
-
await native.requestAskSelectedAck(
|
|
813
|
-
input.replyReceiptId,
|
|
814
|
-
JSON.stringify({
|
|
815
|
-
mode: "live",
|
|
816
|
-
requestId,
|
|
817
|
-
commitKey: input.commitKey,
|
|
818
|
-
actionId: input.actionId,
|
|
819
|
-
deadlineAt: input.deadlineAt,
|
|
820
|
-
}),
|
|
821
|
-
),
|
|
822
|
-
);
|
|
823
|
-
} catch (error) {
|
|
824
|
-
logger.warn(`notifications: Selected acknowledgement failed: ${String(error)}`);
|
|
825
|
-
return { status: "unknown", reason: "host_timeout" };
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
async function requestRecoveredSelectedAck(
|
|
830
|
-
native: {
|
|
831
|
-
requestRecoveredAskSelectedAck(
|
|
832
|
-
requestJson: string,
|
|
833
|
-
): Promise<{ status: string; messageId?: number; reason?: string }>;
|
|
834
|
-
},
|
|
835
|
-
input: { sessionId: string; actionId: string; commitKey: string; deadlineAt: number },
|
|
836
|
-
): Promise<AskSelectedAckOutcome> {
|
|
837
|
-
try {
|
|
838
|
-
return selectedAckOutcome(
|
|
839
|
-
await native.requestRecoveredAskSelectedAck(
|
|
840
|
-
JSON.stringify({
|
|
841
|
-
mode: "recovery",
|
|
842
|
-
requestId: `ack:${crypto.randomUUID()}`,
|
|
843
|
-
commitKey: input.commitKey,
|
|
844
|
-
sessionId: input.sessionId,
|
|
845
|
-
actionId: input.actionId,
|
|
846
|
-
deadlineAt: input.deadlineAt,
|
|
847
|
-
}),
|
|
848
|
-
),
|
|
849
|
-
);
|
|
850
|
-
} catch (error) {
|
|
851
|
-
logger.warn(`notifications: recovered Selected acknowledgement failed: ${String(error)}`);
|
|
852
|
-
return { status: "unknown", reason: "host_timeout" };
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
/** Register the interactive `ask` answer source for a session (the ask tool
|
|
857
|
-
* races the local UI against a remote reply). Returns the deregister disposer. */
|
|
858
|
-
function registerInteractiveAnswerSource(
|
|
859
|
-
id: string,
|
|
860
|
-
server: NotificationServer,
|
|
861
|
-
pendingInteractive: Map<string, PendingInteractiveAsk>,
|
|
862
|
-
getRedact: () => boolean,
|
|
863
|
-
tag: string,
|
|
864
|
-
): () => void {
|
|
865
|
-
return registerAskAnswerSource(id, {
|
|
866
|
-
awaitAnswer(question, options, signal) {
|
|
867
|
-
const result = this.awaitAnswerRequest?.({ question, options, interaction: "selector", controls: [] }, signal);
|
|
868
|
-
if (!result) return Promise.resolve(undefined);
|
|
869
|
-
return result.then(answer => {
|
|
870
|
-
if (!answer || typeof answer === "string") return answer;
|
|
871
|
-
return answer.interaction.kind === "value" ? answer.interaction.value : undefined;
|
|
872
|
-
});
|
|
873
|
-
},
|
|
874
|
-
awaitAnswerRequest(request: AskAnswerRequest, signal?: AbortSignal): Promise<AskAnswerSourceResult> {
|
|
875
|
-
if (signal?.aborted) return Promise.resolve(undefined);
|
|
876
|
-
const register = (askId: string): boolean => {
|
|
877
|
-
try {
|
|
878
|
-
server.registerAsk(
|
|
879
|
-
JSON.stringify(
|
|
880
|
-
notificationActionPayload(
|
|
881
|
-
{
|
|
882
|
-
id: askId,
|
|
883
|
-
kind: "ask",
|
|
884
|
-
sessionId: id,
|
|
885
|
-
question: request.question,
|
|
886
|
-
options: request.options,
|
|
887
|
-
controls: request.controls,
|
|
888
|
-
},
|
|
889
|
-
{ redact: getRedact(), sessionTag: tag },
|
|
890
|
-
),
|
|
891
|
-
),
|
|
892
|
-
true,
|
|
893
|
-
);
|
|
894
|
-
return true;
|
|
895
|
-
} catch (error) {
|
|
896
|
-
logger.warn(`notifications: registerAsk failed: ${String(error)}`);
|
|
897
|
-
return false;
|
|
898
|
-
}
|
|
899
|
-
};
|
|
900
|
-
let activeAskId = `ask:${crypto.randomUUID()}`;
|
|
901
|
-
if (!register(activeAskId)) return Promise.resolve(undefined);
|
|
902
|
-
return new Promise<AskAnswerSourceResult>(resolve => {
|
|
903
|
-
const pending: PendingInteractiveAsk = {
|
|
904
|
-
resolve,
|
|
905
|
-
options: request.options,
|
|
906
|
-
controls: request.controls,
|
|
907
|
-
reissue: () => {
|
|
908
|
-
const nextAskId = `ask:${crypto.randomUUID()}`;
|
|
909
|
-
if (!register(nextAskId)) return false;
|
|
910
|
-
activeAskId = nextAskId;
|
|
911
|
-
pendingInteractive.set(nextAskId, pending);
|
|
912
|
-
return true;
|
|
913
|
-
},
|
|
914
|
-
};
|
|
915
|
-
pendingInteractive.set(activeAskId, pending);
|
|
916
|
-
signal?.addEventListener("abort", () => {
|
|
917
|
-
if (!pendingInteractive.delete(activeAskId)) return;
|
|
918
|
-
try {
|
|
919
|
-
server.resolveLocal(activeAskId, undefined);
|
|
920
|
-
} catch {}
|
|
921
|
-
resolve(undefined);
|
|
922
|
-
});
|
|
923
|
-
});
|
|
924
|
-
},
|
|
925
|
-
});
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
/** Extract the session id from a `<timestamp>_<uuid>.jsonl` session file path. */
|
|
929
|
-
function sessionIdFromFile(file: string | undefined): string | undefined {
|
|
930
|
-
if (!file) return undefined;
|
|
931
|
-
const base = path.basename(file).replace(/\.jsonl$/, "");
|
|
932
|
-
const underscore = base.indexOf("_");
|
|
933
|
-
return underscore >= 0 ? base.slice(underscore + 1) : undefined;
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
export function createNotificationsExtension(api: ExtensionAPI, options: { settings?: Settings } = {}): void {
|
|
937
|
-
const runtimes = new Map<string, SessionRuntime>();
|
|
938
|
-
const disabledSessions = new Set<string>();
|
|
939
|
-
const sessionId = (ctx: ExtensionContext): string => ctx.sessionManager.getSessionId();
|
|
940
|
-
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
|
|
941
|
-
|
|
942
|
-
async function stopSession(id: string): Promise<boolean> {
|
|
943
|
-
const rt = runtimes.get(id);
|
|
944
|
-
if (!rt) return false;
|
|
945
|
-
runtimes.delete(id);
|
|
946
|
-
try {
|
|
947
|
-
rt.cancelPostmortemCleanup();
|
|
948
|
-
} catch {}
|
|
949
|
-
try {
|
|
950
|
-
rt.disposeAnswerSource();
|
|
951
|
-
} catch {}
|
|
952
|
-
try {
|
|
953
|
-
rt.disposeFileSink();
|
|
954
|
-
} catch {}
|
|
955
|
-
try {
|
|
956
|
-
rt.disposeGateListener();
|
|
957
|
-
} catch {}
|
|
958
|
-
try {
|
|
959
|
-
rt.disposeGateTerminalController();
|
|
960
|
-
} catch {}
|
|
961
|
-
try {
|
|
962
|
-
rt.disposeAckRecoveryParticipant();
|
|
963
|
-
} catch {}
|
|
964
|
-
try {
|
|
965
|
-
rt.disposeGateEmitterListener();
|
|
966
|
-
} catch {}
|
|
967
|
-
rt.gatePresentations?.dispose();
|
|
968
|
-
// Resolve any still-pending interactive asks so the ask tool is not left hanging.
|
|
969
|
-
for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
|
|
970
|
-
rt.pendingInteractive.clear();
|
|
971
|
-
let closeFrameSent = false;
|
|
972
|
-
try {
|
|
973
|
-
rt.server.pushFrame(JSON.stringify({ type: "session_closed", sessionId: id }));
|
|
974
|
-
closeFrameSent = true;
|
|
975
|
-
} catch (e) {
|
|
976
|
-
logger.warn(`notifications: session_closed failed: ${String(e)}`);
|
|
977
|
-
}
|
|
978
|
-
if (closeFrameSent) await sleep(100);
|
|
979
|
-
try {
|
|
980
|
-
rt.server.stop();
|
|
981
|
-
} catch (e) {
|
|
982
|
-
logger.warn(`notifications: stop failed: ${String(e)}`);
|
|
983
|
-
}
|
|
984
|
-
return true;
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
function isEnabledForSession(id: string, cfg: NotificationConfig): boolean {
|
|
988
|
-
return isSessionNotificationsEnabled({ cfg, env: process.env, sessionDisabled: disabledSessions.has(id) });
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
function isNotificationEligibleContext(ctx: ExtensionContext): boolean {
|
|
992
|
-
return ctx.sessionMetadata?.kind !== "sub";
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
async function startSession(ctx: ExtensionContext): Promise<"started" | "already" | "disabled" | "failed"> {
|
|
996
|
-
const id = sessionId(ctx);
|
|
997
|
-
const { settings, cfg, settingsAvailable } = resolveSettings(options.settings);
|
|
998
|
-
if (!isNotificationEligibleContext(ctx) || !isEnabledForSession(id, cfg)) return "disabled";
|
|
999
|
-
if (runtimes.has(id)) return "already";
|
|
1000
|
-
|
|
1001
|
-
const stateRoot = path.join(ctx.cwd, ".gjc", "state");
|
|
1002
|
-
const gateOptions = new Map<string, string[]>();
|
|
1003
|
-
const pendingInteractive = new Map<string, PendingInteractiveAsk>();
|
|
1004
|
-
const tag = sessionTag(id);
|
|
1005
|
-
const redact = cfg.redact;
|
|
1006
|
-
const verbosity = cfg.verbosity;
|
|
1007
|
-
let runtime: SessionRuntime | undefined;
|
|
1008
|
-
|
|
1009
|
-
// The SDK can always answer now (interactive via the answer source, or the
|
|
1010
|
-
// unattended gate), so the endpoint advertises a resolver.
|
|
1011
|
-
const server = new NotificationServer(id, resolveToken(), stateRoot, true);
|
|
1012
|
-
const gatePresentations = new GatePresentationRegistry(server, () => runtime?.redact ?? redact, tag);
|
|
1013
|
-
|
|
1014
|
-
server.onReply((err, reply) => {
|
|
1015
|
-
if (err || !reply) return;
|
|
1016
|
-
const native = server as unknown as {
|
|
1017
|
-
resolveClaim(receiptId: string, answerJson?: string, idempotencyKey?: string): void;
|
|
1018
|
-
closeClaimInvalid(receiptId: string, reason: string): void;
|
|
1019
|
-
requestAskSelectedAck(
|
|
1020
|
-
receiptId: string,
|
|
1021
|
-
requestJson: string,
|
|
1022
|
-
): Promise<{ status: string; messageId?: number; reason?: string }>;
|
|
1023
|
-
};
|
|
1024
|
-
const pending = pendingInteractive.get(reply.id);
|
|
1025
|
-
if (pending) {
|
|
1026
|
-
pendingInteractive.delete(reply.id);
|
|
1027
|
-
let interaction: AskRemoteInteraction | undefined;
|
|
1028
|
-
try {
|
|
1029
|
-
const answer = JSON.parse(reply.answerJson) as unknown;
|
|
1030
|
-
if (typeof answer === "object" && answer && "controlId" in answer) {
|
|
1031
|
-
const controlId = (answer as { controlId?: unknown }).controlId;
|
|
1032
|
-
if (
|
|
1033
|
-
controlId === "navigation_forward" &&
|
|
1034
|
-
pending.controls.some(control => control.id === controlId && control.enabled)
|
|
1035
|
-
) {
|
|
1036
|
-
interaction = { kind: "control", controlId };
|
|
1037
|
-
}
|
|
1038
|
-
} else {
|
|
1039
|
-
const value = mapAnswerToLabel(reply.answerJson, pending.options);
|
|
1040
|
-
if (value !== undefined) interaction = { kind: "value", value };
|
|
1041
|
-
}
|
|
1042
|
-
} catch {}
|
|
1043
|
-
if (!interaction) {
|
|
1044
|
-
try {
|
|
1045
|
-
native.closeClaimInvalid(reply.replyReceiptId, "invalid_answer");
|
|
1046
|
-
} catch {}
|
|
1047
|
-
if (!pending.reissue()) pending.resolve(undefined);
|
|
1048
|
-
return;
|
|
1049
|
-
}
|
|
1050
|
-
let settled: Promise<AskSettlementResult> | undefined;
|
|
1051
|
-
const receipt: AskRemoteReceipt = {
|
|
1052
|
-
source: "remote",
|
|
1053
|
-
interaction,
|
|
1054
|
-
settle(settlement: AskSettlement): Promise<AskSettlementResult> {
|
|
1055
|
-
if (settled) return settled;
|
|
1056
|
-
settled = Promise.resolve().then(async () => {
|
|
1057
|
-
if (settlement.kind === "invalid") {
|
|
1058
|
-
native.closeClaimInvalid(reply.replyReceiptId, settlement.reason);
|
|
1059
|
-
return { kind: "invalid_closed" };
|
|
1060
|
-
}
|
|
1061
|
-
if (settlement.kind === "resolve_without_commit") {
|
|
1062
|
-
native.resolveClaim(reply.replyReceiptId, reply.answerJson, reply.idempotencyKey ?? undefined);
|
|
1063
|
-
return { kind: "resolved_without_commit" };
|
|
1064
|
-
}
|
|
1065
|
-
const ack = await requestLiveSelectedAck(native, {
|
|
1066
|
-
replyReceiptId: reply.replyReceiptId,
|
|
1067
|
-
actionId: reply.id,
|
|
1068
|
-
commitKey: `${reply.id}:${reply.idempotencyKey ?? reply.replyReceiptId}`,
|
|
1069
|
-
deadlineAt: Date.now() + 8_000,
|
|
1070
|
-
});
|
|
1071
|
-
native.resolveClaim(reply.replyReceiptId, reply.answerJson, reply.idempotencyKey ?? undefined);
|
|
1072
|
-
return { kind: "committed", ack };
|
|
1073
|
-
});
|
|
1074
|
-
return settled;
|
|
1075
|
-
},
|
|
1076
|
-
};
|
|
1077
|
-
pending.resolve(receipt);
|
|
1078
|
-
return;
|
|
1079
|
-
}
|
|
1080
|
-
const gate = runtime?.workflowGate;
|
|
1081
|
-
const unattended =
|
|
1082
|
-
gate?.isUnattended?.() === true &&
|
|
1083
|
-
typeof gate.onGateEmitted === "function" &&
|
|
1084
|
-
typeof gate.resolveGate === "function";
|
|
1085
|
-
const gateId = gatePresentations.routeFor(reply.id);
|
|
1086
|
-
if (unattended && gateId && gate?.resolveGateFromNotification) {
|
|
1087
|
-
const presentation = gatePresentations.presentationFor(reply.id);
|
|
1088
|
-
const rawAnswer = parseAnswer(reply.answerJson);
|
|
1089
|
-
if (presentation?.multi) {
|
|
1090
|
-
const option =
|
|
1091
|
-
typeof rawAnswer === "number"
|
|
1092
|
-
? presentation.options[rawAnswer]
|
|
1093
|
-
: typeof rawAnswer === "string" && presentation.options.includes(rawAnswer)
|
|
1094
|
-
? rawAnswer
|
|
1095
|
-
: undefined;
|
|
1096
|
-
if (option !== undefined) {
|
|
1097
|
-
native.resolveClaim(reply.replyReceiptId, reply.answerJson, reply.idempotencyKey ?? undefined);
|
|
1098
|
-
if (!gatePresentations.toggle(reply.id, option)) gatePresentations.reissue(gateId);
|
|
1099
|
-
return;
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
let answer: unknown;
|
|
1103
|
-
if (
|
|
1104
|
-
presentation?.multi &&
|
|
1105
|
-
typeof rawAnswer === "object" &&
|
|
1106
|
-
rawAnswer !== null &&
|
|
1107
|
-
(rawAnswer as { controlId?: unknown }).controlId === "navigation_forward"
|
|
1108
|
-
) {
|
|
1109
|
-
if (!presentation.allowEmpty && presentation.selectedOptions.length === 0) {
|
|
1110
|
-
native.closeClaimInvalid(reply.replyReceiptId, "invalid_control");
|
|
1111
|
-
gatePresentations.closeInteraction(reply.id, "invalid_control");
|
|
1112
|
-
gatePresentations.reissue(gateId);
|
|
1113
|
-
return;
|
|
1114
|
-
}
|
|
1115
|
-
answer = { selected: presentation.selectedOptions };
|
|
1116
|
-
} else if (
|
|
1117
|
-
typeof rawAnswer === "object" &&
|
|
1118
|
-
rawAnswer !== null &&
|
|
1119
|
-
(rawAnswer as { action?: unknown }).action === "clarify"
|
|
1120
|
-
) {
|
|
1121
|
-
answer = rawAnswer;
|
|
1122
|
-
} else if (presentation?.multi && typeof rawAnswer === "string") {
|
|
1123
|
-
answer = { selected: presentation.selectedOptions, other: true, custom: rawAnswer };
|
|
1124
|
-
} else {
|
|
1125
|
-
const mapped = mapAnswerToGate(reply.answerJson, gateOptions.get(gateId) ?? []);
|
|
1126
|
-
if (!mapped.ok) {
|
|
1127
|
-
// A numeric selector outside options is invalid (issue #2030): close the
|
|
1128
|
-
// exact claim/receipt and reissue the interaction — never a success ack.
|
|
1129
|
-
native.closeClaimInvalid(reply.replyReceiptId, mapped.reason);
|
|
1130
|
-
gatePresentations.closeInteraction(reply.id, mapped.reason);
|
|
1131
|
-
gatePresentations.reissue(gateId);
|
|
1132
|
-
return;
|
|
1133
|
-
}
|
|
1134
|
-
answer = mapped.answer;
|
|
1135
|
-
}
|
|
1136
|
-
void gate
|
|
1137
|
-
.resolveGateFromNotification(
|
|
1138
|
-
{ gate_id: gateId, answer, idempotency_key: reply.idempotencyKey ?? undefined },
|
|
1139
|
-
{
|
|
1140
|
-
interactionActionId: reply.id,
|
|
1141
|
-
replyReceiptId: reply.replyReceiptId,
|
|
1142
|
-
answerJson: reply.answerJson,
|
|
1143
|
-
idempotencyKey: reply.idempotencyKey ?? undefined,
|
|
1144
|
-
resolveClaim: () =>
|
|
1145
|
-
native.resolveClaim(reply.replyReceiptId, reply.answerJson, reply.idempotencyKey ?? undefined),
|
|
1146
|
-
closeClaimInvalid: reason => {
|
|
1147
|
-
native.closeClaimInvalid(reply.replyReceiptId, reason);
|
|
1148
|
-
gatePresentations.closeInteraction(reply.id, reason);
|
|
1149
|
-
gatePresentations.reissue(gateId);
|
|
1150
|
-
},
|
|
1151
|
-
requestSelectedAck: input =>
|
|
1152
|
-
requestLiveSelectedAck(native, {
|
|
1153
|
-
replyReceiptId: input.replyReceiptId,
|
|
1154
|
-
actionId: input.actionId,
|
|
1155
|
-
commitKey: input.commitKey,
|
|
1156
|
-
deadlineAt: input.daemonDeadlineAt,
|
|
1157
|
-
}),
|
|
1158
|
-
},
|
|
1159
|
-
)
|
|
1160
|
-
.catch(error => logger.warn(`notifications: resolveGateFromNotification failed: ${String(error)}`));
|
|
1161
|
-
return;
|
|
1162
|
-
}
|
|
1163
|
-
try {
|
|
1164
|
-
server.closeClaimInvalid(reply.replyReceiptId, "unknown_action");
|
|
1165
|
-
} catch (error) {
|
|
1166
|
-
logger.warn(`notifications: closeClaimInvalid failed: ${String(error)}`);
|
|
1167
|
-
}
|
|
1168
|
-
});
|
|
1169
|
-
|
|
1170
|
-
// Inbound free-text injection / in-thread config command from a session
|
|
1171
|
-
// thread (forwarded by the daemon over the WS, fail-closed at the daemon).
|
|
1172
|
-
server.onInbound((err, inbound) => {
|
|
1173
|
-
if (err || !inbound) return;
|
|
1174
|
-
if (inbound.kind === "user_message") {
|
|
1175
|
-
// Inject as a user turn (steers/continues the agent; the resulting
|
|
1176
|
-
// turn streams back via the turn_end handler even when not idle).
|
|
1177
|
-
// Record the update id so it can be acked as "consumed" on the next
|
|
1178
|
-
// turn_start, and steer (vs start a fresh turn) when already busy.
|
|
1179
|
-
const text = inbound.text ?? "";
|
|
1180
|
-
const images = inbound.images ?? [];
|
|
1181
|
-
if (!text && images.length === 0) return;
|
|
1182
|
-
if (runtime && typeof inbound.updateId === "number") runtime.pendingInbound.add(inbound.updateId);
|
|
1183
|
-
const content: string | (TextContent | ImageContent)[] =
|
|
1184
|
-
images.length > 0
|
|
1185
|
-
? [
|
|
1186
|
-
...(text ? [{ type: "text", text } as TextContent] : []),
|
|
1187
|
-
...images.map(
|
|
1188
|
-
img =>
|
|
1189
|
-
({ type: "image", data: img.data, mimeType: img.mime ?? "image/jpeg" }) as ImageContent,
|
|
1190
|
-
),
|
|
1191
|
-
]
|
|
1192
|
-
: text;
|
|
1193
|
-
try {
|
|
1194
|
-
api.sendUserMessage(content, runtime?.busy ? { deliverAs: "steer" } : undefined);
|
|
1195
|
-
} catch (e) {
|
|
1196
|
-
logger.warn(`notifications: sendUserMessage failed: ${String(e)}`);
|
|
1197
|
-
}
|
|
1198
|
-
return;
|
|
1199
|
-
}
|
|
1200
|
-
if (inbound.kind === "config_command") {
|
|
1201
|
-
if (!runtime) return;
|
|
1202
|
-
const update: {
|
|
1203
|
-
type: "config_update";
|
|
1204
|
-
sessionId: string;
|
|
1205
|
-
verbosity?: "lean" | "verbose";
|
|
1206
|
-
redact?: boolean;
|
|
1207
|
-
} = {
|
|
1208
|
-
type: "config_update",
|
|
1209
|
-
sessionId: id,
|
|
1210
|
-
};
|
|
1211
|
-
if (inbound.verbosity === "lean" || inbound.verbosity === "verbose") {
|
|
1212
|
-
runtime.verbosity = inbound.verbosity;
|
|
1213
|
-
update.verbosity = inbound.verbosity;
|
|
1214
|
-
}
|
|
1215
|
-
if (typeof inbound.redact === "boolean") {
|
|
1216
|
-
runtime.redact = inbound.redact;
|
|
1217
|
-
update.redact = inbound.redact;
|
|
1218
|
-
}
|
|
1219
|
-
if (update.verbosity !== undefined || update.redact !== undefined) {
|
|
1220
|
-
try {
|
|
1221
|
-
runtime.server.pushFrame(JSON.stringify(update));
|
|
1222
|
-
} catch (e) {
|
|
1223
|
-
logger.warn(`notifications: config_update failed: ${String(e)}`);
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
if (inbound.kind === "control_command") {
|
|
1228
|
-
if (!runtime || !inbound.requestId) return;
|
|
1229
|
-
void executeNotificationControlCommand(parseControlCommandPayload(inbound.commandJson), ctx, api)
|
|
1230
|
-
.then(result => {
|
|
1231
|
-
runtime?.server.pushFrame(
|
|
1232
|
-
JSON.stringify({
|
|
1233
|
-
type: "control_command_result",
|
|
1234
|
-
sessionId: id,
|
|
1235
|
-
requestId: inbound.requestId,
|
|
1236
|
-
updateId: inbound.updateId,
|
|
1237
|
-
status: result.status,
|
|
1238
|
-
message: result.message,
|
|
1239
|
-
}),
|
|
1240
|
-
);
|
|
1241
|
-
})
|
|
1242
|
-
.catch(err => {
|
|
1243
|
-
try {
|
|
1244
|
-
runtime?.server.pushFrame(
|
|
1245
|
-
JSON.stringify({
|
|
1246
|
-
type: "control_command_result",
|
|
1247
|
-
sessionId: id,
|
|
1248
|
-
requestId: inbound.requestId,
|
|
1249
|
-
updateId: inbound.updateId,
|
|
1250
|
-
status: "error",
|
|
1251
|
-
message: `Control command failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1252
|
-
}),
|
|
1253
|
-
);
|
|
1254
|
-
} catch (pushErr) {
|
|
1255
|
-
logger.warn(`notifications: control_command_result failed: ${String(pushErr)}`);
|
|
1256
|
-
}
|
|
1257
|
-
});
|
|
1258
|
-
}
|
|
1259
|
-
});
|
|
1260
|
-
|
|
1261
|
-
try {
|
|
1262
|
-
const endpoint = await server.start();
|
|
1263
|
-
|
|
1264
|
-
// Interactive answer source: the ask tool races the local UI against this.
|
|
1265
|
-
const disposeAnswerSource = registerInteractiveAnswerSource(
|
|
1266
|
-
id,
|
|
1267
|
-
server,
|
|
1268
|
-
pendingInteractive,
|
|
1269
|
-
() => runtime?.redact ?? redact,
|
|
1270
|
-
tag,
|
|
1271
|
-
);
|
|
1272
|
-
const disposeFileSink = registerTelegramFileSink(id, async file => {
|
|
1273
|
-
if (runtime?.redact ?? redact) {
|
|
1274
|
-
return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
try {
|
|
1278
|
-
const data = await fs.promises.readFile(file.path);
|
|
1279
|
-
server.pushFrame(
|
|
1280
|
-
JSON.stringify({
|
|
1281
|
-
type: "file_attachment",
|
|
1282
|
-
sessionId: id,
|
|
1283
|
-
name: path.basename(file.path),
|
|
1284
|
-
data: data.toString("base64"),
|
|
1285
|
-
caption: file.caption,
|
|
1286
|
-
}),
|
|
1287
|
-
);
|
|
1288
|
-
return { ok: true };
|
|
1289
|
-
} catch (e) {
|
|
1290
|
-
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
1291
|
-
}
|
|
1292
|
-
});
|
|
1293
|
-
|
|
1294
|
-
runtime = {
|
|
1295
|
-
server,
|
|
1296
|
-
idleSeq: 0,
|
|
1297
|
-
pendingInteractive,
|
|
1298
|
-
disposeAnswerSource,
|
|
1299
|
-
disposeFileSink,
|
|
1300
|
-
disposeGateListener: () => {},
|
|
1301
|
-
disposeGateTerminalController: () => {},
|
|
1302
|
-
disposeAckRecoveryParticipant: () => {},
|
|
1303
|
-
disposeGateEmitterListener: () => {},
|
|
1304
|
-
workflowGate: undefined,
|
|
1305
|
-
gatePresentations,
|
|
1306
|
-
cancelPostmortemCleanup: () => {},
|
|
1307
|
-
redact,
|
|
1308
|
-
verbosity,
|
|
1309
|
-
stream: streamingEnabled(),
|
|
1310
|
-
sessionTag: tag,
|
|
1311
|
-
busy: false,
|
|
1312
|
-
pendingInbound: new Set<number>(),
|
|
1313
|
-
};
|
|
1314
|
-
runtimes.set(id, runtime);
|
|
1315
|
-
const activeRuntime = runtime;
|
|
1316
|
-
// A native terminal close (SIGHUP), SIGTERM, Ctrl+C exit, or fatal error
|
|
1317
|
-
// skips AgentSession.dispose(), so the `session_shutdown` extension event
|
|
1318
|
-
// never fires and the daemon-side topic would be orphaned. postmortem
|
|
1319
|
-
// awaits registered cleanups on those paths, so send the graceful
|
|
1320
|
-
// `session_closed` frame from there too. stopSession() cancels this
|
|
1321
|
-
// registration on every other teardown path, so it never double-fires.
|
|
1322
|
-
runtime.cancelPostmortemCleanup = postmortem.register(`notifications-session-closed:${id}`, async () => {
|
|
1323
|
-
await stopSession(id);
|
|
1324
|
-
});
|
|
1325
|
-
logger.info(`notifications: serving session ${id} at ${endpoint.url}`);
|
|
1326
|
-
|
|
1327
|
-
if (settingsAvailable && settings && isTelegramConfigured(cfg)) {
|
|
1328
|
-
try {
|
|
1329
|
-
await ensureTelegramDaemonRunning({ settings, cwd: ctx.cwd, sessionId: id });
|
|
1330
|
-
} catch (e) {
|
|
1331
|
-
logger.warn(`notifications: failed to ensure Telegram daemon: ${String(e)}`);
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
// One-time identity header (repo/branch/machine/session) pinned at the top
|
|
1336
|
-
// of the session thread by the daemon.
|
|
1337
|
-
try {
|
|
1338
|
-
server.pushFrame(
|
|
1339
|
-
JSON.stringify({
|
|
1340
|
-
type: "identity_header",
|
|
1341
|
-
sessionId: id,
|
|
1342
|
-
...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
|
|
1343
|
-
}),
|
|
1344
|
-
);
|
|
1345
|
-
} catch (e) {
|
|
1346
|
-
logger.warn(`notifications: identity_header failed: ${String(e)}`);
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
// Workflow-gate installation occurs after session_start in RPC/bridge modes.
|
|
1350
|
-
// Attach dynamically so those sessions receive the same Telegram gate surface.
|
|
1351
|
-
const attachWorkflowGate = (gate: WorkflowGateEmitter | undefined): void => {
|
|
1352
|
-
if (activeRuntime.workflowGate === gate) return;
|
|
1353
|
-
activeRuntime.disposeGateListener();
|
|
1354
|
-
activeRuntime.disposeGateTerminalController();
|
|
1355
|
-
activeRuntime.disposeAckRecoveryParticipant();
|
|
1356
|
-
activeRuntime.disposeGateListener = () => {};
|
|
1357
|
-
activeRuntime.disposeGateTerminalController = () => {};
|
|
1358
|
-
activeRuntime.disposeAckRecoveryParticipant = () => {};
|
|
1359
|
-
activeRuntime.workflowGate = undefined;
|
|
1360
|
-
gateOptions.clear();
|
|
1361
|
-
gatePresentations.dispose();
|
|
1362
|
-
if (typeof gate?.onGateEmitted !== "function" || typeof gate.resolveGate !== "function") {
|
|
1363
|
-
return;
|
|
1364
|
-
}
|
|
1365
|
-
activeRuntime.workflowGate = gate;
|
|
1366
|
-
if (gate.registerGateTerminalController) {
|
|
1367
|
-
const controller: WorkflowGateTerminalController = {
|
|
1368
|
-
completeGateInteractions: gateId => gatePresentations.complete(gateId),
|
|
1369
|
-
cancelGateInteractions: (gateId, reason) => gatePresentations.cancel(gateId, reason),
|
|
1370
|
-
};
|
|
1371
|
-
try {
|
|
1372
|
-
activeRuntime.disposeGateTerminalController = gate.registerGateTerminalController(controller);
|
|
1373
|
-
} catch (error) {
|
|
1374
|
-
logger.warn(`notifications: gate terminal controller unavailable: ${String(error)}`);
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
activeRuntime.disposeGateListener = gate.onGateEmitted(g => {
|
|
1378
|
-
const options = (g.options ?? []).map(o => String((o as { label?: unknown }).label ?? ""));
|
|
1379
|
-
gateOptions.set(g.gate_id, options);
|
|
1380
|
-
const promptCtx = g.context as { prompt?: unknown; title?: unknown } | undefined;
|
|
1381
|
-
const question =
|
|
1382
|
-
(typeof promptCtx?.prompt === "string" && promptCtx.prompt) ||
|
|
1383
|
-
(typeof promptCtx?.title === "string" && promptCtx.title) ||
|
|
1384
|
-
"Question";
|
|
1385
|
-
const stageState =
|
|
1386
|
-
typeof g.context?.stage_state === "object" && g.context.stage_state !== null
|
|
1387
|
-
? (g.context.stage_state as Record<string, unknown>)
|
|
1388
|
-
: {};
|
|
1389
|
-
gatePresentations.retain({
|
|
1390
|
-
gateId: g.gate_id,
|
|
1391
|
-
sessionId: id,
|
|
1392
|
-
question,
|
|
1393
|
-
options,
|
|
1394
|
-
controls: [],
|
|
1395
|
-
multi: stageState.multi === true,
|
|
1396
|
-
allowEmpty: stageState.allow_empty === true,
|
|
1397
|
-
navigationLabel: stageState.navigation_label === "Next" ? "Next" : "Done",
|
|
1398
|
-
selectedOptions: [],
|
|
1399
|
-
});
|
|
1400
|
-
gatePresentations.reissue(g.gate_id);
|
|
1401
|
-
});
|
|
1402
|
-
if (gate.setAckRecoveryParticipant) {
|
|
1403
|
-
const native = server as unknown as {
|
|
1404
|
-
requestRecoveredAskSelectedAck(
|
|
1405
|
-
requestJson: string,
|
|
1406
|
-
): Promise<{ status: string; messageId?: number; reason?: string }>;
|
|
1407
|
-
};
|
|
1408
|
-
gate.setAckRecoveryParticipant({
|
|
1409
|
-
requestRecoveredAskSelectedAck: input =>
|
|
1410
|
-
requestRecoveredSelectedAck(native, {
|
|
1411
|
-
sessionId: input.sessionId,
|
|
1412
|
-
actionId: input.actionId,
|
|
1413
|
-
commitKey: input.commitKey,
|
|
1414
|
-
deadlineAt: input.deadlineAt,
|
|
1415
|
-
}),
|
|
1416
|
-
});
|
|
1417
|
-
activeRuntime.disposeAckRecoveryParticipant = () => gate.setAckRecoveryParticipant?.(null);
|
|
1418
|
-
}
|
|
1419
|
-
};
|
|
1420
|
-
activeRuntime.disposeGateEmitterListener = registerWorkflowGateEmitterListener(id, attachWorkflowGate);
|
|
1421
|
-
if (ctx.workflowGate) attachWorkflowGate(ctx.workflowGate);
|
|
1422
|
-
return "started";
|
|
1423
|
-
} catch (e) {
|
|
1424
|
-
logger.warn(`notifications: failed to start server: ${String(e)}`);
|
|
1425
|
-
return "failed";
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
api.registerCommand("notify", {
|
|
1430
|
-
description: "Control notifications for this session (on, off, status).",
|
|
1431
|
-
async handler(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
1432
|
-
const id = sessionId(ctx);
|
|
1433
|
-
const command = args.trim().split(/\s+/, 1)[0]?.toLowerCase() || "status";
|
|
1434
|
-
const resolved = resolveSettings(options.settings);
|
|
1435
|
-
const enabledWithoutLocalOff = isSessionNotificationsEnabled({
|
|
1436
|
-
cfg: resolved.cfg,
|
|
1437
|
-
env: process.env,
|
|
1438
|
-
sessionDisabled: false,
|
|
1439
|
-
});
|
|
1440
|
-
|
|
1441
|
-
if (command === "off") {
|
|
1442
|
-
disabledSessions.add(id);
|
|
1443
|
-
const stopped = await stopSession(id);
|
|
1444
|
-
ctx.ui.notify(
|
|
1445
|
-
stopped
|
|
1446
|
-
? "Notifications disabled for this session."
|
|
1447
|
-
: "Notifications already disabled for this session.",
|
|
1448
|
-
"info",
|
|
1449
|
-
);
|
|
1450
|
-
return;
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
if (command === "on") {
|
|
1454
|
-
if (!isNotificationEligibleContext(ctx)) {
|
|
1455
|
-
ctx.ui.notify("Notifications are disabled for subagent sessions.", "warning");
|
|
1456
|
-
return;
|
|
1457
|
-
}
|
|
1458
|
-
if (process.env.GJC_NOTIFICATIONS === "0") {
|
|
1459
|
-
ctx.ui.notify(
|
|
1460
|
-
"Notifications remain disabled: GJC_NOTIFICATIONS=0 is an authoritative opt-out.",
|
|
1461
|
-
"warning",
|
|
1462
|
-
);
|
|
1463
|
-
return;
|
|
1464
|
-
}
|
|
1465
|
-
if (!enabledWithoutLocalOff) {
|
|
1466
|
-
ctx.ui.notify(
|
|
1467
|
-
"Notifications are not configured. Run `gjc notify setup` or set GJC_NOTIFICATIONS=1.",
|
|
1468
|
-
"warning",
|
|
1469
|
-
);
|
|
1470
|
-
return;
|
|
1471
|
-
}
|
|
1472
|
-
disabledSessions.delete(id);
|
|
1473
|
-
const result = await startSession(ctx);
|
|
1474
|
-
ctx.ui.notify(
|
|
1475
|
-
result === "started"
|
|
1476
|
-
? "Notifications enabled for this session."
|
|
1477
|
-
: result === "already"
|
|
1478
|
-
? "Notifications already enabled for this session."
|
|
1479
|
-
: result === "failed"
|
|
1480
|
-
? "Notifications failed to start for this session."
|
|
1481
|
-
: "Notifications are not configured. Run `gjc notify setup` or set GJC_NOTIFICATIONS=1.",
|
|
1482
|
-
result === "failed" ? "error" : result === "disabled" ? "warning" : "info",
|
|
1483
|
-
);
|
|
1484
|
-
return;
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
if (command !== "status") {
|
|
1488
|
-
ctx.ui.notify("Usage: /notify status | /notify on | /notify off", "warning");
|
|
1489
|
-
return;
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
const running = runtimes.has(id);
|
|
1493
|
-
const locallyDisabled = disabledSessions.has(id);
|
|
1494
|
-
const enabled = isEnabledForSession(id, resolved.cfg);
|
|
1495
|
-
const runtime = runtimes.get(id);
|
|
1496
|
-
ctx.ui.notify(
|
|
1497
|
-
`Notifications ${running ? "running" : enabled ? "enabled" : "disabled"} for this session; redaction ${(runtime?.redact ?? resolved.cfg.redact) ? "on" : "off"}; verbosity ${runtime?.verbosity ?? resolved.cfg.verbosity}${locallyDisabled ? "; locally off" : ""}.`,
|
|
1498
|
-
"info",
|
|
1499
|
-
);
|
|
1500
|
-
},
|
|
1501
|
-
});
|
|
1502
|
-
|
|
1503
|
-
api.on("session_start", async (_event, ctx) => {
|
|
1504
|
-
await startSession(ctx);
|
|
1505
|
-
});
|
|
1506
|
-
|
|
1507
|
-
// A session id change within the same process needs reason-aware handling.
|
|
1508
|
-
// `/new` and fork CONTINUE the same terminal thread (e.g. plan "approve and
|
|
1509
|
-
// execute" clears into a fresh session), so re-key the existing runtime
|
|
1510
|
-
// old→new WITHOUT recreating the NotificationServer: the server, its endpoint
|
|
1511
|
-
// discovery file, and the daemon's forum topic are all keyed by the original
|
|
1512
|
-
// session id and the daemon routes by socket, so the existing topic is reused
|
|
1513
|
-
// and the next identity frame renames it in place instead of spawning a new
|
|
1514
|
-
// thread. `resume`, by contrast, loads a DIFFERENT, already-persisted session
|
|
1515
|
-
// that owns its own topic — tear the previous runtime down and start fresh
|
|
1516
|
-
// under the resumed id so the daemon attaches to (or recreates) that
|
|
1517
|
-
// session's own discovery + topic rather than hijacking this terminal's.
|
|
1518
|
-
api.on("session_switch", async (event, ctx) => {
|
|
1519
|
-
const newId = sessionId(ctx);
|
|
1520
|
-
const prevId = sessionIdFromFile(event.previousSessionFile);
|
|
1521
|
-
if (!prevId || prevId === newId) return;
|
|
1522
|
-
|
|
1523
|
-
if (event.reason === "resume") {
|
|
1524
|
-
stopSession(prevId);
|
|
1525
|
-
await startSession(ctx);
|
|
1526
|
-
return;
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
// `/new` / fork: re-key in place and rename the existing topic.
|
|
1530
|
-
if (disabledSessions.delete(prevId)) disabledSessions.add(newId);
|
|
1531
|
-
const rt = runtimes.get(prevId);
|
|
1532
|
-
if (!rt || runtimes.has(newId)) return;
|
|
1533
|
-
runtimes.delete(prevId);
|
|
1534
|
-
runtimes.set(newId, rt);
|
|
1535
|
-
// Re-bind the interactive ask answer source: the ask tool resolves the
|
|
1536
|
-
// source by the current session id, which just changed.
|
|
1537
|
-
try {
|
|
1538
|
-
rt.cancelPostmortemCleanup();
|
|
1539
|
-
rt.disposeAnswerSource();
|
|
1540
|
-
rt.disposeFileSink();
|
|
1541
|
-
} catch {}
|
|
1542
|
-
// Follow the id change so a later process teardown closes the re-keyed
|
|
1543
|
-
// session (the old closure captured the retired id).
|
|
1544
|
-
rt.cancelPostmortemCleanup = postmortem.register(`notifications-session-closed:${newId}`, async () => {
|
|
1545
|
-
await stopSession(newId);
|
|
1546
|
-
});
|
|
1547
|
-
rt.disposeAnswerSource = registerInteractiveAnswerSource(
|
|
1548
|
-
newId,
|
|
1549
|
-
rt.server,
|
|
1550
|
-
rt.pendingInteractive,
|
|
1551
|
-
() => rt.redact,
|
|
1552
|
-
rt.sessionTag,
|
|
1553
|
-
);
|
|
1554
|
-
rt.disposeFileSink = registerTelegramFileSink(newId, async file => {
|
|
1555
|
-
if (rt.redact) {
|
|
1556
|
-
return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
try {
|
|
1560
|
-
const data = await fs.promises.readFile(file.path);
|
|
1561
|
-
rt.server.pushFrame(
|
|
1562
|
-
JSON.stringify({
|
|
1563
|
-
type: "file_attachment",
|
|
1564
|
-
sessionId: newId,
|
|
1565
|
-
name: path.basename(file.path),
|
|
1566
|
-
data: data.toString("base64"),
|
|
1567
|
-
caption: file.caption,
|
|
1568
|
-
}),
|
|
1569
|
-
);
|
|
1570
|
-
return { ok: true };
|
|
1571
|
-
} catch (e) {
|
|
1572
|
-
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
1573
|
-
}
|
|
1574
|
-
});
|
|
1575
|
-
// Rename the existing topic now when the new session already has a name; a
|
|
1576
|
-
// fresh unnamed session is renamed on its next agent_end re-assert, which
|
|
1577
|
-
// avoids a transient rename to bare "repo/branch".
|
|
1578
|
-
if (ctx.sessionManager.getSessionName()) {
|
|
1579
|
-
try {
|
|
1580
|
-
rt.server.pushFrame(
|
|
1581
|
-
JSON.stringify({
|
|
1582
|
-
type: "identity_header",
|
|
1583
|
-
sessionId: newId,
|
|
1584
|
-
...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
|
|
1585
|
-
}),
|
|
1586
|
-
);
|
|
1587
|
-
} catch (e) {
|
|
1588
|
-
logger.warn(`notifications: identity_header (switch) failed: ${String(e)}`);
|
|
1589
|
-
}
|
|
1590
|
-
}
|
|
1591
|
-
});
|
|
1592
|
-
|
|
1593
|
-
// Drive the live typing indicator: mark busy when the agent loop starts so
|
|
1594
|
-
// the daemon shows "typing…" in the thread while the agent is thinking,
|
|
1595
|
-
// before any turn output exists. Cleared on `agent_end` below.
|
|
1596
|
-
api.on("agent_start", (_event, ctx) => {
|
|
1597
|
-
const id = sessionId(ctx);
|
|
1598
|
-
const rt = runtimes.get(id);
|
|
1599
|
-
if (!rt) return;
|
|
1600
|
-
rt.busy = true;
|
|
1601
|
-
try {
|
|
1602
|
-
rt.server.pushFrame(JSON.stringify({ type: "activity", sessionId: id, state: "busy" }));
|
|
1603
|
-
} catch (e) {
|
|
1604
|
-
logger.warn(`notifications: activity (busy) failed: ${String(e)}`);
|
|
1605
|
-
}
|
|
1606
|
-
});
|
|
1607
|
-
|
|
1608
|
-
// Each turn that starts has absorbed any messages injected from the thread,
|
|
1609
|
-
// so ack them as "consumed": the daemon flips the queued reaction on the
|
|
1610
|
-
// originating Telegram message to the consumed (double-check) reaction.
|
|
1611
|
-
api.on("turn_start", (_event, ctx) => {
|
|
1612
|
-
const id = sessionId(ctx);
|
|
1613
|
-
const rt = runtimes.get(id);
|
|
1614
|
-
if (!rt) return;
|
|
1615
|
-
// A new turn is live: re-open the live-stream window (see turnClosed).
|
|
1616
|
-
rt.turnClosed = false;
|
|
1617
|
-
if (rt.pendingInbound.size === 0) return;
|
|
1618
|
-
for (const updateId of rt.pendingInbound) {
|
|
1619
|
-
try {
|
|
1620
|
-
rt.server.pushFrame(JSON.stringify({ type: "inbound_ack", sessionId: id, updateId, state: "consumed" }));
|
|
1621
|
-
} catch (e) {
|
|
1622
|
-
logger.warn(`notifications: inbound_ack failed: ${String(e)}`);
|
|
1623
|
-
}
|
|
1624
|
-
}
|
|
1625
|
-
rt.pendingInbound.clear();
|
|
1626
|
-
});
|
|
1627
|
-
|
|
1628
|
-
// Idle fires on `agent_end` (the agent loop settling to await the user), NOT
|
|
1629
|
-
// per `turn_end`. turn_end fires once per turn iteration, so a single
|
|
1630
|
-
// user-visible idle previously produced many idle pings (the flood); agent_end
|
|
1631
|
-
// fires exactly once per settle, yielding exactly one idle notification.
|
|
1632
|
-
api.on("agent_end", (_event, ctx) => {
|
|
1633
|
-
const id = sessionId(ctx);
|
|
1634
|
-
const rt = runtimes.get(id);
|
|
1635
|
-
if (!rt) return;
|
|
1636
|
-
const seq = rt.idleSeq++;
|
|
1637
|
-
// Clear the typing indicator: the agent loop has settled.
|
|
1638
|
-
rt.busy = false;
|
|
1639
|
-
try {
|
|
1640
|
-
rt.server.pushFrame(JSON.stringify({ type: "activity", sessionId: id, state: "idle" }));
|
|
1641
|
-
} catch (e) {
|
|
1642
|
-
logger.warn(`notifications: activity (idle) failed: ${String(e)}`);
|
|
1643
|
-
}
|
|
1644
|
-
// Re-assert the identity header so the daemon renames the topic once the
|
|
1645
|
-
// session title has been auto-generated ("{repo}/{branch} - {title}"). The
|
|
1646
|
-
// daemon only renames when the title actually changed.
|
|
1647
|
-
try {
|
|
1648
|
-
rt.server.pushFrame(
|
|
1649
|
-
JSON.stringify({
|
|
1650
|
-
type: "identity_header",
|
|
1651
|
-
sessionId: id,
|
|
1652
|
-
...buildIdentity(ctx.cwd, ctx.sessionManager.getSessionName()),
|
|
1653
|
-
}),
|
|
1654
|
-
);
|
|
1655
|
-
} catch {}
|
|
1656
|
-
try {
|
|
1657
|
-
rt.server.noteIdle(
|
|
1658
|
-
JSON.stringify(
|
|
1659
|
-
notificationActionPayload(
|
|
1660
|
-
{
|
|
1661
|
-
id: `idle:${id}#${seq}`,
|
|
1662
|
-
kind: "idle",
|
|
1663
|
-
sessionId: id,
|
|
1664
|
-
summary: undefined,
|
|
1665
|
-
},
|
|
1666
|
-
{ redact: rt.redact, sessionTag: rt.sessionTag },
|
|
1667
|
-
),
|
|
1668
|
-
),
|
|
1669
|
-
);
|
|
1670
|
-
} catch (e) {
|
|
1671
|
-
logger.warn(`notifications: noteIdle failed: ${String(e)}`);
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
// On idle, stream a context update with metadata (token/model usage +
|
|
1675
|
-
// working-tree diff) unless redaction is on. The agent's last message is
|
|
1676
|
-
// NOT repeated here — it is already streamed once via `turn_stream`.
|
|
1677
|
-
if (!rt.redact && rt.verbosity === "verbose") {
|
|
1678
|
-
const usage = (
|
|
1679
|
-
ctx as { getContextUsage?: () => { tokens: number | null; contextWindow: number } | undefined }
|
|
1680
|
-
).getContextUsage?.();
|
|
1681
|
-
const model = (ctx as { getModel?: () => { id?: string } | undefined }).getModel?.();
|
|
1682
|
-
const tokenUsage = usage && usage.tokens != null ? `${usage.tokens}/${usage.contextWindow}` : undefined;
|
|
1683
|
-
const modelId = model?.id;
|
|
1684
|
-
void readGitDiffStat(ctx.cwd).then(diff => {
|
|
1685
|
-
const cwd = compactCwd(ctx.cwd);
|
|
1686
|
-
if (!diff && !tokenUsage && !modelId && !cwd) return;
|
|
1687
|
-
try {
|
|
1688
|
-
rt.server.pushFrame(
|
|
1689
|
-
JSON.stringify({
|
|
1690
|
-
type: "context_update",
|
|
1691
|
-
sessionId: id,
|
|
1692
|
-
tokenUsage,
|
|
1693
|
-
model: modelId,
|
|
1694
|
-
diff,
|
|
1695
|
-
cwd,
|
|
1696
|
-
}),
|
|
1697
|
-
);
|
|
1698
|
-
} catch (e) {
|
|
1699
|
-
logger.warn(`notifications: context_update failed: ${String(e)}`);
|
|
1700
|
-
}
|
|
1701
|
-
});
|
|
1702
|
-
}
|
|
1703
|
-
});
|
|
1704
|
-
|
|
1705
|
-
// Stream viable agent output per turn (the live thread mirror). Unlike idle,
|
|
1706
|
-
// turn output is expected to be multiple messages — one per turn that
|
|
1707
|
-
// produced assistant text. Tool-only turns yield no text and are skipped.
|
|
1708
|
-
// Redaction suppresses streamed content (only the one-time identity header
|
|
1709
|
-
// survives redaction). The daemon coalesces/throttles these via its shared
|
|
1710
|
-
// rate-limit pool before sending to Telegram.
|
|
1711
|
-
// Push the in-flight turn's assistant text as a finalized turn_stream, deduped
|
|
1712
|
-
// against what was already flushed for this turn (the pre-ask lead-in).
|
|
1713
|
-
const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined, finalAnswer: boolean): void => {
|
|
1714
|
-
if (!text || text === rt.preAskFlushedText) return;
|
|
1715
|
-
rt.preAskFlushedText = text;
|
|
1716
|
-
// Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
|
|
1717
|
-
// live message, never a fresh (rich-promotable) send. If live frames were
|
|
1718
|
-
// async-queued and none landed before this flush, allocate the per-turn ref
|
|
1719
|
-
// now so the finalized frame always carries a messageRef → the daemon keeps it
|
|
1720
|
-
// editable (HTML edit) and never rich-promotes a streamed final.
|
|
1721
|
-
if (finalAnswer && rt.stream && rt.liveRef === undefined) {
|
|
1722
|
-
rt.turnSeq = (rt.turnSeq ?? 0) + 1;
|
|
1723
|
-
rt.liveRef = String(rt.turnSeq);
|
|
1724
|
-
}
|
|
1725
|
-
try {
|
|
1726
|
-
rt.server.pushFrame(
|
|
1727
|
-
JSON.stringify({
|
|
1728
|
-
type: "turn_stream",
|
|
1729
|
-
sessionId: id,
|
|
1730
|
-
phase: "finalized",
|
|
1731
|
-
finalAnswer,
|
|
1732
|
-
text,
|
|
1733
|
-
...(rt.liveRef ? { messageRef: rt.liveRef } : {}),
|
|
1734
|
-
}),
|
|
1735
|
-
);
|
|
1736
|
-
} catch (e) {
|
|
1737
|
-
logger.warn(`notifications: pushFrame (turn) failed: ${String(e)}`);
|
|
1738
|
-
}
|
|
1739
|
-
};
|
|
1740
|
-
|
|
1741
|
-
// Emit the assistant text that precedes an ask BEFORE the ask's action_needed
|
|
1742
|
-
// is broadcast, so the remote (e.g. Telegram) shows the lead-in first instead
|
|
1743
|
-
// of only after the ask resolves at turn_end. The text is captured on
|
|
1744
|
-
// message_end (which, like tool_execution_start, is on the awaited extension
|
|
1745
|
-
// path and ordered before it — unlike message_update, which is queued async),
|
|
1746
|
-
// then flushed here before the ask tool's execute calls registerAsk.
|
|
1747
|
-
api.on("tool_execution_start", (event, ctx) => {
|
|
1748
|
-
if (event.toolName !== "ask") return;
|
|
1749
|
-
const id = sessionId(ctx);
|
|
1750
|
-
const rt = runtimes.get(id);
|
|
1751
|
-
if (!rt || rt.redact) return;
|
|
1752
|
-
flushTurnText(rt, id, rt.currentTurnText, false);
|
|
1753
|
-
});
|
|
1754
|
-
|
|
1755
|
-
api.on("turn_end", (event, ctx) => {
|
|
1756
|
-
const id = sessionId(ctx);
|
|
1757
|
-
const rt = runtimes.get(id);
|
|
1758
|
-
if (!rt) return;
|
|
1759
|
-
const text = rt.redact ? undefined : summaryFromMessage(event.message, turnTextMax());
|
|
1760
|
-
if (text) flushTurnText(rt, id, text, true);
|
|
1761
|
-
// Reset per-turn streaming state so the next turn starts fresh and a later
|
|
1762
|
-
// turn with identical text is not falsely deduped.
|
|
1763
|
-
rt.currentTurnText = undefined;
|
|
1764
|
-
rt.preAskFlushedText = undefined;
|
|
1765
|
-
rt.liveRef = undefined;
|
|
1766
|
-
// Close the live-stream window: any message_update queued after turn_end is
|
|
1767
|
-
// dropped so it can never emit a stale live edit past the finalized turn.
|
|
1768
|
-
rt.turnClosed = true;
|
|
1769
|
-
rt.lastLiveAt = undefined;
|
|
1770
|
-
rt.lastLiveText = undefined;
|
|
1771
|
-
});
|
|
1772
|
-
|
|
1773
|
-
// Live streaming (opt-in): push throttled in-progress assistant text as
|
|
1774
|
-
// non-finalized turn_stream frames so remote clients edit one message as the
|
|
1775
|
-
// turn streams. The finalized frame (turn_end) carries the same messageRef and
|
|
1776
|
-
// lands the authoritative text. Suppressed under redaction.
|
|
1777
|
-
api.on("message_update", (event, ctx) => {
|
|
1778
|
-
const id = sessionId(ctx);
|
|
1779
|
-
const rt = runtimes.get(id);
|
|
1780
|
-
if (!rt?.stream || rt.redact || rt.turnClosed) return;
|
|
1781
|
-
if ((event.message as { role?: unknown }).role !== "assistant") return;
|
|
1782
|
-
if (rt.liveRef === undefined) {
|
|
1783
|
-
rt.turnSeq = (rt.turnSeq ?? 0) + 1;
|
|
1784
|
-
rt.liveRef = String(rt.turnSeq);
|
|
1785
|
-
}
|
|
1786
|
-
const now = Date.now();
|
|
1787
|
-
if (now - (rt.lastLiveAt ?? 0) < streamIntervalMs()) return;
|
|
1788
|
-
const text = summaryFromMessage(event.message, 3500);
|
|
1789
|
-
if (!text || text === rt.lastLiveText) return;
|
|
1790
|
-
rt.lastLiveAt = now;
|
|
1791
|
-
rt.lastLiveText = text;
|
|
1792
|
-
try {
|
|
1793
|
-
rt.server.pushFrame(
|
|
1794
|
-
JSON.stringify({ type: "turn_stream", sessionId: id, phase: "live", text, messageRef: rt.liveRef }),
|
|
1795
|
-
);
|
|
1796
|
-
} catch (e) {
|
|
1797
|
-
logger.warn(`notifications: pushFrame (live) failed: ${String(e)}`);
|
|
1798
|
-
}
|
|
1799
|
-
});
|
|
1800
|
-
|
|
1801
|
-
// Stream agent-produced images (computer/browser/tool screenshots) as
|
|
1802
|
-
// image_attachment frames; suppressed when redaction is on.
|
|
1803
|
-
api.on("message_end", (event, ctx) => {
|
|
1804
|
-
const id = sessionId(ctx);
|
|
1805
|
-
const rt = runtimes.get(id);
|
|
1806
|
-
if (!rt || rt.redact) return;
|
|
1807
|
-
// Capture the in-flight ASSISTANT text here (message_end is on the awaited
|
|
1808
|
-
// extension path and ordered before tool_execution_start) so the pre-ask
|
|
1809
|
-
// flush can emit it before the ask prompt. Role-scoped: message_end also
|
|
1810
|
-
// fires for the user prompt, which must never be mirrored back as turn output.
|
|
1811
|
-
if ((event.message as { role?: unknown }).role === "assistant") {
|
|
1812
|
-
const turnText = summaryFromMessage(event.message, turnTextMax());
|
|
1813
|
-
if (turnText) rt.currentTurnText = turnText;
|
|
1814
|
-
}
|
|
1815
|
-
for (const img of imageAttachmentsFromMessage(event.message)) {
|
|
1816
|
-
try {
|
|
1817
|
-
rt.server.pushFrame(
|
|
1818
|
-
JSON.stringify({
|
|
1819
|
-
type: "image_attachment",
|
|
1820
|
-
sessionId: id,
|
|
1821
|
-
source: img.source,
|
|
1822
|
-
mime: img.mime,
|
|
1823
|
-
data: img.data,
|
|
1824
|
-
}),
|
|
1825
|
-
);
|
|
1826
|
-
} catch (e) {
|
|
1827
|
-
logger.warn(`notifications: image_attachment failed: ${String(e)}`);
|
|
1828
|
-
}
|
|
1829
|
-
}
|
|
1830
|
-
});
|
|
1831
|
-
|
|
1832
|
-
api.on("session_shutdown", async (_event, ctx) => {
|
|
1833
|
-
await stopSession(sessionId(ctx));
|
|
1834
|
-
});
|
|
1835
|
-
}
|