@gajae-code/coding-agent 0.11.3 → 0.11.5

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.
Files changed (213) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/types/cli/notify-cli.d.ts +5 -1
  3. package/dist/types/cli/worktree-cli.d.ts +8 -25
  4. package/dist/types/commands/gc.d.ts +4 -0
  5. package/dist/types/commands/worktree.d.ts +7 -32
  6. package/dist/types/config/settings-schema.d.ts +20 -0
  7. package/dist/types/export/html/template.generated.d.ts +1 -1
  8. package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
  9. package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
  10. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
  11. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
  12. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
  13. package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
  14. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
  15. package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
  16. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  17. package/dist/types/harness-control-plane/owner.d.ts +3 -1
  18. package/dist/types/internal-urls/local-protocol.d.ts +18 -0
  19. package/dist/types/main.d.ts +2 -2
  20. package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
  21. package/dist/types/modes/components/custom-editor.d.ts +8 -3
  22. package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
  23. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
  24. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  25. package/dist/types/modes/components/tool-transcript-format.d.ts +77 -0
  26. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
  27. package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
  28. package/dist/types/modes/controllers/input-controller.d.ts +8 -2
  29. package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
  30. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
  31. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
  32. package/dist/types/plan-mode/approved-plan.d.ts +1 -0
  33. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  34. package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
  35. package/dist/types/sdk/broker/broker.d.ts +1 -0
  36. package/dist/types/sdk/broker/discovery.d.ts +10 -0
  37. package/dist/types/sdk/broker/ensure.d.ts +24 -1
  38. package/dist/types/sdk/broker/internal.d.ts +3 -0
  39. package/dist/types/sdk/broker/session-index.d.ts +12 -0
  40. package/dist/types/sdk/broker/state-version.d.ts +2 -1
  41. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +39 -4
  43. package/dist/types/sdk/bus/config-commands.d.ts +8 -0
  44. package/dist/types/sdk/bus/config.d.ts +22 -0
  45. package/dist/types/sdk/bus/index.d.ts +162 -2
  46. package/dist/types/sdk/bus/notification-service.d.ts +71 -8
  47. package/dist/types/sdk/bus/session-control.d.ts +11 -8
  48. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
  49. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -3
  50. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
  51. package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
  52. package/dist/types/sdk/session.d.ts +16 -1
  53. package/dist/types/secrets/index.d.ts +4 -2
  54. package/dist/types/secrets/obfuscator.d.ts +1 -1
  55. package/dist/types/session/agent-session.d.ts +31 -2
  56. package/dist/types/session/artifacts.d.ts +30 -8
  57. package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
  58. package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
  59. package/dist/types/session/session-manager.d.ts +88 -18
  60. package/dist/types/session/session-storage.d.ts +27 -0
  61. package/dist/types/session/streaming-output.d.ts +4 -0
  62. package/dist/types/task/executor.d.ts +9 -0
  63. package/dist/types/tool-discovery/tool-index.d.ts +2 -0
  64. package/dist/types/tools/ask.d.ts +87 -12
  65. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  66. package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
  67. package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
  68. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
  69. package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
  70. package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
  71. package/dist/types/tools/index.d.ts +4 -0
  72. package/dist/types/tools/path-utils.d.ts +9 -14
  73. package/dist/types/tools/render-utils.d.ts +1 -8
  74. package/dist/types/tools/resource-gc.d.ts +14 -0
  75. package/dist/types/utils/image-loading.d.ts +23 -0
  76. package/dist/types/utils/image-resize.d.ts +2 -0
  77. package/dist/types/utils/pasted-image-loading.d.ts +39 -0
  78. package/dist/types/utils/pasted-image-path.d.ts +21 -19
  79. package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
  80. package/dist/types/web/scrapers/types.d.ts +1 -5
  81. package/package.json +25 -9
  82. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  83. package/scripts/run-threat-model-check.ts +60 -0
  84. package/src/cli/notify-cli.ts +40 -18
  85. package/src/cli/worktree-cli.ts +9 -287
  86. package/src/cli.ts +10 -9
  87. package/src/commands/gc.ts +11 -1
  88. package/src/commands/sdk.ts +10 -4
  89. package/src/commands/worktree.ts +10 -53
  90. package/src/config/model-profiles.ts +15 -15
  91. package/src/config/settings-schema.ts +17 -0
  92. package/src/daemon/runtime.ts +2 -2
  93. package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
  94. package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
  95. package/src/defaults/gjc/skills/team/SKILL.md +7 -9
  96. package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
  97. package/src/eval/js/shared/runtime.ts +1 -1
  98. package/src/eval/py/index.ts +2 -2
  99. package/src/export/html/template.generated.ts +1 -1
  100. package/src/extensibility/plugins/marketplace/manager.ts +94 -10
  101. package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
  102. package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
  103. package/src/gjc-runtime/deep-interview-state.ts +127 -3
  104. package/src/gjc-runtime/gc-render.ts +19 -1
  105. package/src/gjc-runtime/gc-runtime.ts +68 -3
  106. package/src/gjc-runtime/launch-tmux.ts +61 -6
  107. package/src/gjc-runtime/managed-owner-admission.ts +352 -0
  108. package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
  109. package/src/gjc-runtime/state-argv.ts +292 -0
  110. package/src/gjc-runtime/state-runtime.ts +80 -222
  111. package/src/gjc-runtime/team-runtime.ts +1 -1
  112. package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
  113. package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
  114. package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
  115. package/src/harness-control-plane/owner.ts +59 -3
  116. package/src/hindsight/client.ts +65 -2
  117. package/src/internal-urls/agent-protocol.ts +76 -5
  118. package/src/internal-urls/docs-index.generated.ts +7 -6
  119. package/src/internal-urls/local-protocol.ts +416 -18
  120. package/src/lsp/index.ts +4 -88
  121. package/src/main.ts +37 -13
  122. package/src/modes/acp/acp-agent.ts +8 -0
  123. package/src/modes/components/ansi-display-validator.ts +54 -0
  124. package/src/modes/components/custom-editor.ts +103 -32
  125. package/src/modes/components/custom-provider-wizard.ts +26 -1
  126. package/src/modes/components/notifications-settings-editor.ts +26 -2
  127. package/src/modes/components/session-observer-overlay.ts +40 -37
  128. package/src/modes/components/settings-selector.ts +3 -1
  129. package/src/modes/components/tool-transcript-format.ts +393 -0
  130. package/src/modes/components/transcript-viewer-overlay.ts +63 -1
  131. package/src/modes/controllers/goal-mode-controller.ts +20 -1
  132. package/src/modes/controllers/input-controller.ts +98 -37
  133. package/src/modes/controllers/plan-mode-controller.ts +2 -0
  134. package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
  135. package/src/modes/controllers/selector-controller.ts +71 -9
  136. package/src/modes/interactive-mode.ts +14 -2
  137. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
  138. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
  139. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
  140. package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
  141. package/src/plan-mode/approved-plan.ts +3 -1
  142. package/src/prompts/agents/architect.md +1 -0
  143. package/src/prompts/agents/critic.md +1 -0
  144. package/src/prompts/system/custom-system-prompt.md +1 -1
  145. package/src/prompts/system/system-prompt.md +2 -1
  146. package/src/prompts/tools/lsp.md +3 -3
  147. package/src/runtime-mcp/manager.ts +16 -1
  148. package/src/runtime-mcp/oauth-discovery.ts +116 -26
  149. package/src/sdk/broker/broker.ts +139 -32
  150. package/src/sdk/broker/discovery.ts +125 -0
  151. package/src/sdk/broker/ensure.ts +103 -19
  152. package/src/sdk/broker/internal.ts +10 -0
  153. package/src/sdk/broker/session-index.ts +309 -38
  154. package/src/sdk/broker/state-version.ts +5 -2
  155. package/src/sdk/bus/chat-daemon-cli.ts +72 -10
  156. package/src/sdk/bus/chat-daemon-control.ts +526 -130
  157. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  158. package/src/sdk/bus/config-commands.ts +22 -0
  159. package/src/sdk/bus/config.ts +37 -0
  160. package/src/sdk/bus/index.ts +384 -92
  161. package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
  162. package/src/sdk/bus/notification-service.ts +284 -62
  163. package/src/sdk/bus/recent-activity.ts +14 -5
  164. package/src/sdk/bus/session-control.ts +150 -44
  165. package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
  166. package/src/sdk/bus/telegram-daemon-contract.ts +6 -3
  167. package/src/sdk/bus/telegram-daemon-control.ts +294 -76
  168. package/src/sdk/bus/telegram-daemon.ts +1842 -281
  169. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  170. package/src/sdk/session.ts +155 -27
  171. package/src/secrets/index.ts +53 -10
  172. package/src/secrets/obfuscator.ts +21 -18
  173. package/src/secrets/regex.ts +2 -0
  174. package/src/session/agent-session.ts +495 -253
  175. package/src/session/artifacts.ts +188 -23
  176. package/src/session/context-estimation.ts +7 -2
  177. package/src/session/internal/managed-session-scope.ts +215 -39
  178. package/src/session/internal/managed-session-storage.ts +1152 -46
  179. package/src/session/session-manager.ts +1055 -148
  180. package/src/session/session-storage.ts +109 -11
  181. package/src/session/streaming-output.ts +6 -0
  182. package/src/task/executor.ts +71 -26
  183. package/src/task/index.ts +63 -20
  184. package/src/tool-discovery/tool-index.ts +18 -0
  185. package/src/tools/ask.ts +329 -57
  186. package/src/tools/bash-allowed-prefixes.ts +79 -29
  187. package/src/tools/bash-skill-urls.ts +14 -3
  188. package/src/tools/bash.ts +2 -2
  189. package/src/tools/browser/launch.ts +3 -1
  190. package/src/tools/browser/readable.ts +1 -1
  191. package/src/tools/browser/screenshot-format.ts +34 -0
  192. package/src/tools/browser/tab-protocol.ts +3 -0
  193. package/src/tools/browser/tab-supervisor.ts +125 -122
  194. package/src/tools/browser/tab-worker-entry.ts +33 -14
  195. package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
  196. package/src/tools/browser/tab-worker-smoke.ts +87 -0
  197. package/src/tools/browser/tab-worker.ts +7 -5
  198. package/src/tools/index.ts +4 -0
  199. package/src/tools/path-utils.ts +42 -35
  200. package/src/tools/plan-mode-guard.ts +1 -0
  201. package/src/tools/read.ts +7 -1
  202. package/src/tools/render-utils.ts +1 -27
  203. package/src/tools/resource-gc.ts +123 -34
  204. package/src/tools/sqlite-reader.ts +3 -2
  205. package/src/tools/subagent.ts +2 -0
  206. package/src/utils/image-loading.ts +85 -34
  207. package/src/utils/image-resize.ts +16 -9
  208. package/src/utils/pasted-image-loading.ts +514 -0
  209. package/src/utils/pasted-image-path.ts +223 -98
  210. package/src/web/scrapers/html-to-markdown.ts +68 -0
  211. package/src/web/scrapers/readthedocs.ts +1 -1
  212. package/src/web/scrapers/types.ts +1 -68
  213. package/src/web/search/providers/openai-compatible.ts +76 -23
@@ -33,12 +33,15 @@ import { NotificationServer, nativeBuildInfo } from "@gajae-code/natives";
33
33
  import { logger, postmortem, prompt, VERSION } from "@gajae-code/utils";
34
34
  import { Settings } from "../../config/settings";
35
35
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../../extensibility/extensions";
36
- import type {
37
- WorkflowGateEmitter,
38
- WorkflowGateTerminalController,
39
- WorkflowGateTerminalProof,
36
+ import { toAgentWireEventPayload } from "../../modes/shared/agent-wire/event-envelope";
37
+ import {
38
+ NotificationGatePolicyChangedError,
39
+ type WorkflowGateEmitter,
40
+ type WorkflowGateTerminalController,
41
+ type WorkflowGateTerminalProof,
40
42
  } from "../../modes/shared/agent-wire/workflow-gate-broker";
41
43
  import btwUserPrompt from "../../prompts/system/btw-user.md" with { type: "text" };
44
+ import type { AgentSessionEvent } from "../../session/agent-session";
42
45
  import { parseThinkingLevel } from "../../thinking";
43
46
  import type {
44
47
  AskAnswerRequest,
@@ -81,6 +84,7 @@ import { imageAttachmentsFromMessage, notificationActionPayload, summaryFromMess
81
84
  import { assertNativeRuntimeCompatibility } from "./native-runtime-compatibility";
82
85
  import { NotificationSessionController, type NotificationSessionRuntime } from "./session-control";
83
86
  import {
87
+ ASK_SELECTED_ACK_CAPABILITY,
84
88
  type EnsureDaemonResult,
85
89
  endpointAuthorityDigest,
86
90
  ensureTelegramDaemonRunningDetailed,
@@ -880,6 +884,12 @@ interface SessionRuntime {
880
884
  workflowGate?: WorkflowGateEmitter;
881
885
  gatePresentations?: PresentationArbiter;
882
886
  redact: boolean;
887
+ /** Last stable policy's redaction state, retained while provisional policy is held. */
888
+ committedRedact: boolean;
889
+ /** Provisional policy suppresses delivery without changing committed-side effects. */
890
+ policySuspended: boolean;
891
+ /** Monotonic policy epoch fences asynchronous notification delivery. */
892
+ policyGeneration: number;
883
893
  /** True only after the exact host generation was registered with the broker index. */
884
894
  brokerRegistrationActive: boolean;
885
895
  /** Terminal cleanup proof retained across retries; each owner is released at most once after proof. */
@@ -910,6 +920,8 @@ interface SessionRuntime {
910
920
  error: { code: string; message: string };
911
921
  },
912
922
  ) => void;
923
+ /** Publishes one canonical agent-wire event to the client that owns the active prompt. */
924
+ emitPromptEvent: (event: AgentSessionEvent) => void;
913
925
  /** Inbound Telegram update ids injected but not yet consumed by a turn. */
914
926
  pendingInbound: Set<number>;
915
927
  /** Latest assistant text of the in-flight turn (from message_update). */
@@ -926,6 +938,12 @@ interface SessionRuntime {
926
938
  /** True between turn_end and the next turn_start: drops late async message_update
927
939
  * frames so a stale live edit can never be emitted after the finalized turn. */
928
940
  turnClosed?: boolean;
941
+ /** Finalized while provisional policy was held; flush exactly once on stable activation. */
942
+ pendingFinal?: { text?: string; messageRef?: string };
943
+ /** Durable gates emitted while ownership is provisional; presented only after stable activation. */
944
+ deferredGatePresentations: Array<() => void>;
945
+ /** SDK control frames received during provisional ownership; replayed only after stable activation. */
946
+ deferredInboundControls: Array<() => void>;
929
947
  /** Started tool calls awaiting a terminal activity frame, keyed by tool call id. */
930
948
  inFlightTools: Map<string, { toolName: string; args: unknown }>;
931
949
  /** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */
@@ -981,6 +999,7 @@ type SessionStartResult = {
981
999
  status: SessionStartStatus;
982
1000
  runtime?: SessionRuntime;
983
1001
  failure?: SdkStartupFailure;
1002
+ suppressExtensionError?: boolean;
984
1003
  };
985
1004
 
986
1005
  function pushSessionFrame(
@@ -1049,6 +1068,8 @@ const defaultConfig: NotificationConfig = {
1049
1068
  idleTimeoutMs: 60_000,
1050
1069
  rich: { enabled: true },
1051
1070
  richDraft: { enabled: false },
1071
+ toolActivity: { enabled: true },
1072
+ streaming: { enabled: true },
1052
1073
  topics: {},
1053
1074
  btw: { enabled: true },
1054
1075
  };
@@ -1057,13 +1078,6 @@ export function notificationsEnabled(): boolean {
1057
1078
  return process.env.GJC_NOTIFICATIONS === "1" || Boolean(process.env.GJC_NOTIFICATIONS_TOKEN);
1058
1079
  }
1059
1080
 
1060
- // Live streaming (opt-in): emit throttled non-finalized `turn_stream` frames as
1061
- // the assistant message streams so remote clients can edit ONE message live. The
1062
- // finalized frame (turn_end) carries the same messageRef and stays authoritative,
1063
- // so a dropped live frame self-heals. Off unless GJC_NOTIFICATIONS_STREAM=1.
1064
- function streamingEnabled(): boolean {
1065
- return process.env.GJC_NOTIFICATIONS_STREAM === "1";
1066
- }
1067
1081
  function streamIntervalMs(): number {
1068
1082
  return Math.max(200, Number(process.env.GJC_NOTIFICATIONS_STREAM_INTERVAL_MS) || 500);
1069
1083
  }
@@ -2728,6 +2742,22 @@ function sdkInboundFrame(commandJson: string | undefined): Record<string, unknow
2728
2742
  return undefined;
2729
2743
  }
2730
2744
  }
2745
+ /**
2746
+ * Ensures every configured chat-provider daemon is ready before the SDK
2747
+ * publishes session identity. A rejected ensure is startup-fatal: presenting
2748
+ * an identity for a transport that never became available is false success.
2749
+ */
2750
+ export async function ensureConfiguredProviderDaemons(
2751
+ settings: Settings,
2752
+ cfg: NotificationConfig,
2753
+ ensureProviderDaemon: (provider: "discord" | "slack", settings: Settings) => Promise<unknown> = (
2754
+ provider,
2755
+ configuredSettings,
2756
+ ) => (provider === "discord" ? ensureDiscordDaemon(configuredSettings) : ensureSlackDaemon(configuredSettings)),
2757
+ ): Promise<void> {
2758
+ if (isDiscordConfigured(cfg)) await ensureProviderDaemon("discord", settings);
2759
+ if (isSlackConfigured(cfg)) await ensureProviderDaemon("slack", settings);
2760
+ }
2731
2761
 
2732
2762
  export function createNotificationsExtension(
2733
2763
  api: ExtensionAPI,
@@ -2738,6 +2768,7 @@ export function createNotificationsExtension(
2738
2768
  cwd: string;
2739
2769
  sessionId: string;
2740
2770
  }) => Promise<EnsureDaemonResult>;
2771
+ ensureProviderDaemon?: (provider: "discord" | "slack", settings: Settings) => Promise<unknown>;
2741
2772
  /** Suppress auto-delivery for a GJC-spawned child under `sessionScope=primary`. */
2742
2773
  spawnedByGjc?: boolean;
2743
2774
  controller?: NotificationSessionController;
@@ -2746,6 +2777,10 @@ export function createNotificationsExtension(
2746
2777
 
2747
2778
  onSdkRequest?: (kind: "control" | "query", connectionId: string, frame: Record<string, unknown>) => void;
2748
2779
  runEphemeralTurn?: (promptText: string, signal: AbortSignal) => Promise<{ replyText: string }>;
2780
+ /** Observes settlement of optional session-branch startup after reconciliation completes. */
2781
+ onBranchStartupSettled?: (receipt: { sessionId: string; status: SessionStartResult["status"] }) => void;
2782
+ readNotificationFile?: (path: string) => Promise<Buffer>;
2783
+ readNotificationDiffStat?: (cwd: string) => Promise<string | undefined>;
2749
2784
  } = {},
2750
2785
  ): void {
2751
2786
  const lifecycleStartupCapability = lifecycleStartupCapabilityForApi(api);
@@ -2761,9 +2796,12 @@ export function createNotificationsExtension(
2761
2796
  // exact runtime object retains authority for an explicit idempotent retry.
2762
2797
  const cleanupRetries = new Map<string, SessionRuntime>();
2763
2798
  const sessionStartPromises = new Map<string, Promise<SessionStartResult>>();
2799
+ const branchStartupTasks = new Set<Promise<void>>();
2764
2800
  let activeRuntimeId: string | undefined;
2765
2801
  let identityControlInFlight = false;
2766
- let deferredIdentityRotation: { event: { previousSessionFile?: string }; ctx: ExtensionContext } | undefined;
2802
+ let deferredIdentityRotation:
2803
+ | { event: { previousSessionFile?: string }; ctx: ExtensionContext; awaitStartup: boolean }
2804
+ | undefined;
2767
2805
  let extensionShuttingDown = false;
2768
2806
 
2769
2807
  async function ensureTelegramOwner(
@@ -2780,7 +2818,7 @@ export function createNotificationsExtension(
2780
2818
  ? "blocked_identity"
2781
2819
  : "ready";
2782
2820
  }
2783
- async function ensureConfiguredDaemons(
2821
+ async function ensureConfiguredDaemonOwners(
2784
2822
  settings: Settings,
2785
2823
  cfg: NotificationConfig,
2786
2824
  cwd: string,
@@ -2789,8 +2827,7 @@ export function createNotificationsExtension(
2789
2827
  if (isTelegramConfigured(cfg)) {
2790
2828
  if ((await ensureTelegramOwner(settings, cwd, id)) === "blocked_identity") return false;
2791
2829
  }
2792
- if (isDiscordConfigured(cfg)) await ensureDiscordDaemon(settings);
2793
- if (isSlackConfigured(cfg)) await ensureSlackDaemon(settings);
2830
+ await ensureConfiguredProviderDaemons(settings, cfg, options.ensureProviderDaemon);
2794
2831
  return true;
2795
2832
  }
2796
2833
  const identityControlOperations = new Set([
@@ -2953,6 +2990,16 @@ export function createNotificationsExtension(
2953
2990
  return ctx.sessionMetadata?.kind !== "sub";
2954
2991
  }
2955
2992
 
2993
+ function canDeliverAsync(runtime: SessionRuntime, generation: number): boolean {
2994
+ return (
2995
+ runtimes.get(runtime.id) === runtime &&
2996
+ !runtime.stopping &&
2997
+ runtime.notificationsActive &&
2998
+ !runtime.redact &&
2999
+ runtime.policyGeneration === generation
3000
+ );
3001
+ }
3002
+
2956
3003
  async function startSession(ctx: ExtensionContext): Promise<SessionStartResult> {
2957
3004
  const id = sessionId(ctx);
2958
3005
  const lifecycleRequestId = safeLifecycleRequestId(process.env.GJC_LIFECYCLE_REQUEST_ID);
@@ -3006,8 +3053,6 @@ export function createNotificationsExtension(
3006
3053
  const pendingInteractive = new Map<string, PendingInteractiveAsk>();
3007
3054
  const pendingPromptCorrelations: Array<{ commandId: string; turnId: string }> = [];
3008
3055
  const tag = sessionTag(id);
3009
- const redact = cfg.redact;
3010
- const verbosity = cfg.verbosity;
3011
3056
  let runtime: SessionRuntime | undefined;
3012
3057
 
3013
3058
  // The SDK can always answer now (interactive via the answer source, or the
@@ -3027,7 +3072,7 @@ export function createNotificationsExtension(
3027
3072
  if (lifecycleRequired) return failLifecycleStartup("failed", error);
3028
3073
  throw error;
3029
3074
  }
3030
- const gatePresentations = new PresentationArbiter(server, () => runtime?.redact ?? redact, tag);
3075
+ const gatePresentations = new PresentationArbiter(server, () => runtime?.redact ?? true, tag);
3031
3076
  let inboundSdkFrame: ((connectionId: string, frame: Record<string, unknown>) => void) | undefined;
3032
3077
  const inFlightGateResolutions = new Set<Promise<void>>();
3033
3078
  const trackGateResolution = <T>(resolution: Promise<T>): Promise<T> => {
@@ -3097,7 +3142,7 @@ export function createNotificationsExtension(
3097
3142
  error: unknown;
3098
3143
  terminal: boolean;
3099
3144
  createdAt: number;
3100
- bufferedLifecycle: PromptLifecycleFrame[];
3145
+ bufferedFrames: Array<PromptLifecycleFrame | Record<string, unknown>>;
3101
3146
  };
3102
3147
  const promptSubmissions = new Map<string, PromptSubmission>();
3103
3148
  const promptTerminalTombstones = new Map<string, number>();
@@ -3129,7 +3174,7 @@ export function createNotificationsExtension(
3129
3174
  };
3130
3175
  const abandonPrompt = (submission: PromptSubmission) => {
3131
3176
  submission.abandoned = true;
3132
- submission.bufferedLifecycle.length = 0;
3177
+ submission.bufferedFrames.length = 0;
3133
3178
  };
3134
3179
  const emitPromptLifecycle = (
3135
3180
  correlation: { commandId: string; turnId: string } | undefined,
@@ -3149,7 +3194,7 @@ export function createNotificationsExtension(
3149
3194
  return;
3150
3195
  }
3151
3196
  if (!submission.acknowledged) {
3152
- submission.bufferedLifecycle.push(frame);
3197
+ submission.bufferedFrames.push(frame);
3153
3198
  return;
3154
3199
  }
3155
3200
  try {
@@ -3160,8 +3205,31 @@ export function createNotificationsExtension(
3160
3205
  }
3161
3206
  if (submission.terminal) finalizePrompt(key, correlation);
3162
3207
  };
3208
+ const emitPromptEvent = (event: AgentSessionEvent) => {
3209
+ if (!runtime?.activePromptCorrelation) return;
3210
+ cleanupPromptRecords();
3211
+ const correlation = runtime.activePromptCorrelation;
3212
+ const submission = promptSubmissions.get(promptSubmissionKey(correlation));
3213
+ if (!submission || submission.abandoned) return;
3214
+ const frame = {
3215
+ type: "event",
3216
+ kind: event.type,
3217
+ payload: toAgentWireEventPayload(event),
3218
+ ...correlation,
3219
+ };
3220
+ if (!submission.acknowledged) {
3221
+ submission.bufferedFrames.push(frame);
3222
+ return;
3223
+ }
3224
+ try {
3225
+ runtime.server.sendTo(submission.connectionId, JSON.stringify(frame));
3226
+ } catch (error) {
3227
+ logger.warn(`sdk: correlated agent event delivery failed: ${String(error)}`);
3228
+ abandonPrompt(submission);
3229
+ }
3230
+ };
3163
3231
  const flushPromptLifecycle = (key: string, submission: PromptSubmission) => {
3164
- for (const frame of submission.bufferedLifecycle.splice(0)) {
3232
+ for (const frame of submission.bufferedFrames.splice(0)) {
3165
3233
  try {
3166
3234
  server.sendTo(submission.connectionId, JSON.stringify(frame));
3167
3235
  } catch (error) {
@@ -3197,7 +3265,7 @@ export function createNotificationsExtension(
3197
3265
  error: undefined,
3198
3266
  terminal: false,
3199
3267
  createdAt: Date.now(),
3200
- bufferedLifecycle: [],
3268
+ bufferedFrames: [],
3201
3269
  });
3202
3270
  };
3203
3271
  const recordPromptTerminal = (correlation: { commandId: string; turnId: string } | undefined) => {
@@ -3371,7 +3439,8 @@ export function createNotificationsExtension(
3371
3439
  const pending = deferredIdentityRotation;
3372
3440
  deferredIdentityRotation = undefined;
3373
3441
  identityControlInFlight = false;
3374
- if (response.ok === true && pending) await rotateSessionAuthority(pending.event, pending.ctx);
3442
+ if (response.ok === true && pending)
3443
+ await rotateSessionAuthority(pending.event, pending.ctx, pending.awaitStartup);
3375
3444
  }
3376
3445
  },
3377
3446
  control: async (connectionId, frame) => {
@@ -3466,17 +3535,23 @@ export function createNotificationsExtension(
3466
3535
  disableEphemeralTurns: () => {},
3467
3536
  cancelPostmortemCleanup: () => {},
3468
3537
 
3469
- redact,
3470
- verbosity,
3471
- stream: streamingEnabled(),
3538
+ redact: true,
3539
+ committedRedact: true,
3540
+ policySuspended: true,
3541
+ verbosity: "lean",
3542
+ stream: false,
3543
+ policyGeneration: 0,
3472
3544
  sessionTag: tag,
3473
3545
  busy: false,
3474
3546
  pendingPromptCorrelations,
3475
3547
  activePromptCorrelation: undefined,
3476
3548
  recordPromptTerminal,
3477
3549
  emitPromptLifecycle,
3550
+ emitPromptEvent,
3478
3551
  pendingInbound: new Set<number>(),
3479
3552
  inFlightTools: new Map<string, { toolName: string; args: unknown }>(),
3553
+ deferredGatePresentations: [],
3554
+ deferredInboundControls: [],
3480
3555
  };
3481
3556
  const initializedRuntime = runtime;
3482
3557
  runtimes.set(id, initializedRuntime);
@@ -3501,21 +3576,26 @@ export function createNotificationsExtension(
3501
3576
  };
3502
3577
  const cleanupAbandonedStartup = async (): Promise<void> => {
3503
3578
  try {
3504
- await server.stopAndWait();
3505
- } catch {}
3506
- try {
3507
- await host.stop();
3508
- host.reverse.dispose();
3509
- } catch {}
3510
- try {
3511
- cursors.close();
3512
- await revisions.close();
3513
- } catch {}
3579
+ await stopSession(id, "session", initializedRuntime);
3580
+ } catch (error) {
3581
+ // stopSession fences the exact runtime before releasing its owners and records
3582
+ // the lifecycle rollback proof even when one release needs a later retry.
3583
+ logger.error(`notifications: SDK notification runtime cleanup failed: ${String(error)}`);
3584
+ }
3514
3585
  };
3515
3586
 
3516
3587
  const ephemeralTurns = new EphemeralTurnHost(sendSdkFrame, async (question, signal) => {
3517
3588
  if (!options.runEphemeralTurn) throw new Error("Ephemeral turns are unavailable.");
3518
- return await options.runEphemeralTurn(prompt.render(btwUserPrompt, { question }), signal);
3589
+ const generation = initializedRuntime.policyGeneration;
3590
+ if (initializedRuntime.policySuspended) throw new Error("Notification policy is provisional.");
3591
+ const result = await options.runEphemeralTurn(prompt.render(btwUserPrompt, { question }), signal);
3592
+ if (
3593
+ initializedRuntime.policySuspended ||
3594
+ initializedRuntime.policyGeneration !== generation ||
3595
+ runtimes.get(id) !== initializedRuntime
3596
+ )
3597
+ throw new Error("Notification policy changed during the ephemeral turn.");
3598
+ return result;
3519
3599
  });
3520
3600
  initializedRuntime.abortEphemeralTurns = () => ephemeralTurns.dispose();
3521
3601
  initializedRuntime.disableEphemeralTurns = () => ephemeralTurns.disable();
@@ -3553,12 +3633,18 @@ export function createNotificationsExtension(
3553
3633
 
3554
3634
  server.onReply((err, reply) => {
3555
3635
  if (err || !reply) return;
3556
- if (runtime?.stopping || runtimes.get(id) !== runtime) {
3636
+ if (runtime?.stopping || runtime?.policySuspended || runtimes.get(id) !== runtime) {
3557
3637
  try {
3558
3638
  server.closeClaimInvalid(reply.replyReceiptId, "session_stopping");
3559
3639
  } catch {}
3560
3640
  return;
3561
3641
  }
3642
+ const replyGeneration = runtime.policyGeneration;
3643
+ const replyIsCurrent = (): boolean =>
3644
+ runtimes.get(id) === runtime &&
3645
+ !runtime.stopping &&
3646
+ !runtime.policySuspended &&
3647
+ runtime.policyGeneration === replyGeneration;
3562
3648
  const native = server as unknown as {
3563
3649
  resolveClaim(receiptId: string, answerJson?: string, idempotencyKey?: string): void;
3564
3650
  closeClaimInvalid(receiptId: string, reason: string): void;
@@ -3600,6 +3686,13 @@ export function createNotificationsExtension(
3600
3686
  settle(settlement: AskSettlement): Promise<AskSettlementResult> {
3601
3687
  if (settled) return settled;
3602
3688
  settled = Promise.resolve().then(async () => {
3689
+ if (!replyIsCurrent()) {
3690
+ try {
3691
+ native.closeClaimInvalid(reply.replyReceiptId, "policy_changed");
3692
+ } catch {}
3693
+ pending.fail(reply.id);
3694
+ return { kind: "invalid_closed" };
3695
+ }
3603
3696
  if (settlement.kind === "invalid") {
3604
3697
  try {
3605
3698
  native.closeClaimInvalid(reply.replyReceiptId, settlement.reason);
@@ -3626,6 +3719,11 @@ export function createNotificationsExtension(
3626
3719
  commitKey: `${reply.id}:${reply.idempotencyKey ?? reply.replyReceiptId}`,
3627
3720
  deadlineAt: Date.now() + 8_000,
3628
3721
  });
3722
+ if (!replyIsCurrent()) {
3723
+ native.closeClaimInvalid(reply.replyReceiptId, "policy_changed");
3724
+ pending.fail(reply.id);
3725
+ return { kind: "invalid_closed" };
3726
+ }
3629
3727
  native.resolveClaim(
3630
3728
  reply.replyReceiptId,
3631
3729
  reply.answerJson,
@@ -3711,24 +3809,33 @@ export function createNotificationsExtension(
3711
3809
  replyReceiptId: reply.replyReceiptId,
3712
3810
  answerJson: reply.answerJson,
3713
3811
  idempotencyKey: reply.idempotencyKey ?? undefined,
3714
- resolveClaim: () =>
3812
+ resolveClaim: () => {
3813
+ if (!replyIsCurrent()) {
3814
+ native.closeClaimInvalid(reply.replyReceiptId, "policy_changed");
3815
+ throw new NotificationGatePolicyChangedError();
3816
+ }
3715
3817
  native.resolveClaim(
3716
3818
  reply.replyReceiptId,
3717
3819
  reply.answerJson,
3718
3820
  reply.idempotencyKey ?? undefined,
3719
- ),
3821
+ );
3822
+ },
3720
3823
  closeClaimInvalid: reason => {
3721
3824
  native.closeClaimInvalid(reply.replyReceiptId, reason);
3722
3825
  gatePresentations.closeInteraction(reply.id, reason);
3723
3826
  gatePresentations.reconcile();
3724
3827
  },
3725
- requestSelectedAck: input =>
3726
- requestLiveSelectedAck(native, {
3828
+ requestSelectedAck: async input => {
3829
+ if (!replyIsCurrent()) throw new NotificationGatePolicyChangedError();
3830
+ const ack = await requestLiveSelectedAck(native, {
3727
3831
  replyReceiptId: input.replyReceiptId,
3728
3832
  actionId: input.actionId,
3729
3833
  commitKey: input.commitKey,
3730
3834
  deadlineAt: input.daemonDeadlineAt,
3731
- }),
3835
+ });
3836
+ if (!replyIsCurrent()) throw new NotificationGatePolicyChangedError();
3837
+ return ack;
3838
+ },
3732
3839
  },
3733
3840
  )
3734
3841
  .catch(() => {
@@ -3773,6 +3880,26 @@ export function createNotificationsExtension(
3773
3880
  messageId?: number;
3774
3881
  reason?: string;
3775
3882
  };
3883
+ const notificationOrigin = hostCapCache
3884
+ .get(authenticatedInbound.connectionId)
3885
+ ?.has(ASK_SELECTED_ACK_CAPABILITY);
3886
+ if (runtime?.policySuspended && notificationOrigin) {
3887
+ if (inbound.kind === "control_command") {
3888
+ const frame = sdkInboundFrame(inbound.commandJson);
3889
+ if (frame) {
3890
+ const suspendedRuntime = runtime;
3891
+ runtime.deferredInboundControls.push(() => {
3892
+ if (
3893
+ runtimes.get(id) === suspendedRuntime &&
3894
+ !suspendedRuntime.stopping &&
3895
+ !suspendedRuntime.policySuspended
3896
+ )
3897
+ inboundSdkFrame?.(`seam:${inbound.requestId ?? "notification"}`, frame);
3898
+ });
3899
+ }
3900
+ }
3901
+ return;
3902
+ }
3776
3903
  if (inbound.kind === "control_command") {
3777
3904
  const frame = sdkInboundFrame(inbound.commandJson);
3778
3905
  if (frame) {
@@ -3832,6 +3959,7 @@ export function createNotificationsExtension(
3832
3959
  }
3833
3960
  if (inbound.kind === "config_command") {
3834
3961
  if (!runtime) return;
3962
+ if (runtime.policySuspended) return;
3835
3963
  const update: {
3836
3964
  type: "config_update";
3837
3965
  sessionId: string;
@@ -3846,16 +3974,15 @@ export function createNotificationsExtension(
3846
3974
  update.verbosity = inbound.verbosity;
3847
3975
  }
3848
3976
  if (typeof inbound.redact === "boolean") {
3849
- // Redact turning ON: terminalize any already-visible in-flight tool
3850
- // bubbles (while redact is still off so the helper emits) so they never
3851
- // strand permanently in "started"; subsequent detail is then suppressed.
3852
- if (inbound.redact && !runtime.redact) {
3977
+ if (inbound.redact && !runtime.committedRedact) {
3853
3978
  terminalizeInFlightTools(runtime, runtime.id, "unknown");
3854
3979
  }
3980
+ runtime.committedRedact = inbound.redact;
3855
3981
  runtime.redact = inbound.redact;
3856
3982
  update.redact = inbound.redact;
3857
3983
  }
3858
3984
  if (update.verbosity !== undefined || update.redact !== undefined) {
3985
+ runtime.policyGeneration++;
3859
3986
  try {
3860
3987
  pushSessionFrame(runtime, update);
3861
3988
  } catch (error) {
@@ -3907,7 +4034,7 @@ export function createNotificationsExtension(
3907
4034
  }
3908
4035
  if (notificationsEnabledForSession && settingsAvailable && settings) {
3909
4036
  try {
3910
- if (!(await ensureConfiguredDaemons(settings, cfg, ctx.cwd, id))) {
4037
+ if (!(await ensureConfiguredDaemonOwners(settings, cfg, ctx.cwd, id))) {
3911
4038
  const result = failLifecycleStartup("failed", "Telegram daemon ownership is blocked.");
3912
4039
  finishStartup(result);
3913
4040
  await cleanupAbandonedStartup();
@@ -3921,10 +4048,9 @@ export function createNotificationsExtension(
3921
4048
  }
3922
4049
  }
3923
4050
 
3924
- // Startup contract: identity is committed to the replayable SDK event log
3925
- // immediately after the host starts, before awaiting notification transport
3926
- // startup. Native frames are ephemeral, so ensure configured daemon owners
3927
- // before broadcasting identity; late SDK consumers still recover it from
4051
+ // Startup contract: configured notification daemon ownership must be ready
4052
+ // before identity or endpoint publication. Native frames are ephemeral, so
4053
+ // publish identity only after readiness; late SDK consumers recover it from
3928
4054
  // event_replay.
3929
4055
  const identityHeader = {
3930
4056
  type: "identity_header",
@@ -4002,9 +4128,13 @@ export function createNotificationsExtension(
4002
4128
  gatePresentations,
4003
4129
  );
4004
4130
  runtime.disposeFileSink = registerTelegramFileSink(runtime.id, async file => {
4005
- if (runtime.redact) return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
4131
+ const generation = runtime.policyGeneration;
4132
+ if (!canDeliverAsync(runtime, generation)) return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
4006
4133
  try {
4007
- const data = await fs.promises.readFile(file.path);
4134
+ const data = await (options.readNotificationFile ?? fs.promises.readFile)(file.path);
4135
+ if (!canDeliverAsync(runtime, generation)) {
4136
+ return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
4137
+ }
4008
4138
  pushFileAttachment(
4009
4139
  runtime,
4010
4140
  {
@@ -4065,7 +4195,11 @@ export function createNotificationsExtension(
4065
4195
  logger.warn(`notifications: gate terminal controller unavailable: ${String(error)}`);
4066
4196
  }
4067
4197
  }
4068
- activeRuntime.disposeGateListener = gate.onGateEmitted(g => {
4198
+ const presentGate = (
4199
+ g: Parameters<NonNullable<WorkflowGateEmitter["onGateEmitted"]>>[0] extends (gate: infer Gate) => void
4200
+ ? Gate
4201
+ : never,
4202
+ ): void => {
4069
4203
  const options = (g.options ?? []).map(o => String((o as { label?: unknown }).label ?? ""));
4070
4204
  gateOptions.set(g.gate_id, options);
4071
4205
  const promptCtx = g.context as { prompt?: unknown; title?: unknown } | undefined;
@@ -4089,6 +4223,13 @@ export function createNotificationsExtension(
4089
4223
  navigationLabel: stageState.navigation_label === "Next" ? "Next" : "Done",
4090
4224
  selectedOptions: [],
4091
4225
  });
4226
+ };
4227
+ activeRuntime.disposeGateListener = gate.onGateEmitted(g => {
4228
+ if (activeRuntime.policySuspended) {
4229
+ activeRuntime.deferredGatePresentations.push(() => presentGate(g));
4230
+ return;
4231
+ }
4232
+ presentGate(g);
4092
4233
  });
4093
4234
  if (gate.setAckRecoveryParticipant) {
4094
4235
  const native = server as unknown as {
@@ -4097,13 +4238,19 @@ export function createNotificationsExtension(
4097
4238
  ): Promise<{ status: string; messageId?: number; reason?: string }>;
4098
4239
  };
4099
4240
  gate.setAckRecoveryParticipant({
4100
- requestRecoveredAskSelectedAck: input =>
4101
- requestRecoveredSelectedAck(native, {
4241
+ requestRecoveredAskSelectedAck: async input => {
4242
+ const generation = activeRuntime.policyGeneration;
4243
+ if (activeRuntime.policySuspended) return { status: "failed", reason: "cancelled" };
4244
+ const outcome = await requestRecoveredSelectedAck(native, {
4102
4245
  sessionId: input.sessionId,
4103
4246
  actionId: input.actionId,
4104
4247
  commitKey: input.commitKey,
4105
4248
  deadlineAt: input.deadlineAt,
4106
- }),
4249
+ });
4250
+ if (activeRuntime.policySuspended || activeRuntime.policyGeneration !== generation)
4251
+ return { status: "failed", reason: "cancelled" };
4252
+ return outcome;
4253
+ },
4107
4254
  });
4108
4255
  activeRuntime.disposeAckRecoveryParticipant = () => gate.setAckRecoveryParticipant?.(null);
4109
4256
  }
@@ -4113,13 +4260,13 @@ export function createNotificationsExtension(
4113
4260
  };
4114
4261
  activeRuntime.disposeGateEmitterListener = registerWorkflowGateEmitterListener(id, attachWorkflowGate);
4115
4262
  if (ctx.workflowGate) attachWorkflowGate(ctx.workflowGate);
4116
- if (notificationsEnabledForSession) initializedRuntime.enableNotifications();
4117
4263
  finishStartup({ status: "started", runtime: initializedRuntime });
4118
4264
  return { status: "started", runtime: initializedRuntime };
4119
4265
  } catch (e) {
4120
4266
  logger.warn(`notifications: failed to start server: ${String(e)}`);
4121
4267
  const result = failLifecycleStartup("failed", e);
4122
4268
  finishStartup(result);
4269
+ let suppressExtensionError = false;
4123
4270
  let stopped = false;
4124
4271
  try {
4125
4272
  stopped = await stopSession(id, "session", runtime);
@@ -4129,9 +4276,10 @@ export function createNotificationsExtension(
4129
4276
  // than letting it escape startSession and surface a red extension error
4130
4277
  // through session_start / session_switch / session_branch.
4131
4278
  logger.error(`notifications: SDK notification runtime cleanup failed: ${String(error)}`);
4279
+ suppressExtensionError = true;
4132
4280
  }
4133
4281
  if (!stopped) await cleanupAbandonedStartup();
4134
- return { ...result, runtime };
4282
+ return { ...result, runtime, suppressExtensionError };
4135
4283
  }
4136
4284
  }
4137
4285
 
@@ -4145,25 +4293,51 @@ export function createNotificationsExtension(
4145
4293
  if (!runtime || sessionId(binding.context) !== binding.sessionId || activeRuntimeId !== binding.sessionId) {
4146
4294
  return "failed";
4147
4295
  }
4148
- runtime.enableNotifications();
4149
4296
  return "started";
4150
4297
  }
4151
4298
  const runtime = runtimes.get(binding.sessionId);
4152
4299
  if (runtime) {
4153
- runtime.enableNotifications();
4154
4300
  return "started";
4155
4301
  }
4156
4302
  const result = await startSession(binding.context);
4157
4303
  return result.status === "started" || result.status === "already" ? "started" : result.status;
4158
4304
  },
4159
4305
  stop: async binding => await stopSession(binding.sessionId, "notifications"),
4306
+ refreshPolicy: (binding, policy) => {
4307
+ const runtime = runtimes.get(binding.sessionId);
4308
+ if (!runtime) return;
4309
+ if (policy.mode === "provisional") {
4310
+ runtime.policyGeneration++;
4311
+ runtime.policySuspended = true;
4312
+ runtime.redact = true;
4313
+ runtime.verbosity = "lean";
4314
+ runtime.stream = false;
4315
+ return;
4316
+ }
4317
+ const redactionEnabled = policy.redact && !runtime.committedRedact;
4318
+ runtime.policyGeneration++;
4319
+ runtime.committedRedact = policy.redact;
4320
+ runtime.policySuspended = false;
4321
+ runtime.redact = policy.redact;
4322
+ runtime.verbosity = policy.verbosity;
4323
+ runtime.stream = policy.stream;
4324
+ if (redactionEnabled) terminalizeInFlightTools(runtime, runtime.id, "unknown");
4325
+ },
4326
+ activate: binding => {
4327
+ const runtime = runtimes.get(binding.sessionId);
4328
+ if (!runtime || runtime.stopping) return;
4329
+ runtime.enableNotifications();
4330
+ flushPendingFinal(runtime, runtime.id);
4331
+ for (const present of runtime.deferredGatePresentations.splice(0)) present();
4332
+ for (const processControl of runtime.deferredInboundControls.splice(0)) processControl();
4333
+ },
4160
4334
  ensureTelegramDaemon: async binding => {
4161
4335
  const { settings, settingsAvailable } = resolveSettings(options.settings);
4162
4336
  if (!settingsAvailable || !settings) return "blocked_identity";
4163
4337
  try {
4164
4338
  return await ensureTelegramOwner(settings, binding.cwd, binding.sessionId);
4165
4339
  } catch {
4166
- return "blocked_identity";
4340
+ return "failed";
4167
4341
  }
4168
4342
  },
4169
4343
  };
@@ -4214,16 +4388,17 @@ export function createNotificationsExtension(
4214
4388
  const result = await controller.setLocalEnabled(ctx, true);
4215
4389
  const enabled = result.status.running && result.status.effectiveEnabled;
4216
4390
  const rotated = sessionId(ctx) !== id;
4391
+ if (rotated) await stopSession(id);
4217
4392
  const failed = result.outcome === "failed" || (!enabled && !rotated && activeRuntimeId !== id);
4218
4393
  ctx.ui.notify(
4219
- enabled
4220
- ? "Notifications enabled for this session."
4221
- : rotated
4222
- ? "Notifications were not enabled because the active session changed during startup."
4394
+ rotated
4395
+ ? "Notifications were not enabled because the active session changed during startup."
4396
+ : enabled
4397
+ ? "Notifications enabled for this session."
4223
4398
  : failed
4224
4399
  ? "Notifications failed to start for this session."
4225
4400
  : "Notifications were not enabled because daemon ownership could not be proved.",
4226
- enabled ? "info" : rotated ? "warning" : failed ? "error" : "warning",
4401
+ rotated ? "warning" : enabled ? "info" : failed ? "error" : "warning",
4227
4402
  );
4228
4403
  return;
4229
4404
  }
@@ -4242,22 +4417,93 @@ export function createNotificationsExtension(
4242
4417
  },
4243
4418
  });
4244
4419
 
4420
+ const startAndReconcileSession = async (ctx: ExtensionContext): Promise<void> => {
4421
+ const result = await startSession(ctx);
4422
+ if (result.status === "started" || result.status === "already") {
4423
+ await controller.reconcileCurrentSession(ctx);
4424
+ return;
4425
+ }
4426
+ if (
4427
+ !lifecycleStartupCapability &&
4428
+ result.status === "failed" &&
4429
+ !extensionShuttingDown &&
4430
+ !result.suppressExtensionError
4431
+ )
4432
+ throw new Error(`notifications: SDK startup failed: ${result.failure?.message ?? "Unknown startup failure."}`);
4433
+ };
4434
+
4245
4435
  api.on("session_start", async (_event, ctx) => {
4246
- await startSession(ctx);
4247
- await controller.reconcileCurrentSession(ctx);
4436
+ await startAndReconcileSession(ctx);
4248
4437
  });
4249
4438
 
4250
4439
  // A session endpoint's token and generation are authority for exactly one
4251
4440
  // session id. `/new`, fork, and resume must all tear down A before publishing
4252
4441
  // B. Chat implementations may preserve a topic as metadata, but it must never
4253
4442
  // preserve A's endpoint or credentials as B's control/viewing authority.
4443
+ const reconcileBackgroundStartup = (
4444
+ id: string,
4445
+ ctx: ExtensionContext,
4446
+ startup: Promise<SessionStartResult>,
4447
+ ): Promise<void> =>
4448
+ startup
4449
+ .then(async result => {
4450
+ if (
4451
+ result.status !== "started" ||
4452
+ extensionShuttingDown ||
4453
+ sessionId(ctx) !== id ||
4454
+ activeRuntimeId !== id ||
4455
+ !runtimes.has(id)
4456
+ )
4457
+ return;
4458
+ await controller.reconcileCurrentSession(ctx);
4459
+ })
4460
+ .catch(error => logger.warn(`notifications: deferred startup reconciliation failed: ${String(error)}`));
4461
+
4462
+ const trackBranchStartup = (id: string, ctx: ExtensionContext, startup: Promise<SessionStartResult>): void => {
4463
+ let status: SessionStartResult["status"] = "failed";
4464
+ void startup.then(
4465
+ result => {
4466
+ status = result.status;
4467
+ },
4468
+ () => {},
4469
+ );
4470
+ const task = reconcileBackgroundStartup(id, ctx, startup);
4471
+ branchStartupTasks.add(task);
4472
+ void task.finally(() => {
4473
+ branchStartupTasks.delete(task);
4474
+ try {
4475
+ options.onBranchStartupSettled?.({ sessionId: id, status });
4476
+ } catch (error) {
4477
+ logger.warn(`notifications: branch startup receipt failed: ${String(error)}`);
4478
+ }
4479
+ });
4480
+ };
4481
+
4254
4482
  const rotateSessionAuthority = async (
4255
4483
  event: { previousSessionFile?: string },
4256
4484
  ctx: ExtensionContext,
4485
+ awaitStartup: boolean,
4257
4486
  ): Promise<void> => {
4258
4487
  if (extensionShuttingDown) return;
4259
4488
  const newId = sessionId(ctx);
4260
4489
  const prevId = activeRuntimeId ?? sessionIdFromFile(event.previousSessionFile);
4490
+ if (prevId === newId) {
4491
+ const pendingStartup = sessionStartPromises.get(newId);
4492
+ if (pendingStartup) {
4493
+ if (awaitStartup) {
4494
+ await pendingStartup;
4495
+ if (!extensionShuttingDown && runtimes.has(newId) && activeRuntimeId === newId)
4496
+ await controller.reconcileCurrentSession(ctx);
4497
+ } else {
4498
+ trackBranchStartup(newId, ctx, pendingStartup);
4499
+ }
4500
+ return;
4501
+ }
4502
+ if (runtimes.has(newId)) {
4503
+ await controller.reconcileCurrentSession(ctx);
4504
+ return;
4505
+ }
4506
+ }
4261
4507
  if (prevId && prevId !== newId) {
4262
4508
  controller.rekeySession(prevId, newId);
4263
4509
  try {
@@ -4271,26 +4517,35 @@ export function createNotificationsExtension(
4271
4517
  }
4272
4518
  }
4273
4519
  if (extensionShuttingDown) return;
4274
- await startSession(ctx);
4275
- if (extensionShuttingDown) {
4276
- await stopSession(newId);
4520
+ const startup = startSession(ctx);
4521
+ if (awaitStartup) {
4522
+ const result = await startup;
4523
+ if (!lifecycleStartupCapability && result.status === "failed")
4524
+ throw new Error(
4525
+ `notifications: SDK startup failed: ${result.failure?.message ?? "Unknown startup failure."}`,
4526
+ );
4527
+ if (extensionShuttingDown) {
4528
+ await stopSession(newId);
4529
+ return;
4530
+ }
4531
+ await controller.reconcileCurrentSession(ctx);
4277
4532
  return;
4278
4533
  }
4279
- await controller.reconcileCurrentSession(ctx);
4534
+ trackBranchStartup(newId, ctx, startup);
4280
4535
  };
4281
4536
  api.on("session_switch", async (event, ctx) => {
4282
4537
  if (identityControlInFlight) {
4283
- deferredIdentityRotation = { event, ctx };
4538
+ deferredIdentityRotation = { event, ctx, awaitStartup: true };
4284
4539
  return;
4285
4540
  }
4286
- await rotateSessionAuthority(event, ctx);
4541
+ await rotateSessionAuthority(event, ctx, true);
4287
4542
  });
4288
4543
  api.on("session_branch", async (event, ctx) => {
4289
4544
  if (identityControlInFlight) {
4290
- deferredIdentityRotation = { event, ctx };
4545
+ deferredIdentityRotation = { event, ctx, awaitStartup: false };
4291
4546
  return;
4292
4547
  }
4293
- await rotateSessionAuthority(event, ctx);
4548
+ await rotateSessionAuthority(event, ctx, false);
4294
4549
  });
4295
4550
 
4296
4551
  const terminalizeInFlightTools = (rt: SessionRuntime, id: string, phase: "cancelled" | "unknown"): void => {
@@ -4306,6 +4561,36 @@ export function createNotificationsExtension(
4306
4561
  rt.inFlightTools.clear();
4307
4562
  };
4308
4563
 
4564
+ const resetTurnStreamState = (rt: SessionRuntime): void => {
4565
+ rt.currentTurnText = undefined;
4566
+ rt.preAskFlushedText = undefined;
4567
+ rt.liveRef = undefined;
4568
+ rt.turnClosed = true;
4569
+ rt.lastLiveAt = undefined;
4570
+ rt.lastLiveText = undefined;
4571
+ };
4572
+
4573
+ const flushPendingFinal = (rt: SessionRuntime, id: string): void => {
4574
+ const pending = rt.pendingFinal;
4575
+ if (!pending) return;
4576
+ rt.pendingFinal = undefined;
4577
+ if (pending.text && rt.notificationsActive && !rt.redact) {
4578
+ try {
4579
+ pushSessionFrame(rt, {
4580
+ type: "turn_stream",
4581
+ sessionId: id,
4582
+ phase: "finalized",
4583
+ finalAnswer: true,
4584
+ text: pending.text,
4585
+ ...(pending.messageRef ? { messageRef: pending.messageRef } : {}),
4586
+ });
4587
+ } catch (error) {
4588
+ logger.warn(`notifications: pushFrame (pending turn) failed: ${String(error)}`);
4589
+ }
4590
+ }
4591
+ resetTurnStreamState(rt);
4592
+ };
4593
+
4309
4594
  // Drive the live typing indicator: mark busy when the agent loop starts so
4310
4595
  // the daemon shows "typing…" in the thread while the agent is thinking,
4311
4596
  // before any turn output exists. Cleared on `agent_end` below.
@@ -4417,7 +4702,9 @@ export function createNotificationsExtension(
4417
4702
  const model = (ctx as { getModel?: () => { id?: string } | undefined }).getModel?.();
4418
4703
  const tokenUsage = usage && usage.tokens != null ? `${usage.tokens}/${usage.contextWindow}` : undefined;
4419
4704
  const modelId = model?.id;
4420
- void readGitDiffStat(ctx.cwd).then(diff => {
4705
+ const generation = rt.policyGeneration;
4706
+ void (options.readNotificationDiffStat ?? readGitDiffStat)(ctx.cwd).then(diff => {
4707
+ if (!canDeliverAsync(rt, generation)) return;
4421
4708
  const cwd = compactCwd(ctx.cwd);
4422
4709
  if (!diff && !tokenUsage && !modelId && !cwd) return;
4423
4710
  try {
@@ -4445,7 +4732,7 @@ export function createNotificationsExtension(
4445
4732
  // Push the in-flight turn's assistant text as a finalized turn_stream, deduped
4446
4733
  // against what was already flushed for this turn (the pre-ask lead-in).
4447
4734
  const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined, finalAnswer: boolean): void => {
4448
- if (!text || text === rt.preAskFlushedText || !rt.notificationsActive) return;
4735
+ if (!text || text === rt.preAskFlushedText || !rt.notificationsActive || rt.policySuspended) return;
4449
4736
  rt.preAskFlushedText = text;
4450
4737
  // Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
4451
4738
  // live message, never a fresh (rich-promotable) send. If live frames were
@@ -4575,18 +4862,20 @@ export function createNotificationsExtension(
4575
4862
  const id = sessionId(ctx);
4576
4863
  const rt = runtimes.get(id);
4577
4864
  if (!rt?.notificationsActive) return;
4578
- const text = rt.redact ? undefined : summaryFromMessage(event.message, turnTextMax());
4865
+ const text = rt.policySuspended
4866
+ ? rt.committedRedact
4867
+ ? undefined
4868
+ : summaryFromMessage(event.message, turnTextMax())
4869
+ : rt.redact
4870
+ ? undefined
4871
+ : summaryFromMessage(event.message, turnTextMax());
4872
+ if (rt.policySuspended) {
4873
+ rt.pendingFinal = { text, messageRef: rt.liveRef };
4874
+ rt.turnClosed = true;
4875
+ return;
4876
+ }
4579
4877
  if (text) flushTurnText(rt, id, text, true);
4580
- // Reset per-turn streaming state so the next turn starts fresh and a later
4581
- // turn with identical text is not falsely deduped.
4582
- rt.currentTurnText = undefined;
4583
- rt.preAskFlushedText = undefined;
4584
- rt.liveRef = undefined;
4585
- // Close the live-stream window: any message_update queued after turn_end is
4586
- // dropped so it can never emit a stale live edit past the finalized turn.
4587
- rt.turnClosed = true;
4588
- rt.lastLiveAt = undefined;
4589
- rt.lastLiveText = undefined;
4878
+ resetTurnStreamState(rt);
4590
4879
  });
4591
4880
 
4592
4881
  // Live streaming (opt-in): push throttled in-progress assistant text as
@@ -4596,6 +4885,7 @@ export function createNotificationsExtension(
4596
4885
  api.on("message_update", (event, ctx) => {
4597
4886
  const id = sessionId(ctx);
4598
4887
  const rt = runtimes.get(id);
4888
+ rt?.emitPromptEvent(event);
4599
4889
  if (!rt?.notificationsActive || !rt.stream || rt.redact || rt.turnClosed) return;
4600
4890
  if ((event.message as { role?: unknown }).role !== "assistant") return;
4601
4891
  if (rt.liveRef === undefined && rt.turnSeq !== undefined) {
@@ -4619,6 +4909,7 @@ export function createNotificationsExtension(
4619
4909
  api.on("message_end", (event, ctx) => {
4620
4910
  const id = sessionId(ctx);
4621
4911
  const rt = runtimes.get(id);
4912
+ rt?.emitPromptEvent(event);
4622
4913
  if (!rt?.notificationsActive || rt.redact) return;
4623
4914
  // Capture the in-flight ASSISTANT text here (message_end is on the awaited
4624
4915
  // extension path and ordered before tool_execution_start) so the pre-ask
@@ -4647,6 +4938,7 @@ export function createNotificationsExtension(
4647
4938
  extensionShuttingDown = true;
4648
4939
  identityControlInFlight = false;
4649
4940
  deferredIdentityRotation = undefined;
4941
+ await Promise.allSettled([...branchStartupTasks]);
4650
4942
  const id = sessionId(ctx);
4651
4943
  const rt = runtimes.get(id);
4652
4944
  if (rt) terminalizeInFlightTools(rt, id, "unknown");