@gajae-code/coding-agent 0.11.4 → 0.11.6

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 (154) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/cli/notify-cli.d.ts +5 -1
  3. package/dist/types/config/settings-schema.d.ts +31 -1
  4. package/dist/types/export/html/template.generated.d.ts +1 -1
  5. package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
  6. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
  7. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
  8. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
  9. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
  10. package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  12. package/dist/types/harness-control-plane/owner.d.ts +3 -1
  13. package/dist/types/internal-urls/local-protocol.d.ts +18 -0
  14. package/dist/types/main.d.ts +2 -2
  15. package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
  16. package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
  17. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
  18. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  19. package/dist/types/modes/components/tool-transcript-format.d.ts +58 -0
  20. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
  21. package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
  22. package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
  23. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
  24. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
  25. package/dist/types/plan-mode/approved-plan.d.ts +1 -0
  26. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  27. package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
  28. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
  29. package/dist/types/sdk/bus/chat-daemon-control.d.ts +42 -4
  30. package/dist/types/sdk/bus/config-commands.d.ts +8 -0
  31. package/dist/types/sdk/bus/config.d.ts +22 -0
  32. package/dist/types/sdk/bus/index.d.ts +162 -2
  33. package/dist/types/sdk/bus/notification-service.d.ts +71 -8
  34. package/dist/types/sdk/bus/session-control.d.ts +11 -8
  35. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
  36. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -3
  37. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
  38. package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
  39. package/dist/types/sdk/session.d.ts +16 -1
  40. package/dist/types/secrets/index.d.ts +4 -2
  41. package/dist/types/secrets/obfuscator.d.ts +1 -1
  42. package/dist/types/session/agent-session.d.ts +31 -2
  43. package/dist/types/session/artifacts.d.ts +30 -8
  44. package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
  45. package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
  46. package/dist/types/session/session-manager.d.ts +98 -18
  47. package/dist/types/session/session-storage.d.ts +27 -0
  48. package/dist/types/session/streaming-output.d.ts +4 -0
  49. package/dist/types/session/yield-queue.d.ts +8 -0
  50. package/dist/types/task/executor.d.ts +9 -0
  51. package/dist/types/tool-discovery/tool-index.d.ts +2 -0
  52. package/dist/types/tools/ask.d.ts +48 -0
  53. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  54. package/dist/types/tools/index.d.ts +4 -0
  55. package/package.json +9 -9
  56. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  57. package/src/cli/notify-cli.ts +40 -18
  58. package/src/commands/sdk.ts +7 -2
  59. package/src/config/settings-schema.ts +31 -1
  60. package/src/daemon/runtime.ts +2 -2
  61. package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
  62. package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
  63. package/src/defaults/gjc/skills/team/SKILL.md +7 -9
  64. package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
  65. package/src/eval/py/index.ts +2 -2
  66. package/src/export/html/template.generated.ts +1 -1
  67. package/src/extensibility/plugins/marketplace/manager.ts +94 -10
  68. package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
  69. package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
  70. package/src/gjc-runtime/deep-interview-state.ts +127 -3
  71. package/src/gjc-runtime/launch-tmux.ts +61 -6
  72. package/src/gjc-runtime/managed-owner-admission.ts +352 -0
  73. package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
  74. package/src/gjc-runtime/state-runtime.ts +38 -12
  75. package/src/gjc-runtime/team-runtime.ts +1 -1
  76. package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
  77. package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
  78. package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
  79. package/src/harness-control-plane/owner.ts +59 -3
  80. package/src/hindsight/client.ts +65 -2
  81. package/src/internal-urls/agent-protocol.ts +76 -5
  82. package/src/internal-urls/docs-index.generated.ts +8 -7
  83. package/src/internal-urls/local-protocol.ts +416 -18
  84. package/src/lsp/index.ts +4 -88
  85. package/src/main.ts +36 -12
  86. package/src/modes/acp/acp-agent.ts +8 -0
  87. package/src/modes/components/ansi-display-validator.ts +54 -0
  88. package/src/modes/components/notifications-settings-editor.ts +26 -2
  89. package/src/modes/components/session-observer-overlay.ts +40 -24
  90. package/src/modes/components/settings-selector.ts +3 -1
  91. package/src/modes/components/tool-transcript-format.ts +321 -0
  92. package/src/modes/components/transcript-viewer-overlay.ts +39 -17
  93. package/src/modes/controllers/command-controller.ts +15 -0
  94. package/src/modes/controllers/extension-ui-controller.ts +14 -2
  95. package/src/modes/controllers/goal-mode-controller.ts +20 -1
  96. package/src/modes/controllers/plan-mode-controller.ts +2 -0
  97. package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
  98. package/src/modes/controllers/selector-controller.ts +70 -8
  99. package/src/modes/interactive-mode.ts +1 -0
  100. package/src/modes/runtime-init.ts +16 -2
  101. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
  102. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
  103. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
  104. package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
  105. package/src/plan-mode/approved-plan.ts +3 -1
  106. package/src/prompts/agents/architect.md +1 -0
  107. package/src/prompts/agents/critic.md +1 -0
  108. package/src/prompts/system/custom-system-prompt.md +1 -1
  109. package/src/prompts/system/system-prompt.md +2 -1
  110. package/src/prompts/tools/lsp.md +3 -3
  111. package/src/runtime-mcp/manager.ts +16 -1
  112. package/src/runtime-mcp/oauth-discovery.ts +116 -26
  113. package/src/sdk/broker/session-index.ts +122 -64
  114. package/src/sdk/bus/chat-daemon-cli.ts +72 -10
  115. package/src/sdk/bus/chat-daemon-control.ts +538 -130
  116. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  117. package/src/sdk/bus/config-commands.ts +22 -0
  118. package/src/sdk/bus/config.ts +37 -0
  119. package/src/sdk/bus/index.ts +388 -94
  120. package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
  121. package/src/sdk/bus/notification-service.ts +284 -62
  122. package/src/sdk/bus/recent-activity.ts +14 -5
  123. package/src/sdk/bus/session-control.ts +150 -44
  124. package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
  125. package/src/sdk/bus/telegram-daemon-contract.ts +11 -3
  126. package/src/sdk/bus/telegram-daemon-control.ts +312 -76
  127. package/src/sdk/bus/telegram-daemon.ts +1916 -285
  128. package/src/sdk/host/control/dispatch.ts +14 -5
  129. package/src/sdk/protocol/operation-inventory.generated.json +29 -0
  130. package/src/sdk/session.ts +155 -27
  131. package/src/secrets/index.ts +53 -10
  132. package/src/secrets/obfuscator.ts +21 -18
  133. package/src/secrets/regex.ts +2 -0
  134. package/src/session/agent-session.ts +1447 -852
  135. package/src/session/artifacts.ts +188 -23
  136. package/src/session/context-estimation.ts +7 -2
  137. package/src/session/internal/managed-session-scope.ts +200 -38
  138. package/src/session/internal/managed-session-storage.ts +1152 -46
  139. package/src/session/session-manager.ts +1121 -148
  140. package/src/session/session-storage.ts +109 -11
  141. package/src/session/streaming-output.ts +6 -0
  142. package/src/session/yield-queue.ts +20 -0
  143. package/src/slash-commands/builtin-registry.ts +36 -0
  144. package/src/task/executor.ts +71 -26
  145. package/src/task/index.ts +63 -20
  146. package/src/tool-discovery/tool-index.ts +18 -0
  147. package/src/tools/ask-answer-registry.ts +15 -1
  148. package/src/tools/ask.ts +129 -32
  149. package/src/tools/bash-skill-urls.ts +14 -3
  150. package/src/tools/bash.ts +2 -2
  151. package/src/tools/index.ts +4 -0
  152. package/src/tools/plan-mode-guard.ts +1 -0
  153. package/src/tools/sqlite-reader.ts +3 -2
  154. package/src/tools/subagent.ts +2 -0
@@ -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
  }
@@ -1912,8 +1926,10 @@ function sdkControlSurface(
1912
1926
  }
1913
1927
  return "unknown";
1914
1928
  };
1915
- const sendSteer = (text: string) => {
1916
- api.sendUserMessage(text, { deliverAs: "steer" });
1929
+ const sendSteer = async (text: string) => {
1930
+ // Await admission so a rejection (e.g. handoff in progress) surfaces as a
1931
+ // control error instead of a false `accepted: true`.
1932
+ await api.sendUserMessage(text, { deliverAs: "steer" });
1917
1933
  return { commandId: crypto.randomUUID(), accepted: true };
1918
1934
  };
1919
1935
  const resolveModel = (id: string) => {
@@ -2728,6 +2744,22 @@ function sdkInboundFrame(commandJson: string | undefined): Record<string, unknow
2728
2744
  return undefined;
2729
2745
  }
2730
2746
  }
2747
+ /**
2748
+ * Ensures every configured chat-provider daemon is ready before the SDK
2749
+ * publishes session identity. A rejected ensure is startup-fatal: presenting
2750
+ * an identity for a transport that never became available is false success.
2751
+ */
2752
+ export async function ensureConfiguredProviderDaemons(
2753
+ settings: Settings,
2754
+ cfg: NotificationConfig,
2755
+ ensureProviderDaemon: (provider: "discord" | "slack", settings: Settings) => Promise<unknown> = (
2756
+ provider,
2757
+ configuredSettings,
2758
+ ) => (provider === "discord" ? ensureDiscordDaemon(configuredSettings) : ensureSlackDaemon(configuredSettings)),
2759
+ ): Promise<void> {
2760
+ if (isDiscordConfigured(cfg)) await ensureProviderDaemon("discord", settings);
2761
+ if (isSlackConfigured(cfg)) await ensureProviderDaemon("slack", settings);
2762
+ }
2731
2763
 
2732
2764
  export function createNotificationsExtension(
2733
2765
  api: ExtensionAPI,
@@ -2738,6 +2770,7 @@ export function createNotificationsExtension(
2738
2770
  cwd: string;
2739
2771
  sessionId: string;
2740
2772
  }) => Promise<EnsureDaemonResult>;
2773
+ ensureProviderDaemon?: (provider: "discord" | "slack", settings: Settings) => Promise<unknown>;
2741
2774
  /** Suppress auto-delivery for a GJC-spawned child under `sessionScope=primary`. */
2742
2775
  spawnedByGjc?: boolean;
2743
2776
  controller?: NotificationSessionController;
@@ -2746,6 +2779,10 @@ export function createNotificationsExtension(
2746
2779
 
2747
2780
  onSdkRequest?: (kind: "control" | "query", connectionId: string, frame: Record<string, unknown>) => void;
2748
2781
  runEphemeralTurn?: (promptText: string, signal: AbortSignal) => Promise<{ replyText: string }>;
2782
+ /** Observes settlement of optional session-branch startup after reconciliation completes. */
2783
+ onBranchStartupSettled?: (receipt: { sessionId: string; status: SessionStartResult["status"] }) => void;
2784
+ readNotificationFile?: (path: string) => Promise<Buffer>;
2785
+ readNotificationDiffStat?: (cwd: string) => Promise<string | undefined>;
2749
2786
  } = {},
2750
2787
  ): void {
2751
2788
  const lifecycleStartupCapability = lifecycleStartupCapabilityForApi(api);
@@ -2761,9 +2798,12 @@ export function createNotificationsExtension(
2761
2798
  // exact runtime object retains authority for an explicit idempotent retry.
2762
2799
  const cleanupRetries = new Map<string, SessionRuntime>();
2763
2800
  const sessionStartPromises = new Map<string, Promise<SessionStartResult>>();
2801
+ const branchStartupTasks = new Set<Promise<void>>();
2764
2802
  let activeRuntimeId: string | undefined;
2765
2803
  let identityControlInFlight = false;
2766
- let deferredIdentityRotation: { event: { previousSessionFile?: string }; ctx: ExtensionContext } | undefined;
2804
+ let deferredIdentityRotation:
2805
+ | { event: { previousSessionFile?: string }; ctx: ExtensionContext; awaitStartup: boolean }
2806
+ | undefined;
2767
2807
  let extensionShuttingDown = false;
2768
2808
 
2769
2809
  async function ensureTelegramOwner(
@@ -2780,7 +2820,7 @@ export function createNotificationsExtension(
2780
2820
  ? "blocked_identity"
2781
2821
  : "ready";
2782
2822
  }
2783
- async function ensureConfiguredDaemons(
2823
+ async function ensureConfiguredDaemonOwners(
2784
2824
  settings: Settings,
2785
2825
  cfg: NotificationConfig,
2786
2826
  cwd: string,
@@ -2789,8 +2829,7 @@ export function createNotificationsExtension(
2789
2829
  if (isTelegramConfigured(cfg)) {
2790
2830
  if ((await ensureTelegramOwner(settings, cwd, id)) === "blocked_identity") return false;
2791
2831
  }
2792
- if (isDiscordConfigured(cfg)) await ensureDiscordDaemon(settings);
2793
- if (isSlackConfigured(cfg)) await ensureSlackDaemon(settings);
2832
+ await ensureConfiguredProviderDaemons(settings, cfg, options.ensureProviderDaemon);
2794
2833
  return true;
2795
2834
  }
2796
2835
  const identityControlOperations = new Set([
@@ -2953,6 +2992,16 @@ export function createNotificationsExtension(
2953
2992
  return ctx.sessionMetadata?.kind !== "sub";
2954
2993
  }
2955
2994
 
2995
+ function canDeliverAsync(runtime: SessionRuntime, generation: number): boolean {
2996
+ return (
2997
+ runtimes.get(runtime.id) === runtime &&
2998
+ !runtime.stopping &&
2999
+ runtime.notificationsActive &&
3000
+ !runtime.redact &&
3001
+ runtime.policyGeneration === generation
3002
+ );
3003
+ }
3004
+
2956
3005
  async function startSession(ctx: ExtensionContext): Promise<SessionStartResult> {
2957
3006
  const id = sessionId(ctx);
2958
3007
  const lifecycleRequestId = safeLifecycleRequestId(process.env.GJC_LIFECYCLE_REQUEST_ID);
@@ -3006,8 +3055,6 @@ export function createNotificationsExtension(
3006
3055
  const pendingInteractive = new Map<string, PendingInteractiveAsk>();
3007
3056
  const pendingPromptCorrelations: Array<{ commandId: string; turnId: string }> = [];
3008
3057
  const tag = sessionTag(id);
3009
- const redact = cfg.redact;
3010
- const verbosity = cfg.verbosity;
3011
3058
  let runtime: SessionRuntime | undefined;
3012
3059
 
3013
3060
  // The SDK can always answer now (interactive via the answer source, or the
@@ -3027,7 +3074,7 @@ export function createNotificationsExtension(
3027
3074
  if (lifecycleRequired) return failLifecycleStartup("failed", error);
3028
3075
  throw error;
3029
3076
  }
3030
- const gatePresentations = new PresentationArbiter(server, () => runtime?.redact ?? redact, tag);
3077
+ const gatePresentations = new PresentationArbiter(server, () => runtime?.redact ?? true, tag);
3031
3078
  let inboundSdkFrame: ((connectionId: string, frame: Record<string, unknown>) => void) | undefined;
3032
3079
  const inFlightGateResolutions = new Set<Promise<void>>();
3033
3080
  const trackGateResolution = <T>(resolution: Promise<T>): Promise<T> => {
@@ -3097,7 +3144,7 @@ export function createNotificationsExtension(
3097
3144
  error: unknown;
3098
3145
  terminal: boolean;
3099
3146
  createdAt: number;
3100
- bufferedLifecycle: PromptLifecycleFrame[];
3147
+ bufferedFrames: Array<PromptLifecycleFrame | Record<string, unknown>>;
3101
3148
  };
3102
3149
  const promptSubmissions = new Map<string, PromptSubmission>();
3103
3150
  const promptTerminalTombstones = new Map<string, number>();
@@ -3129,7 +3176,7 @@ export function createNotificationsExtension(
3129
3176
  };
3130
3177
  const abandonPrompt = (submission: PromptSubmission) => {
3131
3178
  submission.abandoned = true;
3132
- submission.bufferedLifecycle.length = 0;
3179
+ submission.bufferedFrames.length = 0;
3133
3180
  };
3134
3181
  const emitPromptLifecycle = (
3135
3182
  correlation: { commandId: string; turnId: string } | undefined,
@@ -3149,7 +3196,7 @@ export function createNotificationsExtension(
3149
3196
  return;
3150
3197
  }
3151
3198
  if (!submission.acknowledged) {
3152
- submission.bufferedLifecycle.push(frame);
3199
+ submission.bufferedFrames.push(frame);
3153
3200
  return;
3154
3201
  }
3155
3202
  try {
@@ -3160,8 +3207,31 @@ export function createNotificationsExtension(
3160
3207
  }
3161
3208
  if (submission.terminal) finalizePrompt(key, correlation);
3162
3209
  };
3210
+ const emitPromptEvent = (event: AgentSessionEvent) => {
3211
+ if (!runtime?.activePromptCorrelation) return;
3212
+ cleanupPromptRecords();
3213
+ const correlation = runtime.activePromptCorrelation;
3214
+ const submission = promptSubmissions.get(promptSubmissionKey(correlation));
3215
+ if (!submission || submission.abandoned) return;
3216
+ const frame = {
3217
+ type: "event",
3218
+ kind: event.type,
3219
+ payload: toAgentWireEventPayload(event),
3220
+ ...correlation,
3221
+ };
3222
+ if (!submission.acknowledged) {
3223
+ submission.bufferedFrames.push(frame);
3224
+ return;
3225
+ }
3226
+ try {
3227
+ runtime.server.sendTo(submission.connectionId, JSON.stringify(frame));
3228
+ } catch (error) {
3229
+ logger.warn(`sdk: correlated agent event delivery failed: ${String(error)}`);
3230
+ abandonPrompt(submission);
3231
+ }
3232
+ };
3163
3233
  const flushPromptLifecycle = (key: string, submission: PromptSubmission) => {
3164
- for (const frame of submission.bufferedLifecycle.splice(0)) {
3234
+ for (const frame of submission.bufferedFrames.splice(0)) {
3165
3235
  try {
3166
3236
  server.sendTo(submission.connectionId, JSON.stringify(frame));
3167
3237
  } catch (error) {
@@ -3197,7 +3267,7 @@ export function createNotificationsExtension(
3197
3267
  error: undefined,
3198
3268
  terminal: false,
3199
3269
  createdAt: Date.now(),
3200
- bufferedLifecycle: [],
3270
+ bufferedFrames: [],
3201
3271
  });
3202
3272
  };
3203
3273
  const recordPromptTerminal = (correlation: { commandId: string; turnId: string } | undefined) => {
@@ -3371,7 +3441,8 @@ export function createNotificationsExtension(
3371
3441
  const pending = deferredIdentityRotation;
3372
3442
  deferredIdentityRotation = undefined;
3373
3443
  identityControlInFlight = false;
3374
- if (response.ok === true && pending) await rotateSessionAuthority(pending.event, pending.ctx);
3444
+ if (response.ok === true && pending)
3445
+ await rotateSessionAuthority(pending.event, pending.ctx, pending.awaitStartup);
3375
3446
  }
3376
3447
  },
3377
3448
  control: async (connectionId, frame) => {
@@ -3466,17 +3537,23 @@ export function createNotificationsExtension(
3466
3537
  disableEphemeralTurns: () => {},
3467
3538
  cancelPostmortemCleanup: () => {},
3468
3539
 
3469
- redact,
3470
- verbosity,
3471
- stream: streamingEnabled(),
3540
+ redact: true,
3541
+ committedRedact: true,
3542
+ policySuspended: true,
3543
+ verbosity: "lean",
3544
+ stream: false,
3545
+ policyGeneration: 0,
3472
3546
  sessionTag: tag,
3473
3547
  busy: false,
3474
3548
  pendingPromptCorrelations,
3475
3549
  activePromptCorrelation: undefined,
3476
3550
  recordPromptTerminal,
3477
3551
  emitPromptLifecycle,
3552
+ emitPromptEvent,
3478
3553
  pendingInbound: new Set<number>(),
3479
3554
  inFlightTools: new Map<string, { toolName: string; args: unknown }>(),
3555
+ deferredGatePresentations: [],
3556
+ deferredInboundControls: [],
3480
3557
  };
3481
3558
  const initializedRuntime = runtime;
3482
3559
  runtimes.set(id, initializedRuntime);
@@ -3501,21 +3578,26 @@ export function createNotificationsExtension(
3501
3578
  };
3502
3579
  const cleanupAbandonedStartup = async (): Promise<void> => {
3503
3580
  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 {}
3581
+ await stopSession(id, "session", initializedRuntime);
3582
+ } catch (error) {
3583
+ // stopSession fences the exact runtime before releasing its owners and records
3584
+ // the lifecycle rollback proof even when one release needs a later retry.
3585
+ logger.error(`notifications: SDK notification runtime cleanup failed: ${String(error)}`);
3586
+ }
3514
3587
  };
3515
3588
 
3516
3589
  const ephemeralTurns = new EphemeralTurnHost(sendSdkFrame, async (question, signal) => {
3517
3590
  if (!options.runEphemeralTurn) throw new Error("Ephemeral turns are unavailable.");
3518
- return await options.runEphemeralTurn(prompt.render(btwUserPrompt, { question }), signal);
3591
+ const generation = initializedRuntime.policyGeneration;
3592
+ if (initializedRuntime.policySuspended) throw new Error("Notification policy is provisional.");
3593
+ const result = await options.runEphemeralTurn(prompt.render(btwUserPrompt, { question }), signal);
3594
+ if (
3595
+ initializedRuntime.policySuspended ||
3596
+ initializedRuntime.policyGeneration !== generation ||
3597
+ runtimes.get(id) !== initializedRuntime
3598
+ )
3599
+ throw new Error("Notification policy changed during the ephemeral turn.");
3600
+ return result;
3519
3601
  });
3520
3602
  initializedRuntime.abortEphemeralTurns = () => ephemeralTurns.dispose();
3521
3603
  initializedRuntime.disableEphemeralTurns = () => ephemeralTurns.disable();
@@ -3553,12 +3635,18 @@ export function createNotificationsExtension(
3553
3635
 
3554
3636
  server.onReply((err, reply) => {
3555
3637
  if (err || !reply) return;
3556
- if (runtime?.stopping || runtimes.get(id) !== runtime) {
3638
+ if (runtime?.stopping || runtime?.policySuspended || runtimes.get(id) !== runtime) {
3557
3639
  try {
3558
3640
  server.closeClaimInvalid(reply.replyReceiptId, "session_stopping");
3559
3641
  } catch {}
3560
3642
  return;
3561
3643
  }
3644
+ const replyGeneration = runtime.policyGeneration;
3645
+ const replyIsCurrent = (): boolean =>
3646
+ runtimes.get(id) === runtime &&
3647
+ !runtime.stopping &&
3648
+ !runtime.policySuspended &&
3649
+ runtime.policyGeneration === replyGeneration;
3562
3650
  const native = server as unknown as {
3563
3651
  resolveClaim(receiptId: string, answerJson?: string, idempotencyKey?: string): void;
3564
3652
  closeClaimInvalid(receiptId: string, reason: string): void;
@@ -3600,6 +3688,13 @@ export function createNotificationsExtension(
3600
3688
  settle(settlement: AskSettlement): Promise<AskSettlementResult> {
3601
3689
  if (settled) return settled;
3602
3690
  settled = Promise.resolve().then(async () => {
3691
+ if (!replyIsCurrent()) {
3692
+ try {
3693
+ native.closeClaimInvalid(reply.replyReceiptId, "policy_changed");
3694
+ } catch {}
3695
+ pending.fail(reply.id);
3696
+ return { kind: "invalid_closed" };
3697
+ }
3603
3698
  if (settlement.kind === "invalid") {
3604
3699
  try {
3605
3700
  native.closeClaimInvalid(reply.replyReceiptId, settlement.reason);
@@ -3626,6 +3721,11 @@ export function createNotificationsExtension(
3626
3721
  commitKey: `${reply.id}:${reply.idempotencyKey ?? reply.replyReceiptId}`,
3627
3722
  deadlineAt: Date.now() + 8_000,
3628
3723
  });
3724
+ if (!replyIsCurrent()) {
3725
+ native.closeClaimInvalid(reply.replyReceiptId, "policy_changed");
3726
+ pending.fail(reply.id);
3727
+ return { kind: "invalid_closed" };
3728
+ }
3629
3729
  native.resolveClaim(
3630
3730
  reply.replyReceiptId,
3631
3731
  reply.answerJson,
@@ -3711,24 +3811,33 @@ export function createNotificationsExtension(
3711
3811
  replyReceiptId: reply.replyReceiptId,
3712
3812
  answerJson: reply.answerJson,
3713
3813
  idempotencyKey: reply.idempotencyKey ?? undefined,
3714
- resolveClaim: () =>
3814
+ resolveClaim: () => {
3815
+ if (!replyIsCurrent()) {
3816
+ native.closeClaimInvalid(reply.replyReceiptId, "policy_changed");
3817
+ throw new NotificationGatePolicyChangedError();
3818
+ }
3715
3819
  native.resolveClaim(
3716
3820
  reply.replyReceiptId,
3717
3821
  reply.answerJson,
3718
3822
  reply.idempotencyKey ?? undefined,
3719
- ),
3823
+ );
3824
+ },
3720
3825
  closeClaimInvalid: reason => {
3721
3826
  native.closeClaimInvalid(reply.replyReceiptId, reason);
3722
3827
  gatePresentations.closeInteraction(reply.id, reason);
3723
3828
  gatePresentations.reconcile();
3724
3829
  },
3725
- requestSelectedAck: input =>
3726
- requestLiveSelectedAck(native, {
3830
+ requestSelectedAck: async input => {
3831
+ if (!replyIsCurrent()) throw new NotificationGatePolicyChangedError();
3832
+ const ack = await requestLiveSelectedAck(native, {
3727
3833
  replyReceiptId: input.replyReceiptId,
3728
3834
  actionId: input.actionId,
3729
3835
  commitKey: input.commitKey,
3730
3836
  deadlineAt: input.daemonDeadlineAt,
3731
- }),
3837
+ });
3838
+ if (!replyIsCurrent()) throw new NotificationGatePolicyChangedError();
3839
+ return ack;
3840
+ },
3732
3841
  },
3733
3842
  )
3734
3843
  .catch(() => {
@@ -3773,6 +3882,26 @@ export function createNotificationsExtension(
3773
3882
  messageId?: number;
3774
3883
  reason?: string;
3775
3884
  };
3885
+ const notificationOrigin = hostCapCache
3886
+ .get(authenticatedInbound.connectionId)
3887
+ ?.has(ASK_SELECTED_ACK_CAPABILITY);
3888
+ if (runtime?.policySuspended && notificationOrigin) {
3889
+ if (inbound.kind === "control_command") {
3890
+ const frame = sdkInboundFrame(inbound.commandJson);
3891
+ if (frame) {
3892
+ const suspendedRuntime = runtime;
3893
+ runtime.deferredInboundControls.push(() => {
3894
+ if (
3895
+ runtimes.get(id) === suspendedRuntime &&
3896
+ !suspendedRuntime.stopping &&
3897
+ !suspendedRuntime.policySuspended
3898
+ )
3899
+ inboundSdkFrame?.(`seam:${inbound.requestId ?? "notification"}`, frame);
3900
+ });
3901
+ }
3902
+ }
3903
+ return;
3904
+ }
3776
3905
  if (inbound.kind === "control_command") {
3777
3906
  const frame = sdkInboundFrame(inbound.commandJson);
3778
3907
  if (frame) {
@@ -3832,6 +3961,7 @@ export function createNotificationsExtension(
3832
3961
  }
3833
3962
  if (inbound.kind === "config_command") {
3834
3963
  if (!runtime) return;
3964
+ if (runtime.policySuspended) return;
3835
3965
  const update: {
3836
3966
  type: "config_update";
3837
3967
  sessionId: string;
@@ -3846,16 +3976,15 @@ export function createNotificationsExtension(
3846
3976
  update.verbosity = inbound.verbosity;
3847
3977
  }
3848
3978
  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) {
3979
+ if (inbound.redact && !runtime.committedRedact) {
3853
3980
  terminalizeInFlightTools(runtime, runtime.id, "unknown");
3854
3981
  }
3982
+ runtime.committedRedact = inbound.redact;
3855
3983
  runtime.redact = inbound.redact;
3856
3984
  update.redact = inbound.redact;
3857
3985
  }
3858
3986
  if (update.verbosity !== undefined || update.redact !== undefined) {
3987
+ runtime.policyGeneration++;
3859
3988
  try {
3860
3989
  pushSessionFrame(runtime, update);
3861
3990
  } catch (error) {
@@ -3907,7 +4036,7 @@ export function createNotificationsExtension(
3907
4036
  }
3908
4037
  if (notificationsEnabledForSession && settingsAvailable && settings) {
3909
4038
  try {
3910
- if (!(await ensureConfiguredDaemons(settings, cfg, ctx.cwd, id))) {
4039
+ if (!(await ensureConfiguredDaemonOwners(settings, cfg, ctx.cwd, id))) {
3911
4040
  const result = failLifecycleStartup("failed", "Telegram daemon ownership is blocked.");
3912
4041
  finishStartup(result);
3913
4042
  await cleanupAbandonedStartup();
@@ -3921,10 +4050,9 @@ export function createNotificationsExtension(
3921
4050
  }
3922
4051
  }
3923
4052
 
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
4053
+ // Startup contract: configured notification daemon ownership must be ready
4054
+ // before identity or endpoint publication. Native frames are ephemeral, so
4055
+ // publish identity only after readiness; late SDK consumers recover it from
3928
4056
  // event_replay.
3929
4057
  const identityHeader = {
3930
4058
  type: "identity_header",
@@ -4002,9 +4130,13 @@ export function createNotificationsExtension(
4002
4130
  gatePresentations,
4003
4131
  );
4004
4132
  runtime.disposeFileSink = registerTelegramFileSink(runtime.id, async file => {
4005
- if (runtime.redact) return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
4133
+ const generation = runtime.policyGeneration;
4134
+ if (!canDeliverAsync(runtime, generation)) return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
4006
4135
  try {
4007
- const data = await fs.promises.readFile(file.path);
4136
+ const data = await (options.readNotificationFile ?? fs.promises.readFile)(file.path);
4137
+ if (!canDeliverAsync(runtime, generation)) {
4138
+ return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
4139
+ }
4008
4140
  pushFileAttachment(
4009
4141
  runtime,
4010
4142
  {
@@ -4065,7 +4197,11 @@ export function createNotificationsExtension(
4065
4197
  logger.warn(`notifications: gate terminal controller unavailable: ${String(error)}`);
4066
4198
  }
4067
4199
  }
4068
- activeRuntime.disposeGateListener = gate.onGateEmitted(g => {
4200
+ const presentGate = (
4201
+ g: Parameters<NonNullable<WorkflowGateEmitter["onGateEmitted"]>>[0] extends (gate: infer Gate) => void
4202
+ ? Gate
4203
+ : never,
4204
+ ): void => {
4069
4205
  const options = (g.options ?? []).map(o => String((o as { label?: unknown }).label ?? ""));
4070
4206
  gateOptions.set(g.gate_id, options);
4071
4207
  const promptCtx = g.context as { prompt?: unknown; title?: unknown } | undefined;
@@ -4089,6 +4225,13 @@ export function createNotificationsExtension(
4089
4225
  navigationLabel: stageState.navigation_label === "Next" ? "Next" : "Done",
4090
4226
  selectedOptions: [],
4091
4227
  });
4228
+ };
4229
+ activeRuntime.disposeGateListener = gate.onGateEmitted(g => {
4230
+ if (activeRuntime.policySuspended) {
4231
+ activeRuntime.deferredGatePresentations.push(() => presentGate(g));
4232
+ return;
4233
+ }
4234
+ presentGate(g);
4092
4235
  });
4093
4236
  if (gate.setAckRecoveryParticipant) {
4094
4237
  const native = server as unknown as {
@@ -4097,13 +4240,19 @@ export function createNotificationsExtension(
4097
4240
  ): Promise<{ status: string; messageId?: number; reason?: string }>;
4098
4241
  };
4099
4242
  gate.setAckRecoveryParticipant({
4100
- requestRecoveredAskSelectedAck: input =>
4101
- requestRecoveredSelectedAck(native, {
4243
+ requestRecoveredAskSelectedAck: async input => {
4244
+ const generation = activeRuntime.policyGeneration;
4245
+ if (activeRuntime.policySuspended) return { status: "failed", reason: "cancelled" };
4246
+ const outcome = await requestRecoveredSelectedAck(native, {
4102
4247
  sessionId: input.sessionId,
4103
4248
  actionId: input.actionId,
4104
4249
  commitKey: input.commitKey,
4105
4250
  deadlineAt: input.deadlineAt,
4106
- }),
4251
+ });
4252
+ if (activeRuntime.policySuspended || activeRuntime.policyGeneration !== generation)
4253
+ return { status: "failed", reason: "cancelled" };
4254
+ return outcome;
4255
+ },
4107
4256
  });
4108
4257
  activeRuntime.disposeAckRecoveryParticipant = () => gate.setAckRecoveryParticipant?.(null);
4109
4258
  }
@@ -4113,13 +4262,13 @@ export function createNotificationsExtension(
4113
4262
  };
4114
4263
  activeRuntime.disposeGateEmitterListener = registerWorkflowGateEmitterListener(id, attachWorkflowGate);
4115
4264
  if (ctx.workflowGate) attachWorkflowGate(ctx.workflowGate);
4116
- if (notificationsEnabledForSession) initializedRuntime.enableNotifications();
4117
4265
  finishStartup({ status: "started", runtime: initializedRuntime });
4118
4266
  return { status: "started", runtime: initializedRuntime };
4119
4267
  } catch (e) {
4120
4268
  logger.warn(`notifications: failed to start server: ${String(e)}`);
4121
4269
  const result = failLifecycleStartup("failed", e);
4122
4270
  finishStartup(result);
4271
+ let suppressExtensionError = false;
4123
4272
  let stopped = false;
4124
4273
  try {
4125
4274
  stopped = await stopSession(id, "session", runtime);
@@ -4129,9 +4278,10 @@ export function createNotificationsExtension(
4129
4278
  // than letting it escape startSession and surface a red extension error
4130
4279
  // through session_start / session_switch / session_branch.
4131
4280
  logger.error(`notifications: SDK notification runtime cleanup failed: ${String(error)}`);
4281
+ suppressExtensionError = true;
4132
4282
  }
4133
4283
  if (!stopped) await cleanupAbandonedStartup();
4134
- return { ...result, runtime };
4284
+ return { ...result, runtime, suppressExtensionError };
4135
4285
  }
4136
4286
  }
4137
4287
 
@@ -4145,25 +4295,51 @@ export function createNotificationsExtension(
4145
4295
  if (!runtime || sessionId(binding.context) !== binding.sessionId || activeRuntimeId !== binding.sessionId) {
4146
4296
  return "failed";
4147
4297
  }
4148
- runtime.enableNotifications();
4149
4298
  return "started";
4150
4299
  }
4151
4300
  const runtime = runtimes.get(binding.sessionId);
4152
4301
  if (runtime) {
4153
- runtime.enableNotifications();
4154
4302
  return "started";
4155
4303
  }
4156
4304
  const result = await startSession(binding.context);
4157
4305
  return result.status === "started" || result.status === "already" ? "started" : result.status;
4158
4306
  },
4159
4307
  stop: async binding => await stopSession(binding.sessionId, "notifications"),
4308
+ refreshPolicy: (binding, policy) => {
4309
+ const runtime = runtimes.get(binding.sessionId);
4310
+ if (!runtime) return;
4311
+ if (policy.mode === "provisional") {
4312
+ runtime.policyGeneration++;
4313
+ runtime.policySuspended = true;
4314
+ runtime.redact = true;
4315
+ runtime.verbosity = "lean";
4316
+ runtime.stream = false;
4317
+ return;
4318
+ }
4319
+ const redactionEnabled = policy.redact && !runtime.committedRedact;
4320
+ runtime.policyGeneration++;
4321
+ runtime.committedRedact = policy.redact;
4322
+ runtime.policySuspended = false;
4323
+ runtime.redact = policy.redact;
4324
+ runtime.verbosity = policy.verbosity;
4325
+ runtime.stream = policy.stream;
4326
+ if (redactionEnabled) terminalizeInFlightTools(runtime, runtime.id, "unknown");
4327
+ },
4328
+ activate: binding => {
4329
+ const runtime = runtimes.get(binding.sessionId);
4330
+ if (!runtime || runtime.stopping) return;
4331
+ runtime.enableNotifications();
4332
+ flushPendingFinal(runtime, runtime.id);
4333
+ for (const present of runtime.deferredGatePresentations.splice(0)) present();
4334
+ for (const processControl of runtime.deferredInboundControls.splice(0)) processControl();
4335
+ },
4160
4336
  ensureTelegramDaemon: async binding => {
4161
4337
  const { settings, settingsAvailable } = resolveSettings(options.settings);
4162
4338
  if (!settingsAvailable || !settings) return "blocked_identity";
4163
4339
  try {
4164
4340
  return await ensureTelegramOwner(settings, binding.cwd, binding.sessionId);
4165
4341
  } catch {
4166
- return "blocked_identity";
4342
+ return "failed";
4167
4343
  }
4168
4344
  },
4169
4345
  };
@@ -4214,16 +4390,17 @@ export function createNotificationsExtension(
4214
4390
  const result = await controller.setLocalEnabled(ctx, true);
4215
4391
  const enabled = result.status.running && result.status.effectiveEnabled;
4216
4392
  const rotated = sessionId(ctx) !== id;
4393
+ if (rotated) await stopSession(id);
4217
4394
  const failed = result.outcome === "failed" || (!enabled && !rotated && activeRuntimeId !== id);
4218
4395
  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."
4396
+ rotated
4397
+ ? "Notifications were not enabled because the active session changed during startup."
4398
+ : enabled
4399
+ ? "Notifications enabled for this session."
4223
4400
  : failed
4224
4401
  ? "Notifications failed to start for this session."
4225
4402
  : "Notifications were not enabled because daemon ownership could not be proved.",
4226
- enabled ? "info" : rotated ? "warning" : failed ? "error" : "warning",
4403
+ rotated ? "warning" : enabled ? "info" : failed ? "error" : "warning",
4227
4404
  );
4228
4405
  return;
4229
4406
  }
@@ -4242,22 +4419,93 @@ export function createNotificationsExtension(
4242
4419
  },
4243
4420
  });
4244
4421
 
4422
+ const startAndReconcileSession = async (ctx: ExtensionContext): Promise<void> => {
4423
+ const result = await startSession(ctx);
4424
+ if (result.status === "started" || result.status === "already") {
4425
+ await controller.reconcileCurrentSession(ctx);
4426
+ return;
4427
+ }
4428
+ if (
4429
+ !lifecycleStartupCapability &&
4430
+ result.status === "failed" &&
4431
+ !extensionShuttingDown &&
4432
+ !result.suppressExtensionError
4433
+ )
4434
+ throw new Error(`notifications: SDK startup failed: ${result.failure?.message ?? "Unknown startup failure."}`);
4435
+ };
4436
+
4245
4437
  api.on("session_start", async (_event, ctx) => {
4246
- await startSession(ctx);
4247
- await controller.reconcileCurrentSession(ctx);
4438
+ await startAndReconcileSession(ctx);
4248
4439
  });
4249
4440
 
4250
4441
  // A session endpoint's token and generation are authority for exactly one
4251
4442
  // session id. `/new`, fork, and resume must all tear down A before publishing
4252
4443
  // B. Chat implementations may preserve a topic as metadata, but it must never
4253
4444
  // preserve A's endpoint or credentials as B's control/viewing authority.
4445
+ const reconcileBackgroundStartup = (
4446
+ id: string,
4447
+ ctx: ExtensionContext,
4448
+ startup: Promise<SessionStartResult>,
4449
+ ): Promise<void> =>
4450
+ startup
4451
+ .then(async result => {
4452
+ if (
4453
+ result.status !== "started" ||
4454
+ extensionShuttingDown ||
4455
+ sessionId(ctx) !== id ||
4456
+ activeRuntimeId !== id ||
4457
+ !runtimes.has(id)
4458
+ )
4459
+ return;
4460
+ await controller.reconcileCurrentSession(ctx);
4461
+ })
4462
+ .catch(error => logger.warn(`notifications: deferred startup reconciliation failed: ${String(error)}`));
4463
+
4464
+ const trackBranchStartup = (id: string, ctx: ExtensionContext, startup: Promise<SessionStartResult>): void => {
4465
+ let status: SessionStartResult["status"] = "failed";
4466
+ void startup.then(
4467
+ result => {
4468
+ status = result.status;
4469
+ },
4470
+ () => {},
4471
+ );
4472
+ const task = reconcileBackgroundStartup(id, ctx, startup);
4473
+ branchStartupTasks.add(task);
4474
+ void task.finally(() => {
4475
+ branchStartupTasks.delete(task);
4476
+ try {
4477
+ options.onBranchStartupSettled?.({ sessionId: id, status });
4478
+ } catch (error) {
4479
+ logger.warn(`notifications: branch startup receipt failed: ${String(error)}`);
4480
+ }
4481
+ });
4482
+ };
4483
+
4254
4484
  const rotateSessionAuthority = async (
4255
4485
  event: { previousSessionFile?: string },
4256
4486
  ctx: ExtensionContext,
4487
+ awaitStartup: boolean,
4257
4488
  ): Promise<void> => {
4258
4489
  if (extensionShuttingDown) return;
4259
4490
  const newId = sessionId(ctx);
4260
4491
  const prevId = activeRuntimeId ?? sessionIdFromFile(event.previousSessionFile);
4492
+ if (prevId === newId) {
4493
+ const pendingStartup = sessionStartPromises.get(newId);
4494
+ if (pendingStartup) {
4495
+ if (awaitStartup) {
4496
+ await pendingStartup;
4497
+ if (!extensionShuttingDown && runtimes.has(newId) && activeRuntimeId === newId)
4498
+ await controller.reconcileCurrentSession(ctx);
4499
+ } else {
4500
+ trackBranchStartup(newId, ctx, pendingStartup);
4501
+ }
4502
+ return;
4503
+ }
4504
+ if (runtimes.has(newId)) {
4505
+ await controller.reconcileCurrentSession(ctx);
4506
+ return;
4507
+ }
4508
+ }
4261
4509
  if (prevId && prevId !== newId) {
4262
4510
  controller.rekeySession(prevId, newId);
4263
4511
  try {
@@ -4271,26 +4519,35 @@ export function createNotificationsExtension(
4271
4519
  }
4272
4520
  }
4273
4521
  if (extensionShuttingDown) return;
4274
- await startSession(ctx);
4275
- if (extensionShuttingDown) {
4276
- await stopSession(newId);
4522
+ const startup = startSession(ctx);
4523
+ if (awaitStartup) {
4524
+ const result = await startup;
4525
+ if (!lifecycleStartupCapability && result.status === "failed")
4526
+ throw new Error(
4527
+ `notifications: SDK startup failed: ${result.failure?.message ?? "Unknown startup failure."}`,
4528
+ );
4529
+ if (extensionShuttingDown) {
4530
+ await stopSession(newId);
4531
+ return;
4532
+ }
4533
+ await controller.reconcileCurrentSession(ctx);
4277
4534
  return;
4278
4535
  }
4279
- await controller.reconcileCurrentSession(ctx);
4536
+ trackBranchStartup(newId, ctx, startup);
4280
4537
  };
4281
4538
  api.on("session_switch", async (event, ctx) => {
4282
4539
  if (identityControlInFlight) {
4283
- deferredIdentityRotation = { event, ctx };
4540
+ deferredIdentityRotation = { event, ctx, awaitStartup: true };
4284
4541
  return;
4285
4542
  }
4286
- await rotateSessionAuthority(event, ctx);
4543
+ await rotateSessionAuthority(event, ctx, true);
4287
4544
  });
4288
4545
  api.on("session_branch", async (event, ctx) => {
4289
4546
  if (identityControlInFlight) {
4290
- deferredIdentityRotation = { event, ctx };
4547
+ deferredIdentityRotation = { event, ctx, awaitStartup: false };
4291
4548
  return;
4292
4549
  }
4293
- await rotateSessionAuthority(event, ctx);
4550
+ await rotateSessionAuthority(event, ctx, false);
4294
4551
  });
4295
4552
 
4296
4553
  const terminalizeInFlightTools = (rt: SessionRuntime, id: string, phase: "cancelled" | "unknown"): void => {
@@ -4306,6 +4563,36 @@ export function createNotificationsExtension(
4306
4563
  rt.inFlightTools.clear();
4307
4564
  };
4308
4565
 
4566
+ const resetTurnStreamState = (rt: SessionRuntime): void => {
4567
+ rt.currentTurnText = undefined;
4568
+ rt.preAskFlushedText = undefined;
4569
+ rt.liveRef = undefined;
4570
+ rt.turnClosed = true;
4571
+ rt.lastLiveAt = undefined;
4572
+ rt.lastLiveText = undefined;
4573
+ };
4574
+
4575
+ const flushPendingFinal = (rt: SessionRuntime, id: string): void => {
4576
+ const pending = rt.pendingFinal;
4577
+ if (!pending) return;
4578
+ rt.pendingFinal = undefined;
4579
+ if (pending.text && rt.notificationsActive && !rt.redact) {
4580
+ try {
4581
+ pushSessionFrame(rt, {
4582
+ type: "turn_stream",
4583
+ sessionId: id,
4584
+ phase: "finalized",
4585
+ finalAnswer: true,
4586
+ text: pending.text,
4587
+ ...(pending.messageRef ? { messageRef: pending.messageRef } : {}),
4588
+ });
4589
+ } catch (error) {
4590
+ logger.warn(`notifications: pushFrame (pending turn) failed: ${String(error)}`);
4591
+ }
4592
+ }
4593
+ resetTurnStreamState(rt);
4594
+ };
4595
+
4309
4596
  // Drive the live typing indicator: mark busy when the agent loop starts so
4310
4597
  // the daemon shows "typing…" in the thread while the agent is thinking,
4311
4598
  // before any turn output exists. Cleared on `agent_end` below.
@@ -4417,7 +4704,9 @@ export function createNotificationsExtension(
4417
4704
  const model = (ctx as { getModel?: () => { id?: string } | undefined }).getModel?.();
4418
4705
  const tokenUsage = usage && usage.tokens != null ? `${usage.tokens}/${usage.contextWindow}` : undefined;
4419
4706
  const modelId = model?.id;
4420
- void readGitDiffStat(ctx.cwd).then(diff => {
4707
+ const generation = rt.policyGeneration;
4708
+ void (options.readNotificationDiffStat ?? readGitDiffStat)(ctx.cwd).then(diff => {
4709
+ if (!canDeliverAsync(rt, generation)) return;
4421
4710
  const cwd = compactCwd(ctx.cwd);
4422
4711
  if (!diff && !tokenUsage && !modelId && !cwd) return;
4423
4712
  try {
@@ -4445,7 +4734,7 @@ export function createNotificationsExtension(
4445
4734
  // Push the in-flight turn's assistant text as a finalized turn_stream, deduped
4446
4735
  // against what was already flushed for this turn (the pre-ask lead-in).
4447
4736
  const flushTurnText = (rt: SessionRuntime, id: string, text: string | undefined, finalAnswer: boolean): void => {
4448
- if (!text || text === rt.preAskFlushedText || !rt.notificationsActive) return;
4737
+ if (!text || text === rt.preAskFlushedText || !rt.notificationsActive || rt.policySuspended) return;
4449
4738
  rt.preAskFlushedText = text;
4450
4739
  // Decision A: a stream-enabled turn must finalize as an in-place edit of ONE
4451
4740
  // live message, never a fresh (rich-promotable) send. If live frames were
@@ -4575,18 +4864,20 @@ export function createNotificationsExtension(
4575
4864
  const id = sessionId(ctx);
4576
4865
  const rt = runtimes.get(id);
4577
4866
  if (!rt?.notificationsActive) return;
4578
- const text = rt.redact ? undefined : summaryFromMessage(event.message, turnTextMax());
4867
+ const text = rt.policySuspended
4868
+ ? rt.committedRedact
4869
+ ? undefined
4870
+ : summaryFromMessage(event.message, turnTextMax())
4871
+ : rt.redact
4872
+ ? undefined
4873
+ : summaryFromMessage(event.message, turnTextMax());
4874
+ if (rt.policySuspended) {
4875
+ rt.pendingFinal = { text, messageRef: rt.liveRef };
4876
+ rt.turnClosed = true;
4877
+ return;
4878
+ }
4579
4879
  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;
4880
+ resetTurnStreamState(rt);
4590
4881
  });
4591
4882
 
4592
4883
  // Live streaming (opt-in): push throttled in-progress assistant text as
@@ -4596,6 +4887,7 @@ export function createNotificationsExtension(
4596
4887
  api.on("message_update", (event, ctx) => {
4597
4888
  const id = sessionId(ctx);
4598
4889
  const rt = runtimes.get(id);
4890
+ rt?.emitPromptEvent(event);
4599
4891
  if (!rt?.notificationsActive || !rt.stream || rt.redact || rt.turnClosed) return;
4600
4892
  if ((event.message as { role?: unknown }).role !== "assistant") return;
4601
4893
  if (rt.liveRef === undefined && rt.turnSeq !== undefined) {
@@ -4619,6 +4911,7 @@ export function createNotificationsExtension(
4619
4911
  api.on("message_end", (event, ctx) => {
4620
4912
  const id = sessionId(ctx);
4621
4913
  const rt = runtimes.get(id);
4914
+ rt?.emitPromptEvent(event);
4622
4915
  if (!rt?.notificationsActive || rt.redact) return;
4623
4916
  // Capture the in-flight ASSISTANT text here (message_end is on the awaited
4624
4917
  // extension path and ordered before tool_execution_start) so the pre-ask
@@ -4647,6 +4940,7 @@ export function createNotificationsExtension(
4647
4940
  extensionShuttingDown = true;
4648
4941
  identityControlInFlight = false;
4649
4942
  deferredIdentityRotation = undefined;
4943
+ await Promise.allSettled([...branchStartupTasks]);
4650
4944
  const id = sessionId(ctx);
4651
4945
  const rt = runtimes.get(id);
4652
4946
  if (rt) terminalizeInFlightTools(rt, id, "unknown");