@gajae-code/coding-agent 0.12.6 → 0.12.8

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 (150) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/dist/types/async/job-manager.d.ts +36 -1
  3. package/dist/types/commands/notify.d.ts +4 -0
  4. package/dist/types/config/file-lock.d.ts +3 -0
  5. package/dist/types/config/model-discovery-manager.d.ts +6 -1
  6. package/dist/types/config/model-registry.d.ts +2 -0
  7. package/dist/types/config/resolve-config-value.d.ts +2 -1
  8. package/dist/types/config/settings-schema.d.ts +17 -1
  9. package/dist/types/eval/backend.d.ts +2 -2
  10. package/dist/types/exec/bash-executor.d.ts +8 -10
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +0 -36
  12. package/dist/types/internal-urls/registry-helpers.d.ts +0 -18
  13. package/dist/types/main.d.ts +1 -0
  14. package/dist/types/modes/components/hook-selector.d.ts +20 -0
  15. package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
  16. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  17. package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
  18. package/dist/types/sdk/broker/broker.d.ts +5 -1
  19. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  20. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -3
  21. package/dist/types/sdk/bus/config.d.ts +75 -113
  22. package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
  23. package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
  24. package/dist/types/sdk/bus/index.d.ts +1 -1
  25. package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
  26. package/dist/types/sdk/bus/notification-service.d.ts +31 -4
  27. package/dist/types/sdk/bus/session-control.d.ts +8 -3
  28. package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
  29. package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -4
  31. package/dist/types/sdk/client/discovery.d.ts +2 -1
  32. package/dist/types/sdk/host/host.d.ts +2 -23
  33. package/dist/types/sdk/host/query/handlers.d.ts +2 -0
  34. package/dist/types/sdk/host/reverse-leases.d.ts +4 -7
  35. package/dist/types/sdk/index.d.ts +1 -0
  36. package/dist/types/sdk/providers.d.ts +22 -0
  37. package/dist/types/sdk/session-directory.d.ts +1 -1
  38. package/dist/types/session/artifacts.d.ts +5 -8
  39. package/dist/types/session/client-bridge.d.ts +0 -14
  40. package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
  41. package/dist/types/session/internal/managed-session-storage.d.ts +11 -4
  42. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -0
  43. package/dist/types/session/internal/session-open-errors.d.ts +5 -0
  44. package/dist/types/session/messages.d.ts +1 -4
  45. package/dist/types/session/session-manager.d.ts +7 -6
  46. package/dist/types/session/session-storage.d.ts +2 -0
  47. package/dist/types/session/streaming-output.d.ts +0 -32
  48. package/dist/types/task/receipt.d.ts +1 -2
  49. package/dist/types/task/types.d.ts +43 -178
  50. package/dist/types/tools/bash.d.ts +1 -1
  51. package/dist/types/tools/fetch.d.ts +0 -3
  52. package/dist/types/tools/output-meta.d.ts +5 -37
  53. package/dist/types/tools/subagent.d.ts +15 -0
  54. package/dist/types/tools/todo-write.d.ts +3 -0
  55. package/dist/types/tools/tool-result.d.ts +0 -1
  56. package/package.json +10 -8
  57. package/scripts/build-sdk-package-smoke.ts +51 -0
  58. package/scripts/capture-notifications-settings-showcase.ts +1 -1
  59. package/src/async/job-manager.ts +430 -46
  60. package/src/cli/notify-cli.ts +204 -94
  61. package/src/commands/notify.ts +19 -8
  62. package/src/config/file-lock.ts +6 -0
  63. package/src/config/model-discovery-manager.ts +50 -8
  64. package/src/config/model-registry.ts +862 -75
  65. package/src/config/resolve-config-value.ts +10 -8
  66. package/src/config/settings-schema.ts +25 -15
  67. package/src/config/settings.ts +15 -15
  68. package/src/cursor.ts +13 -20
  69. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -3
  70. package/src/eval/backend.ts +2 -2
  71. package/src/exec/bash-executor.ts +151 -425
  72. package/src/gjc-runtime/session-state-sidecar.ts +12 -2
  73. package/src/gjc-runtime/ultragoal-runtime.ts +3 -280
  74. package/src/gjc-runtime/workflow-manifest.generated.json +1 -40
  75. package/src/gjc-runtime/workflow-manifest.ts +0 -12
  76. package/src/internal-urls/agent-protocol.ts +38 -98
  77. package/src/internal-urls/artifact-protocol.ts +17 -28
  78. package/src/internal-urls/docs-index.generated.ts +7 -7
  79. package/src/internal-urls/registry-helpers.ts +0 -62
  80. package/src/main.ts +43 -7
  81. package/src/modes/DESIGN.md +11 -11
  82. package/src/modes/acp/acp-agent.ts +0 -24
  83. package/src/modes/acp/acp-client-bridge.ts +11 -154
  84. package/src/modes/components/hook-selector.ts +155 -44
  85. package/src/modes/components/model-selector.ts +242 -49
  86. package/src/modes/components/notifications-settings-editor.ts +631 -85
  87. package/src/modes/controllers/extension-ui-controller.ts +114 -15
  88. package/src/modes/controllers/selector-controller.ts +464 -50
  89. package/src/prompts/tools/bash.md +2 -2
  90. package/src/sdk/acp/adapter.ts +9 -200
  91. package/src/sdk/broker/broker.ts +54 -10
  92. package/src/sdk/broker/lifecycle-ledger.ts +34 -1
  93. package/src/sdk/broker/lifecycle.ts +47 -50
  94. package/src/sdk/bus/chat-daemon-cli.ts +12 -5
  95. package/src/sdk/bus/chat-daemon-control.ts +35 -8
  96. package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
  97. package/src/sdk/bus/config.ts +534 -286
  98. package/src/sdk/bus/discord-live-provider.ts +57 -2
  99. package/src/sdk/bus/discord-provider.ts +21 -0
  100. package/src/sdk/bus/index.ts +105 -359
  101. package/src/sdk/bus/notification-orchestration.ts +371 -68
  102. package/src/sdk/bus/notification-service.ts +431 -130
  103. package/src/sdk/bus/session-control.ts +119 -44
  104. package/src/sdk/bus/slack-live-provider.ts +78 -2
  105. package/src/sdk/bus/slack-provider.ts +24 -0
  106. package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
  107. package/src/sdk/bus/telegram-daemon-contract.ts +9 -4
  108. package/src/sdk/bus/telegram-daemon-control.ts +3 -3
  109. package/src/sdk/bus/telegram-daemon.ts +23 -15
  110. package/src/sdk/client/discovery.ts +10 -4
  111. package/src/sdk/host/host.ts +9 -187
  112. package/src/sdk/host/query/handlers.ts +20 -1
  113. package/src/sdk/host/reverse-leases.ts +42 -249
  114. package/src/sdk/index.ts +1 -0
  115. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  116. package/src/sdk/protocol/operation-registry.ts +5 -2
  117. package/src/sdk/providers.ts +51 -0
  118. package/src/sdk/session-directory.ts +1 -0
  119. package/src/sdk/session.ts +10 -7
  120. package/src/session/agent-session.ts +59 -202
  121. package/src/session/artifacts.ts +74 -211
  122. package/src/session/client-bridge.ts +0 -15
  123. package/src/session/internal/managed-session-scope.ts +981 -450
  124. package/src/session/internal/managed-session-storage.ts +362 -124
  125. package/src/session/internal/native-publish-outcome.ts +3 -1
  126. package/src/session/internal/session-open-errors.ts +11 -0
  127. package/src/session/messages.ts +7 -32
  128. package/src/session/session-manager.ts +48 -15
  129. package/src/session/session-storage.ts +72 -5
  130. package/src/session/streaming-output.ts +40 -198
  131. package/src/slash-commands/builtin-registry.ts +82 -4
  132. package/src/task/index.ts +237 -65
  133. package/src/task/receipt.ts +2 -4
  134. package/src/task/render.ts +0 -30
  135. package/src/task/types.ts +10 -34
  136. package/src/tools/ask.ts +1 -1
  137. package/src/tools/bash.ts +239 -818
  138. package/src/tools/eval.ts +4 -99
  139. package/src/tools/fetch.ts +15 -77
  140. package/src/tools/find.ts +3 -7
  141. package/src/tools/output-meta.ts +114 -696
  142. package/src/tools/search.ts +4 -9
  143. package/src/tools/subagent.ts +97 -75
  144. package/src/tools/telegram-send.ts +4 -2
  145. package/src/tools/todo-write.ts +47 -0
  146. package/src/tools/tool-result.ts +0 -1
  147. package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +0 -83
  148. package/dist/types/utils/artifact-id.d.ts +0 -2
  149. package/src/gjc-runtime/ultragoal-review-source.ts +0 -378
  150. package/src/utils/artifact-id.ts +0 -6
@@ -2,10 +2,13 @@ import type { CasRestoreResult } from "../../config/atomic-yaml-patch";
2
2
  import type { RawSettings, SettingsAtomicPatch, SettingsAtomicReceipt } from "../../config/settings";
3
3
  import { isProcessIncarnation, processIncarnation } from "../broker/process-incarnation";
4
4
  import {
5
+ getCurrentTelegramActivationMarker,
5
6
  getNotificationConfig,
6
7
  hasNonBlankValue,
8
+ isProviderEffectivelyEnabled,
9
+ type NotificationProvider,
7
10
  type NotificationSettingsReader,
8
- type NotificationSettingsSnapshot,
11
+ type ProviderSecretDisposition,
9
12
  readTelegramActivationMarkers,
10
13
  type TelegramActivationMarker,
11
14
  type TelegramActivationMarkers,
@@ -136,7 +139,7 @@ export async function proposedTelegramIdentity(
136
139
 
137
140
  export interface NotificationConfigurationWriter extends NotificationSettingsReader {
138
141
  commitAtomicBatch(patches: readonly SettingsAtomicPatch[]): Promise<SettingsAtomicReceipt>;
139
- commitAtomicBatchWithCurrent?(
142
+ commitAtomicBatchWithCurrent(
140
143
  buildPatches: (
141
144
  current: Readonly<RawSettings>,
142
145
  ) => Promise<readonly SettingsAtomicPatch[]> | readonly SettingsAtomicPatch[],
@@ -181,16 +184,7 @@ async function commitNotificationBatchWithCurrent(
181
184
  current: Readonly<RawSettings>,
182
185
  ) => Promise<readonly SettingsAtomicPatch[]> | readonly SettingsAtomicPatch[],
183
186
  ): Promise<SettingsAtomicReceipt> {
184
- if (settings.commitAtomicBatchWithCurrent) return settings.commitAtomicBatchWithCurrent(buildPatches);
185
- const snapshot = settings.getNotificationSettingsSnapshot();
186
- const current: RawSettings = {
187
- notifications: {
188
- telegram: {
189
- activation: snapshot.telegram.activation,
190
- },
191
- },
192
- };
193
- return settings.commitAtomicBatch(await buildPatches(current));
187
+ return settings.commitAtomicBatchWithCurrent(buildPatches);
194
188
  }
195
189
 
196
190
  function sameActivationMarker(left: TelegramActivationMarker, right: TelegramActivationMarker): boolean {
@@ -231,36 +225,9 @@ export async function clearTelegramActivationMarker(
231
225
  receipt.discard();
232
226
  }
233
227
 
234
- export type CompleteNonTelegramAdapter = "discord" | "slack";
235
-
236
- function completeNonTelegramAdapters(snapshot: NotificationSettingsSnapshot): CompleteNonTelegramAdapter[] {
237
- if (!snapshot.enabled) return [];
238
- const adapters: CompleteNonTelegramAdapter[] = [];
239
- if (
240
- hasNonBlankValue(snapshot.discord.botToken) &&
241
- hasNonBlankValue(snapshot.discord.applicationId) &&
242
- hasNonBlankValue(snapshot.discord.guildId) &&
243
- hasNonBlankValue(snapshot.discord.parentChannelId)
244
- ) {
245
- adapters.push("discord");
246
- }
247
- if (
248
- hasNonBlankValue(snapshot.slack.botToken) &&
249
- hasNonBlankValue(snapshot.slack.appToken) &&
250
- hasNonBlankValue(snapshot.slack.workspaceId) &&
251
- hasNonBlankValue(snapshot.slack.channelId)
252
- ) {
253
- adapters.push("slack");
254
- }
255
- return adapters;
256
- }
257
-
258
228
  export type SaveTelegramInactiveAvailability = { available: true };
259
229
 
260
- /**
261
- * Telegram-specific activation markers let Save inactive preserve globally
262
- * enabled Discord and Slack adapters, so the action is always available.
263
- */
230
+ /** Telegram activation markers make Save inactive independent of sibling providers. */
264
231
  export function getSaveTelegramInactiveAvailability(
265
232
  _settings: NotificationSettingsReader,
266
233
  ): SaveTelegramInactiveAvailability {
@@ -269,10 +236,7 @@ export function getSaveTelegramInactiveAvailability(
269
236
 
270
237
  export type SaveTelegramInactiveResult = { status: "saved_inactive"; receipt: SettingsAtomicReceipt };
271
238
 
272
- /**
273
- * Atomically persist Telegram credentials with a non-secret inactive marker.
274
- * Global notifications are disabled only when Telegram is the sole complete adapter.
275
- */
239
+ /** Atomically persist Telegram credentials, desired-off intent, and its inactive marker. */
276
240
  export async function saveTelegramInactive(input: {
277
241
  settings: NotificationConfigurationWriter;
278
242
  botToken: string;
@@ -281,7 +245,6 @@ export async function saveTelegramInactive(input: {
281
245
  if (!hasNonBlankValue(input.botToken) || !hasNonBlankValue(input.chatId)) {
282
246
  throw new TypeError("Saving inactive Telegram configuration requires a non-blank token and chat ID.");
283
247
  }
284
- const snapshot = input.settings.getNotificationSettingsSnapshot();
285
248
  const marker = createTelegramActivationMarker({
286
249
  botToken: input.botToken,
287
250
  chatId: input.chatId,
@@ -291,15 +254,12 @@ export async function saveTelegramInactive(input: {
291
254
  const receipt = await commitNotificationBatchWithCurrent(input.settings, current => {
292
255
  const markers = activationMarkersFromCurrent(current);
293
256
  markers[marker.identity] = marker;
294
- const patches: SettingsAtomicPatch[] = [
257
+ return [
295
258
  { path: "notifications.telegram.botToken", op: "set", value: input.botToken },
296
259
  { path: "notifications.telegram.chatId", op: "set", value: input.chatId },
260
+ { path: "notifications.telegram.enabled", op: "set", value: false },
297
261
  { path: "notifications.telegram.activation", op: "set", value: markers },
298
262
  ];
299
- if (completeNonTelegramAdapters(snapshot).length === 0) {
300
- patches.push({ path: "notifications.enabled", op: "set", value: false });
301
- }
302
- return patches;
303
263
  });
304
264
  return { status: "saved_inactive", receipt };
305
265
  }
@@ -318,25 +278,14 @@ export async function removeTelegramConfiguration(input: {
318
278
  settings: NotificationConfigurationWriter;
319
279
  removal: TelegramRemovalRuntime;
320
280
  }): Promise<{ receipt: SettingsAtomicReceipt; globallyDisabled: boolean }> {
321
- const cfg = getNotificationConfig(input.settings);
322
- const otherAdapterRemains =
323
- (hasNonBlankValue(cfg.discord.botToken) &&
324
- hasNonBlankValue(cfg.discord.applicationId) &&
325
- hasNonBlankValue(cfg.discord.guildId) &&
326
- hasNonBlankValue(cfg.discord.parentChannelId)) ||
327
- (hasNonBlankValue(cfg.slack.botToken) &&
328
- hasNonBlankValue(cfg.slack.appToken) &&
329
- hasNonBlankValue(cfg.slack.workspaceId) &&
330
- hasNonBlankValue(cfg.slack.channelId));
331
281
  await input.removal.stopAndUnregister();
332
- const patches: SettingsAtomicPatch[] = [
282
+ const receipt = await input.settings.commitAtomicBatch([
333
283
  { path: "notifications.telegram.botToken", op: "unset" },
334
284
  { path: "notifications.telegram.chatId", op: "unset" },
335
285
  { path: "notifications.telegram.activation", op: "unset" },
336
- ];
337
- if (!otherAdapterRemains) patches.push({ path: "notifications.enabled", op: "set", value: false });
338
- const receipt = await input.settings.commitAtomicBatch(patches);
339
- return { receipt, globallyDisabled: !otherAdapterRemains };
286
+ { path: "notifications.telegram.enabled", op: "set", value: false },
287
+ ]);
288
+ return { receipt, globallyDisabled: false };
340
289
  }
341
290
 
342
291
  /** Detailed outcome of checking or reconnecting the Telegram daemon after a durable commit. */
@@ -365,9 +314,20 @@ export interface TelegramPostCommitActivation {
365
314
  }
366
315
 
367
316
  export type PostCommitTelegramActivationResult =
368
- | { status: "activated"; reconnect: Exclude<TelegramDaemonReconnectOutcome, "blocked_identity"> }
317
+ | {
318
+ status: "activated";
319
+ receipt: SettingsAtomicReceipt;
320
+ reconnect: Exclude<TelegramDaemonReconnectOutcome, "blocked_identity" | "disabled">;
321
+ }
322
+ | {
323
+ status: "activation_failed";
324
+ receipt: SettingsAtomicReceipt;
325
+ reconnect?: TelegramDaemonReconnectOutcome;
326
+ message: string;
327
+ }
369
328
  | {
370
329
  status: "blocked_identity";
330
+ receipt: SettingsAtomicReceipt;
371
331
  message: string;
372
332
  restore(): Promise<BlockedTelegramRestoreResult>;
373
333
  retainCommitted(): void;
@@ -433,7 +393,7 @@ async function restoreBlockedConfiguration(input: {
433
393
  * owner race stops the endpoint, durably marks only that Telegram identity
434
394
  * inactive, then exposes an ordered CAS restore/retain choice.
435
395
  */
436
- export async function reconcileCommittedTelegramConfiguration(input: {
396
+ async function reconcileCommittedTelegramConfigurationUnsafe(input: {
437
397
  receipt: SettingsAtomicReceipt;
438
398
  activation: TelegramPostCommitActivation;
439
399
  inactiveMarkerToClear?: TelegramActivationMarker;
@@ -443,6 +403,7 @@ export async function reconcileCommittedTelegramConfiguration(input: {
443
403
  await input.activation.controller.enterBlockedRuntime();
444
404
  const inactiveReceipt = await input.activation.persistInactive(input.activation.marker);
445
405
  return {
406
+ receipt: input.receipt,
446
407
  status: "blocked_identity",
447
408
  message:
448
409
  "Configuration saved inactive; activation blocked; foreign daemon untouched. Current session stopped because Telegram activation was blocked by a foreign daemon.",
@@ -453,6 +414,15 @@ export async function reconcileCommittedTelegramConfiguration(input: {
453
414
  },
454
415
  };
455
416
  }
417
+ if (reconnect === "disabled") {
418
+ await input.activation.controller.enterBlockedRuntime();
419
+ return {
420
+ receipt: input.receipt,
421
+ status: "activation_failed",
422
+ reconnect,
423
+ message: "Configuration and desired intent were saved, but Telegram runtime activation did not become ready.",
424
+ };
425
+ }
456
426
 
457
427
  if (input.inactiveMarkerToClear) {
458
428
  try {
@@ -464,7 +434,32 @@ export async function reconcileCommittedTelegramConfiguration(input: {
464
434
  }
465
435
  await input.activation.controller.clearBlockedRuntime();
466
436
  await input.activation.controller.reconcileCurrentSession();
467
- return { status: "activated", reconnect };
437
+ return { status: "activated", receipt: input.receipt, reconnect };
438
+ }
439
+
440
+ export async function reconcileCommittedTelegramConfiguration(input: {
441
+ receipt: SettingsAtomicReceipt;
442
+ activation: TelegramPostCommitActivation;
443
+ inactiveMarkerToClear?: TelegramActivationMarker;
444
+ }): Promise<PostCommitTelegramActivationResult> {
445
+ try {
446
+ return await reconcileCommittedTelegramConfigurationUnsafe(input);
447
+ } catch {
448
+ let fenced = false;
449
+ try {
450
+ await input.activation.controller.enterBlockedRuntime();
451
+ fenced = true;
452
+ } catch {
453
+ fenced = false;
454
+ }
455
+ return {
456
+ status: "activation_failed",
457
+ receipt: input.receipt,
458
+ message: fenced
459
+ ? "Configuration and desired intent were saved, but post-commit activation or reconciliation failed; the current runtime was fenced."
460
+ : "Configuration and desired intent were saved, but post-commit activation or reconciliation failed and the current runtime could not be fenced.",
461
+ };
462
+ }
468
463
  }
469
464
 
470
465
  export type SaveTelegramConfigurationResult =
@@ -520,6 +515,7 @@ export async function saveTelegramConfiguration(input: {
520
515
  { path: "notifications.telegram.botToken", op: "set", value: input.botToken },
521
516
  { path: "notifications.telegram.chatId", op: "set", value: input.chatId },
522
517
  { path: "notifications.enabled", op: "set", value: true },
518
+ { path: "notifications.telegram.enabled", op: "set", value: true },
523
519
  ];
524
520
  // With post-commit activation, the marker has its own receipt so a blocked
525
521
  // rollback can restore configuration without self-conflicting on the marker
@@ -545,3 +541,310 @@ export async function saveTelegramConfiguration(input: {
545
541
  inactiveMarkerToClear,
546
542
  });
547
543
  }
544
+
545
+ export type ProviderSecretMutation = { action: "keep" } | { action: "replace"; value: string } | { action: "remove" };
546
+
547
+ export interface TelegramProviderConfigurationMutation {
548
+ provider: "telegram";
549
+ botToken: ProviderSecretMutation;
550
+ chatId?: string;
551
+ richEnabled?: boolean;
552
+ richDraftEnabled?: boolean;
553
+ streamingEnabled?: boolean;
554
+ }
555
+
556
+ export interface DiscordProviderConfigurationMutation {
557
+ provider: "discord";
558
+ botToken: ProviderSecretMutation;
559
+ applicationId?: string;
560
+ guildId?: string;
561
+ parentChannelId?: string;
562
+ }
563
+
564
+ export interface SlackProviderConfigurationMutation {
565
+ provider: "slack";
566
+ botToken: ProviderSecretMutation;
567
+ appToken: ProviderSecretMutation;
568
+ workspaceId?: string;
569
+ channelId?: string;
570
+ authorizedUserId?: string | null;
571
+ }
572
+
573
+ export type NotificationProviderConfigurationMutation =
574
+ | TelegramProviderConfigurationMutation
575
+ | DiscordProviderConfigurationMutation
576
+ | SlackProviderConfigurationMutation;
577
+
578
+ export interface NotificationProviderRuntimeAuthority {
579
+ activate(provider: NotificationProvider): Promise<void>;
580
+ deactivate(provider: NotificationProvider): Promise<void>;
581
+ }
582
+
583
+ export type NotificationProviderMutationResult =
584
+ | { status: "saved"; receipt: SettingsAtomicReceipt; observerFailed: boolean }
585
+ | { status: "activated"; receipt: SettingsAtomicReceipt; observerFailed: boolean }
586
+ | { status: "activation_failed"; receipt: SettingsAtomicReceipt; observerFailed: boolean }
587
+ | { status: "deactivation_failed"; receipt: SettingsAtomicReceipt; observerFailed: boolean }
588
+ | { status: "commit_failed" };
589
+
590
+ function secretMutationPatches(
591
+ pathName:
592
+ | "notifications.telegram.botToken"
593
+ | "notifications.discord.botToken"
594
+ | "notifications.slack.botToken"
595
+ | "notifications.slack.appToken",
596
+ mutation: ProviderSecretMutation,
597
+ ): SettingsAtomicPatch[] {
598
+ if (mutation.action === "keep") return [];
599
+ if (mutation.action === "remove") return [{ path: pathName, op: "unset" }];
600
+ if (!hasNonBlankValue(mutation.value)) throw new TypeError(`A non-blank replacement is required for ${pathName}.`);
601
+ return [{ path: pathName, op: "set", value: mutation.value }];
602
+ }
603
+
604
+ function optionalSet(pathName: SettingsAtomicPatch["path"], value: unknown): SettingsAtomicPatch[] {
605
+ return value === undefined ? [] : [{ path: pathName, op: "set", value }];
606
+ }
607
+
608
+ function selectedProviderPatches(mutation: NotificationProviderConfigurationMutation): SettingsAtomicPatch[] {
609
+ if (mutation.provider === "telegram") {
610
+ return [
611
+ ...secretMutationPatches("notifications.telegram.botToken", mutation.botToken),
612
+ ...optionalSet("notifications.telegram.chatId", mutation.chatId),
613
+ ...optionalSet("notifications.telegram.rich.enabled", mutation.richEnabled),
614
+ ...optionalSet("notifications.telegram.richDraft.enabled", mutation.richDraftEnabled),
615
+ ...optionalSet("notifications.telegram.streaming.enabled", mutation.streamingEnabled),
616
+ ];
617
+ }
618
+ if (mutation.provider === "discord") {
619
+ return [
620
+ ...secretMutationPatches("notifications.discord.botToken", mutation.botToken),
621
+ ...optionalSet("notifications.discord.applicationId", mutation.applicationId),
622
+ ...optionalSet("notifications.discord.guildId", mutation.guildId),
623
+ ...optionalSet("notifications.discord.parentChannelId", mutation.parentChannelId),
624
+ ];
625
+ }
626
+ return [
627
+ ...secretMutationPatches("notifications.slack.botToken", mutation.botToken),
628
+ ...secretMutationPatches("notifications.slack.appToken", mutation.appToken),
629
+ ...optionalSet("notifications.slack.workspaceId", mutation.workspaceId),
630
+ ...optionalSet("notifications.slack.channelId", mutation.channelId),
631
+ ...(mutation.authorizedUserId === undefined
632
+ ? []
633
+ : mutation.authorizedUserId === null
634
+ ? [{ path: "notifications.slack.authorizedUserId", op: "unset" } as const]
635
+ : optionalSet("notifications.slack.authorizedUserId", mutation.authorizedUserId)),
636
+ ];
637
+ }
638
+
639
+ function providerDesiredPath(provider: NotificationProvider): SettingsAtomicPatch["path"] {
640
+ if (provider === "telegram") return "notifications.telegram.enabled";
641
+ if (provider === "discord") return "notifications.discord.enabled";
642
+ return "notifications.slack.enabled";
643
+ }
644
+
645
+ function mutationRemovesRequiredSecret(mutation: NotificationProviderConfigurationMutation): boolean {
646
+ if (mutation.provider === "telegram" || mutation.provider === "discord")
647
+ return mutation.botToken.action === "remove";
648
+ return mutation.botToken.action === "remove" || mutation.appToken.action === "remove";
649
+ }
650
+
651
+ /**
652
+ * Sole CAS-backed provider configuration authority. It commits one selected-provider
653
+ * batch before observer/runtime effects and never reports rollback after a durable save.
654
+ */
655
+ export async function mutateNotificationProvider(input: {
656
+ settings: NotificationConfigurationWriter;
657
+ mutation: NotificationProviderConfigurationMutation;
658
+ desiredEnabled?: boolean;
659
+ configureAndActivate?: boolean;
660
+ notifyConfigChanged?: () => Promise<void> | void;
661
+ redact?: boolean;
662
+ runtime?: NotificationProviderRuntimeAuthority;
663
+ signal?: AbortSignal;
664
+ }): Promise<NotificationProviderMutationResult> {
665
+ if (input.signal?.aborted) return { status: "commit_failed" };
666
+ const provider = input.mutation.provider;
667
+ const removesRequiredSecret = mutationRemovesRequiredSecret(input.mutation);
668
+ const desiredEnabled = removesRequiredSecret ? false : input.desiredEnabled;
669
+ const activate = input.configureAndActivate === true && !removesRequiredSecret;
670
+ const patches = selectedProviderPatches(input.mutation);
671
+ if (activate) {
672
+ patches.push(
673
+ { path: "notifications.enabled", op: "set", value: true },
674
+ { path: providerDesiredPath(provider), op: "set", value: true },
675
+ );
676
+ } else if (desiredEnabled !== undefined) {
677
+ patches.push({ path: providerDesiredPath(provider), op: "set", value: desiredEnabled });
678
+ }
679
+ if (input.redact !== undefined) patches.push({ path: "notifications.redact", op: "set", value: input.redact });
680
+ let receipt: SettingsAtomicReceipt;
681
+ try {
682
+ receipt = await input.settings.commitAtomicBatch(patches);
683
+ } catch {
684
+ return { status: "commit_failed" };
685
+ }
686
+ let observerFailed = false;
687
+ try {
688
+ await input.notifyConfigChanged?.();
689
+ } catch {
690
+ observerFailed = true;
691
+ }
692
+ if (!input.runtime) return { status: "saved", receipt, observerFailed };
693
+ try {
694
+ if (activate || desiredEnabled === true) {
695
+ await input.runtime.activate(provider);
696
+ return { status: "activated", receipt, observerFailed };
697
+ }
698
+ if (desiredEnabled === false) {
699
+ await input.runtime.deactivate(provider);
700
+ }
701
+ return { status: "saved", receipt, observerFailed };
702
+ } catch {
703
+ return {
704
+ status: activate || desiredEnabled === true ? "activation_failed" : "deactivation_failed",
705
+ receipt,
706
+ observerFailed,
707
+ };
708
+ }
709
+ }
710
+
711
+ export type NotificationProviderRemovalResult =
712
+ | { status: "removed"; receipt: SettingsAtomicReceipt; observerFailed: boolean }
713
+ | { status: "deactivation_failed"; receipt?: SettingsAtomicReceipt; observerFailed?: boolean }
714
+ | { status: "commit_failed_after_teardown" }
715
+ | { status: "commit_failed" };
716
+
717
+ function providerRemovalPatches(provider: NotificationProvider): SettingsAtomicPatch[] {
718
+ if (provider === "telegram") {
719
+ return [
720
+ { path: "notifications.telegram.botToken", op: "unset" },
721
+ { path: "notifications.telegram.chatId", op: "unset" },
722
+ { path: "notifications.telegram.activation", op: "unset" },
723
+ { path: "notifications.telegram.enabled", op: "set", value: false },
724
+ ];
725
+ }
726
+ if (provider === "discord") {
727
+ return [
728
+ { path: "notifications.discord.botToken", op: "unset" },
729
+ { path: "notifications.discord.applicationId", op: "unset" },
730
+ { path: "notifications.discord.guildId", op: "unset" },
731
+ { path: "notifications.discord.parentChannelId", op: "unset" },
732
+ { path: "notifications.discord.enabled", op: "set", value: false },
733
+ ];
734
+ }
735
+ return [
736
+ { path: "notifications.slack.botToken", op: "unset" },
737
+ { path: "notifications.slack.appToken", op: "unset" },
738
+ { path: "notifications.slack.workspaceId", op: "unset" },
739
+ { path: "notifications.slack.channelId", op: "unset" },
740
+ { path: "notifications.slack.authorizedUserId", op: "unset" },
741
+ { path: "notifications.slack.enabled", op: "set", value: false },
742
+ ];
743
+ }
744
+
745
+ /** Remove only one provider. Telegram keeps teardown-before-delete; chat providers deactivate after commit. */
746
+ export async function removeNotificationProvider(input: {
747
+ settings: NotificationConfigurationWriter;
748
+ provider: NotificationProvider;
749
+ runtime: NotificationProviderRuntimeAuthority;
750
+ notifyConfigChanged?: () => Promise<void> | void;
751
+ }): Promise<NotificationProviderRemovalResult> {
752
+ if (input.provider === "telegram") {
753
+ try {
754
+ await input.runtime.deactivate("telegram");
755
+ } catch {
756
+ return { status: "deactivation_failed" };
757
+ }
758
+ }
759
+ let receipt: SettingsAtomicReceipt;
760
+ try {
761
+ receipt = await input.settings.commitAtomicBatch(providerRemovalPatches(input.provider));
762
+ } catch {
763
+ return { status: input.provider === "telegram" ? "commit_failed_after_teardown" : "commit_failed" };
764
+ }
765
+ let observerFailed = false;
766
+ try {
767
+ await input.notifyConfigChanged?.();
768
+ } catch {
769
+ observerFailed = true;
770
+ }
771
+ if (input.provider !== "telegram") {
772
+ try {
773
+ await input.runtime.deactivate(input.provider);
774
+ } catch {
775
+ return { status: "deactivation_failed", receipt, observerFailed };
776
+ }
777
+ }
778
+ return { status: "removed", receipt, observerFailed };
779
+ }
780
+
781
+ export type GlobalNotificationMutationResult =
782
+ | { status: "saved"; receipt: SettingsAtomicReceipt; observerFailed: boolean }
783
+ | {
784
+ status: "global_deactivation_partial";
785
+ receipt: SettingsAtomicReceipt;
786
+ failed: readonly NotificationProvider[];
787
+ observerFailed: boolean;
788
+ }
789
+ | {
790
+ status: "global_activation_partial";
791
+ receipt: SettingsAtomicReceipt;
792
+ failed: readonly NotificationProvider[];
793
+ observerFailed: boolean;
794
+ }
795
+ | { status: "commit_failed" };
796
+
797
+ /** Toggle only the global master. Provider configuration and desired intent are preserved. */
798
+ export async function setGlobalNotificationsEnabled(input: {
799
+ settings: NotificationConfigurationWriter;
800
+ enabled: boolean;
801
+ runtime?: NotificationProviderRuntimeAuthority;
802
+ notifyConfigChanged?: () => Promise<void> | void;
803
+ }): Promise<GlobalNotificationMutationResult> {
804
+ let receipt: SettingsAtomicReceipt;
805
+ try {
806
+ receipt = await input.settings.commitAtomicBatch([
807
+ { path: "notifications.enabled", op: "set", value: input.enabled },
808
+ ]);
809
+ } catch {
810
+ return { status: "commit_failed" };
811
+ }
812
+ let observerFailed = false;
813
+ try {
814
+ await input.notifyConfigChanged?.();
815
+ } catch {
816
+ observerFailed = true;
817
+ }
818
+ if (!input.runtime) return { status: "saved", receipt, observerFailed };
819
+ const failed: NotificationProvider[] = [];
820
+ if (input.enabled) {
821
+ const cfg = getNotificationConfig(input.settings);
822
+ for (const provider of ["telegram", "discord", "slack"] as const) {
823
+ if (!isProviderEffectivelyEnabled(cfg, provider)) continue;
824
+ if (provider === "telegram" && getCurrentTelegramActivationMarker(cfg)) continue;
825
+ try {
826
+ await input.runtime.activate(provider);
827
+ } catch {
828
+ failed.push(provider);
829
+ }
830
+ }
831
+ return failed.length === 0
832
+ ? { status: "saved", receipt, observerFailed }
833
+ : { status: "global_activation_partial", receipt, failed, observerFailed };
834
+ }
835
+ for (const provider of ["telegram", "discord", "slack"] as const) {
836
+ try {
837
+ await input.runtime.deactivate(provider);
838
+ } catch {
839
+ failed.push(provider);
840
+ }
841
+ }
842
+ return failed.length === 0
843
+ ? { status: "saved", receipt, observerFailed }
844
+ : { status: "global_deactivation_partial", receipt, failed, observerFailed };
845
+ }
846
+
847
+ /** Safe helper for UI contracts that must retain only the disposition, never replacement material. */
848
+ export function providerSecretDisposition(mutation: ProviderSecretMutation): ProviderSecretDisposition {
849
+ return mutation.action;
850
+ }