@gajae-code/coding-agent 0.12.7 → 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 (95) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/types/commands/notify.d.ts +4 -0
  3. package/dist/types/config/file-lock.d.ts +3 -0
  4. package/dist/types/config/model-discovery-manager.d.ts +6 -1
  5. package/dist/types/config/model-registry.d.ts +2 -0
  6. package/dist/types/config/resolve-config-value.d.ts +2 -1
  7. package/dist/types/config/settings-schema.d.ts +17 -1
  8. package/dist/types/main.d.ts +1 -0
  9. package/dist/types/modes/components/hook-selector.d.ts +20 -0
  10. package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
  11. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  12. package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
  13. package/dist/types/sdk/broker/broker.d.ts +5 -1
  14. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  15. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  16. package/dist/types/sdk/bus/config.d.ts +75 -113
  17. package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
  18. package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
  19. package/dist/types/sdk/bus/index.d.ts +1 -1
  20. package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
  21. package/dist/types/sdk/bus/notification-service.d.ts +31 -4
  22. package/dist/types/sdk/bus/session-control.d.ts +8 -3
  23. package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
  24. package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
  25. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  26. package/dist/types/sdk/client/discovery.d.ts +2 -1
  27. package/dist/types/sdk/host/query/handlers.d.ts +2 -0
  28. package/dist/types/sdk/index.d.ts +1 -0
  29. package/dist/types/sdk/providers.d.ts +22 -0
  30. package/dist/types/sdk/session-directory.d.ts +1 -1
  31. package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
  32. package/dist/types/session/internal/managed-session-storage.d.ts +11 -1
  33. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -0
  34. package/dist/types/session/internal/session-open-errors.d.ts +5 -0
  35. package/dist/types/session/session-manager.d.ts +3 -1
  36. package/dist/types/session/session-storage.d.ts +2 -0
  37. package/package.json +10 -8
  38. package/scripts/build-sdk-package-smoke.ts +51 -0
  39. package/scripts/capture-notifications-settings-showcase.ts +1 -1
  40. package/src/cli/notify-cli.ts +204 -94
  41. package/src/commands/notify.ts +19 -8
  42. package/src/config/file-lock.ts +6 -0
  43. package/src/config/model-discovery-manager.ts +50 -8
  44. package/src/config/model-registry.ts +862 -75
  45. package/src/config/resolve-config-value.ts +10 -8
  46. package/src/config/settings-schema.ts +25 -15
  47. package/src/config/settings.ts +15 -15
  48. package/src/gjc-runtime/session-state-sidecar.ts +12 -2
  49. package/src/internal-urls/docs-index.generated.ts +6 -6
  50. package/src/main.ts +43 -7
  51. package/src/modes/DESIGN.md +11 -11
  52. package/src/modes/components/hook-selector.ts +155 -44
  53. package/src/modes/components/model-selector.ts +242 -49
  54. package/src/modes/components/notifications-settings-editor.ts +631 -85
  55. package/src/modes/controllers/extension-ui-controller.ts +114 -15
  56. package/src/modes/controllers/selector-controller.ts +464 -50
  57. package/src/sdk/broker/broker.ts +54 -10
  58. package/src/sdk/broker/lifecycle-ledger.ts +34 -1
  59. package/src/sdk/broker/lifecycle.ts +47 -50
  60. package/src/sdk/bus/chat-daemon-cli.ts +12 -5
  61. package/src/sdk/bus/chat-daemon-control.ts +35 -6
  62. package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
  63. package/src/sdk/bus/config.ts +534 -286
  64. package/src/sdk/bus/discord-live-provider.ts +57 -2
  65. package/src/sdk/bus/discord-provider.ts +21 -0
  66. package/src/sdk/bus/index.ts +97 -35
  67. package/src/sdk/bus/notification-orchestration.ts +371 -68
  68. package/src/sdk/bus/notification-service.ts +431 -130
  69. package/src/sdk/bus/session-control.ts +119 -44
  70. package/src/sdk/bus/slack-live-provider.ts +78 -2
  71. package/src/sdk/bus/slack-provider.ts +24 -0
  72. package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
  73. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  74. package/src/sdk/bus/telegram-daemon-control.ts +3 -3
  75. package/src/sdk/bus/telegram-daemon.ts +23 -15
  76. package/src/sdk/client/discovery.ts +10 -4
  77. package/src/sdk/host/query/handlers.ts +20 -1
  78. package/src/sdk/index.ts +1 -0
  79. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  80. package/src/sdk/protocol/operation-registry.ts +5 -2
  81. package/src/sdk/providers.ts +51 -0
  82. package/src/sdk/session-directory.ts +1 -0
  83. package/src/sdk/session.ts +10 -7
  84. package/src/session/agent-session.ts +44 -24
  85. package/src/session/internal/managed-session-scope.ts +981 -450
  86. package/src/session/internal/managed-session-storage.ts +382 -45
  87. package/src/session/internal/native-publish-outcome.ts +3 -1
  88. package/src/session/internal/session-open-errors.ts +11 -0
  89. package/src/session/session-manager.ts +27 -3
  90. package/src/session/session-storage.ts +72 -5
  91. package/src/slash-commands/builtin-registry.ts +82 -4
  92. package/src/task/index.ts +9 -3
  93. package/src/tools/ask.ts +1 -1
  94. package/src/tools/bash.ts +1 -1
  95. package/src/tools/telegram-send.ts +4 -2
@@ -9,6 +9,7 @@ import {
9
9
  wrapTextWithAnsi,
10
10
  } from "@gajae-code/tui";
11
11
  import type { CasReceipt } from "../../config/atomic-yaml-patch";
12
+ import type { NotificationProvider, ProviderSecretDisposition } from "../../sdk/bus/config";
12
13
  import type {
13
14
  BlockedTelegramRestoreResult,
14
15
  ProposedTelegramIdentity,
@@ -69,6 +70,43 @@ export interface PreparedTelegramConfiguration {
69
70
  streamingEnabled: boolean;
70
71
  }
71
72
 
73
+ export interface ProviderSecretActionInput {
74
+ action: ProviderSecretDisposition;
75
+ value?: SecretValue;
76
+ }
77
+
78
+ export interface NotificationsProviderSetupInput {
79
+ provider: "discord" | "slack";
80
+ botToken: ProviderSecretActionInput;
81
+ appToken?: ProviderSecretActionInput;
82
+ applicationId?: string;
83
+ guildId?: string;
84
+ parentChannelId?: string;
85
+ workspaceId?: string;
86
+ channelId?: string;
87
+ authorizedUserId?: string;
88
+ }
89
+
90
+ export interface PreparedNotificationProviderConfiguration {
91
+ provider: "discord" | "slack";
92
+ botTokenDisposition: ProviderSecretDisposition;
93
+ botTokenMask: string;
94
+ appTokenDisposition?: ProviderSecretDisposition;
95
+ appTokenMask?: string;
96
+ applicationId?: string;
97
+ guildId?: string;
98
+ parentChannelId?: string;
99
+ workspaceId?: string;
100
+ channelId?: string;
101
+ authorizedUserId?: string;
102
+ applicationIdDisplay?: string;
103
+ guildIdDisplay?: string;
104
+ parentChannelIdDisplay?: string;
105
+ workspaceIdDisplay?: string;
106
+ channelIdDisplay?: string;
107
+ authorizedUserIdDisplay?: string;
108
+ }
109
+
72
110
  export interface NotificationsPreflightResult {
73
111
  status: "ready" | "aborted" | "cancelled" | "error";
74
112
  identity: ProposedTelegramIdentity;
@@ -80,6 +118,7 @@ export interface NotificationsPreflightResult {
80
118
  /** A successful configuration save always carries an opaque CAS receipt. */
81
119
  export type NotificationsConfigureCommitResult =
82
120
  | { status: "saved"; receipt: CasReceipt; message: string }
121
+ | { status: "activation_failed" | "observer_failed"; receipt: CasReceipt; message: string }
83
122
  | {
84
123
  status: "blocked_identity";
85
124
  receipt: CasReceipt;
@@ -91,10 +130,12 @@ export type NotificationsConfigureCommitResult =
91
130
  export interface NotificationsMutationResult {
92
131
  message: string;
93
132
  receipt?: CasReceipt;
133
+ outcome?: "success" | "degraded" | "failed";
94
134
  }
95
135
 
96
136
  export type NotificationsSaveInactiveResult =
97
137
  | { status: "saved_inactive"; receipt: CasReceipt; message: string }
138
+ | { status: "observer_failed"; receipt: CasReceipt; message: string }
98
139
  | { status: "unavailable"; guidance: string };
99
140
 
100
141
  /**
@@ -106,9 +147,13 @@ export type NotificationsSaveInactiveResult =
106
147
  export interface NotificationsEditorOperations {
107
148
  /** Combines a buildNotificationStatusReport-shaped status snapshot with the current session-control query. */
108
149
  loadState(): Promise<NotificationsEditorState>;
109
- /** Performs offline health refreshes and a non-cancellable reachability probe when requested. */
110
- refreshHealth(input: { probe: boolean; signal?: AbortSignal }): Promise<NotificationHealthReport>;
111
- sendTest(): Promise<NotificationTestResult>;
150
+ /** Performs offline or selected REST-only health refresh. */
151
+ refreshHealth(input: {
152
+ probe: boolean;
153
+ provider?: NotificationProvider;
154
+ signal?: AbortSignal;
155
+ }): Promise<NotificationHealthReport>;
156
+ sendTest(provider?: NotificationProvider): Promise<NotificationTestResult>;
112
157
  recover(): Promise<NotificationRecoveryReport>;
113
158
  reconnect(): Promise<TelegramDaemonReconnectOutcome>;
114
159
  /** Validates/discovers a Telegram destination and returns a secret-safe prepared draft. */
@@ -122,6 +167,13 @@ export interface NotificationsEditorOperations {
122
167
  saveInactive(draft: PreparedTelegramConfiguration): Promise<NotificationsSaveInactiveResult>;
123
168
  /** Clears adapter-local ephemeral credential material for a prepared setup draft. Idempotent. */
124
169
  discardConfigureDraft(draft: PreparedTelegramConfiguration): void;
170
+ prepareProviderConfiguration(
171
+ input: NotificationsProviderSetupInput,
172
+ ): Promise<PreparedNotificationProviderConfiguration>;
173
+ commitProviderConfiguration(draft: PreparedNotificationProviderConfiguration): Promise<NotificationsMutationResult>;
174
+ discardProviderConfiguration(draft: PreparedNotificationProviderConfiguration): void;
175
+ setProviderDesired(provider: NotificationProvider, enabled: boolean): Promise<NotificationsMutationResult>;
176
+ removeProvider(provider: NotificationProvider): Promise<NotificationsMutationResult>;
125
177
  /** Enables globally without requesting or exposing a credential. */
126
178
  enableGlobally(): Promise<NotificationsMutationResult>;
127
179
  disableGlobally(): Promise<NotificationsMutationResult>;
@@ -138,16 +190,49 @@ export interface NotificationsSettingsEditorCallbacks {
138
190
  onCancel?: () => void;
139
191
  }
140
192
 
141
- type EditorMode =
193
+ export type EditorMode =
142
194
  | "home"
143
195
  | "provider-selection"
196
+ | "provider-actions"
197
+ | "provider-form"
198
+ | "provider-review"
144
199
  | "chat-entry"
145
200
  | "token-entry"
146
201
  | "pairing"
147
202
  | "review"
148
203
  | "preferences"
149
204
  | "confirmation";
150
- type ConfirmationAction = "disable" | "remove" | "blocked_identity";
205
+ type ConfirmationAction = "disable" | "remove" | "remove-provider" | "blocked_identity";
206
+
207
+ type ProviderActionId =
208
+ | "provider-configure"
209
+ | "provider-toggle"
210
+ | "provider-probe"
211
+ | "provider-test"
212
+ | "provider-remove";
213
+
214
+ type ProviderFormStep =
215
+ | "bot-action"
216
+ | "bot-secret"
217
+ | "app-action"
218
+ | "app-secret"
219
+ | "application-id"
220
+ | "guild-id"
221
+ | "parent-channel-id"
222
+ | "workspace-id"
223
+ | "channel-id"
224
+ | "authorized-user-id";
225
+
226
+ interface ProviderFormDraft {
227
+ botToken?: ProviderSecretActionInput;
228
+ appToken?: ProviderSecretActionInput;
229
+ applicationId?: string;
230
+ guildId?: string;
231
+ parentChannelId?: string;
232
+ workspaceId?: string;
233
+ channelId?: string;
234
+ authorizedUserId?: string;
235
+ }
151
236
 
152
237
  type HomeActionId =
153
238
  | "configure"
@@ -165,7 +250,7 @@ type HomeActionId =
165
250
  | "sound";
166
251
 
167
252
  interface Action {
168
- id: HomeActionId | "telegram" | "external" | "save" | "save-inactive" | "cancel" | "confirm";
253
+ id: HomeActionId | ProviderActionId | NotificationProvider | "save" | "save-inactive" | "cancel" | "confirm";
169
254
  label: string;
170
255
  description: string;
171
256
  }
@@ -180,16 +265,46 @@ function emptyState(): NotificationsEditorState {
180
265
  redact: false,
181
266
  verbosity: "lean",
182
267
  globallyConfigured: false,
183
- telegram: { botTokenMasked: "(not set)", channel: undefined, configured: false, tokenFingerprint: undefined },
184
- discord: { botTokenMasked: "(not set)", channel: undefined, configured: false },
185
- slack: { botTokenMasked: "(not set)", channel: undefined, configured: false },
268
+ anyProviderComplete: false,
269
+ anyProviderEffective: false,
270
+ telegram: {
271
+ botTokenMasked: "(not set)",
272
+ channel: undefined,
273
+ configured: false,
274
+ quarantined: false,
275
+ desiredEnabled: false,
276
+ desiredSource: "legacy",
277
+ effectiveEnabled: false,
278
+ issues: [],
279
+ tokenFingerprint: undefined,
280
+ },
281
+ discord: {
282
+ botTokenMasked: "(not set)",
283
+ channel: undefined,
284
+ configured: false,
285
+ quarantined: false,
286
+ desiredEnabled: false,
287
+ desiredSource: "legacy",
288
+ effectiveEnabled: false,
289
+ issues: [],
290
+ },
291
+ slack: {
292
+ botTokenMasked: "(not set)",
293
+ channel: undefined,
294
+ configured: false,
295
+ quarantined: false,
296
+ desiredEnabled: false,
297
+ desiredSource: "legacy",
298
+ effectiveEnabled: false,
299
+ issues: [],
300
+ },
186
301
  },
187
302
  session: {
188
303
  eligible: false,
189
304
  locallyEnabled: false,
190
- effectiveEnabled: false,
305
+ genericSessionEnabled: false,
306
+ genericEligibilitySource: "none",
191
307
  running: false,
192
- environment: "default",
193
308
  },
194
309
  preferences: {
195
310
  redact: false,
@@ -208,6 +323,12 @@ function statusLabel(level: "ok" | "warn" | "error"): "OK" | "WARNING" | "ERROR"
208
323
  return level === "ok" ? "OK" : level === "warn" ? "WARNING" : "ERROR";
209
324
  }
210
325
 
326
+ function mutationStatus(result: NotificationsMutationResult): "OK" | "WARNING" | "ERROR" {
327
+ if (result.outcome === "degraded") return "WARNING";
328
+ if (result.outcome === "failed") return "ERROR";
329
+ return result.receipt || result.outcome === "success" ? "OK" : "ERROR";
330
+ }
331
+
211
332
  function safeDetail(value: string | undefined, fallback: string): string {
212
333
  const detail = (value ?? fallback)
213
334
  .replace(TOKEN_PATTERN, "<redacted>")
@@ -251,6 +372,10 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
251
372
  #status = "INFO — Loading notification status…";
252
373
  #lastTest: NotificationTestResult | undefined;
253
374
  #prepared: PreparedTelegramConfiguration | undefined;
375
+ #selectedProvider: NotificationProvider = "telegram";
376
+ #providerFormStep: ProviderFormStep = "bot-action";
377
+ #providerFormDraft: ProviderFormDraft = {};
378
+ #providerPrepared: PreparedNotificationProviderConfiguration | undefined;
254
379
  #preflightIdentity: ProposedTelegramIdentity["status"] | undefined;
255
380
  #preferencesDraft: NotificationsEditorPreferences | undefined;
256
381
  #confirmation: ConfirmationAction | undefined;
@@ -258,6 +383,8 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
258
383
  #pairingPhase: "discovery" | "validation" = "discovery";
259
384
  #chatInput = new Input();
260
385
  #tokenInput = new SecretInput();
386
+ #providerTextInput = new Input();
387
+ #providerSecretInput = new SecretInput();
261
388
  #abortController = new AbortController();
262
389
  #cancellableWork: Promise<void> | undefined;
263
390
  #guarded = false;
@@ -275,6 +402,10 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
275
402
  this.#chatInput.onEscape = () => this.#cancelCurrentMode();
276
403
  this.#tokenInput.onSubmit = token => this.#startPreflight(token);
277
404
  this.#tokenInput.onEscape = () => this.#cancelCurrentMode();
405
+ this.#providerTextInput.onSubmit = value => this.#advanceProviderText(value);
406
+ this.#providerTextInput.onEscape = () => this.#cancelCurrentMode();
407
+ this.#providerSecretInput.onSubmit = value => this.#advanceProviderSecret(value);
408
+ this.#providerSecretInput.onEscape = () => this.#cancelCurrentMode();
278
409
  void this.#loadState();
279
410
  }
280
411
 
@@ -291,17 +422,23 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
291
422
  invalidate(): void {
292
423
  this.#chatInput.invalidate();
293
424
  this.#tokenInput.invalidate();
425
+ this.#providerTextInput.invalidate();
426
+ this.#providerSecretInput.invalidate();
294
427
  }
295
428
 
296
429
  dispose(): void {
297
- if (this.#disposed || this.navigationLocked) return;
430
+ if (this.#disposed) return;
298
431
  this.#disposed = true;
299
432
  this.#abortController.abort();
300
433
  this.#clearDrafts(true);
434
+ this.#clearProviderDraft(true);
301
435
  }
302
436
 
303
437
  render(width: number): string[] {
304
438
  if (this.#mode === "provider-selection") return this.#renderProviderSelection(width);
439
+ if (this.#mode === "provider-actions") return this.#renderProviderActions(width);
440
+ if (this.#mode === "provider-form") return this.#renderProviderForm(width);
441
+ if (this.#mode === "provider-review") return this.#renderProviderReview(width);
305
442
  if (this.#mode === "chat-entry") return this.#renderChatEntry(width);
306
443
  if (this.#mode === "token-entry") return this.#renderTokenEntry(width);
307
444
  if (this.#mode === "pairing") return this.#renderPairing(width);
@@ -336,6 +473,11 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
336
473
  this.#tokenInput.handleInput(data);
337
474
  return;
338
475
  }
476
+ if (this.#mode === "provider-form") {
477
+ if (this.#providerStepUsesSecret()) this.#providerSecretInput.handleInput(data);
478
+ else this.#providerTextInput.handleInput(data);
479
+ return;
480
+ }
339
481
  if (this.#mode === "pairing") {
340
482
  if (this.#matchesCancel(data))
341
483
  this.#cancelCancellableWork("Pairing cancelled; no notification configuration was changed.");
@@ -345,6 +487,14 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
345
487
  this.#handleListInput(data, this.#providerActions());
346
488
  return;
347
489
  }
490
+ if (this.#mode === "provider-actions") {
491
+ this.#handleListInput(data, this.#selectedProviderActions());
492
+ return;
493
+ }
494
+ if (this.#mode === "provider-review") {
495
+ this.#handleListInput(data, this.#providerReviewActions());
496
+ return;
497
+ }
348
498
  if (this.#mode === "review") {
349
499
  this.#handleListInput(data, this.#reviewActions());
350
500
  return;
@@ -461,14 +611,36 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
461
611
  this.#status = "INFO — Select a notification provider to begin setup.";
462
612
  return;
463
613
  case "telegram":
464
- this.#mode = "chat-entry";
614
+ case "discord":
615
+ case "slack":
616
+ this.#selectedProvider = id;
617
+ this.#mode = "provider-actions";
465
618
  this.#selectedIndex = 0;
466
- this.#chatInput.setValue("");
467
- this.#tokenInput.clear();
468
- this.#status = "INFO — Enter a private chat ID, or leave it blank for guided pairing discovery.";
619
+ this.#status = `INFO — ${id} provider actions.`;
620
+ return;
621
+ case "provider-configure":
622
+ if (this.#selectedProvider === "telegram") {
623
+ this.#mode = "chat-entry";
624
+ this.#selectedIndex = 0;
625
+ this.#chatInput.setValue("");
626
+ this.#tokenInput.clear();
627
+ this.#status = "INFO — Enter a private chat ID, or leave it blank for guided pairing discovery.";
628
+ } else {
629
+ this.#startProviderForm();
630
+ }
469
631
  return;
470
- case "external":
471
- this.#status = "INFO — Discord and Slack credentials are managed in their respective provider settings.";
632
+ case "provider-toggle":
633
+ this.#setSelectedProviderDesired();
634
+ return;
635
+ case "provider-probe":
636
+ this.#refreshHealth(true, this.#selectedProvider);
637
+ return;
638
+ case "provider-test":
639
+ this.#sendTest(this.#selectedProvider);
640
+ return;
641
+ case "provider-remove":
642
+ if (this.#selectedProvider === "telegram") this.#openConfirmation("remove");
643
+ else this.#openConfirmation("remove-provider");
472
644
  return;
473
645
  case "enable":
474
646
  this.#enableGlobally();
@@ -504,7 +676,8 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
504
676
  this.#status = "INFO — Edit the draft, then explicitly save it atomically.";
505
677
  return;
506
678
  case "save":
507
- this.#commitConfiguration();
679
+ if (this.#mode === "provider-review") this.#commitProviderConfiguration();
680
+ else this.#commitConfiguration();
508
681
  return;
509
682
  case "save-inactive":
510
683
  this.#saveInactive();
@@ -527,6 +700,7 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
527
700
  return;
528
701
  }
529
702
  this.#clearDrafts();
703
+ this.#clearProviderDraft();
530
704
  this.#mode = "home";
531
705
  this.#selectedIndex = 0;
532
706
  this.#status = "INFO — Setup cancelled; saved notification configuration is unchanged.";
@@ -543,6 +717,163 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
543
717
  if (disposeSecret) this.#tokenInput.dispose();
544
718
  }
545
719
 
720
+ #clearProviderDraft(disposeSecret = false): void {
721
+ if (this.#providerPrepared) this.operations.discardProviderConfiguration(this.#providerPrepared);
722
+ this.#providerPrepared = undefined;
723
+ this.#providerFormDraft.botToken?.value?.consume();
724
+ this.#providerFormDraft.appToken?.value?.consume();
725
+ this.#providerFormDraft = {};
726
+ this.#providerFormStep = "bot-action";
727
+ this.#providerTextInput.setValue("");
728
+ this.#providerSecretInput.clear();
729
+ if (disposeSecret) this.#providerSecretInput.dispose();
730
+ }
731
+
732
+ #startProviderForm(): void {
733
+ this.#clearProviderDraft();
734
+ this.#mode = "provider-form";
735
+ this.#providerFormStep = "bot-action";
736
+ this.#providerTextInput.setValue("");
737
+ this.#status = `INFO — Choose ${this.#selectedProvider} bot token action: keep, replace, or remove.`;
738
+ }
739
+
740
+ #providerStepUsesSecret(): boolean {
741
+ return this.#providerFormStep === "bot-secret" || this.#providerFormStep === "app-secret";
742
+ }
743
+
744
+ #normalizedSecretAction(value: string): ProviderSecretDisposition | undefined {
745
+ const action = value.trim().toLowerCase();
746
+ return action === "keep" || action === "replace" || action === "remove" ? action : undefined;
747
+ }
748
+
749
+ #advanceProviderText(value: string): void {
750
+ const trimmed = value.trim();
751
+ this.#providerTextInput.setValue("");
752
+ switch (this.#providerFormStep) {
753
+ case "bot-action": {
754
+ const action = this.#normalizedSecretAction(trimmed);
755
+ if (!action) {
756
+ this.#status = "ERROR — Enter keep, replace, or remove for the bot token.";
757
+ return;
758
+ }
759
+ this.#providerFormDraft.botToken = { action };
760
+ this.#providerFormStep =
761
+ action === "replace"
762
+ ? "bot-secret"
763
+ : this.#selectedProvider === "slack"
764
+ ? "app-action"
765
+ : "application-id";
766
+ this.#status =
767
+ action === "replace"
768
+ ? "INFO — Enter the replacement bot token; only bullets are rendered."
769
+ : this.#selectedProvider === "slack"
770
+ ? "INFO — Choose Slack app token action: keep, replace, or remove."
771
+ : "INFO — Enter the Discord application ID; blank keeps the stored value.";
772
+ return;
773
+ }
774
+ case "app-action": {
775
+ const action = this.#normalizedSecretAction(trimmed);
776
+ if (!action) {
777
+ this.#status = "ERROR — Enter keep, replace, or remove for the Slack app token.";
778
+ return;
779
+ }
780
+ this.#providerFormDraft.appToken = { action };
781
+ this.#providerFormStep = action === "replace" ? "app-secret" : "workspace-id";
782
+ this.#status =
783
+ action === "replace"
784
+ ? "INFO — Enter the replacement Slack app token; only bullets are rendered."
785
+ : "INFO — Enter the Slack workspace ID; blank keeps the stored value.";
786
+ return;
787
+ }
788
+ case "application-id":
789
+ this.#providerFormDraft.applicationId = trimmed || undefined;
790
+ this.#providerFormStep = "guild-id";
791
+ this.#status = "INFO — Enter the Discord guild ID; blank keeps the stored value.";
792
+ return;
793
+ case "guild-id":
794
+ this.#providerFormDraft.guildId = trimmed || undefined;
795
+ this.#providerFormStep = "parent-channel-id";
796
+ this.#status = "INFO — Enter the Discord parent channel ID; blank keeps the stored value.";
797
+ return;
798
+ case "parent-channel-id":
799
+ this.#providerFormDraft.parentChannelId = trimmed || undefined;
800
+ this.#prepareProviderDraft();
801
+ return;
802
+ case "workspace-id":
803
+ this.#providerFormDraft.workspaceId = trimmed || undefined;
804
+ this.#providerFormStep = "channel-id";
805
+ this.#status = "INFO — Enter the Slack channel ID; blank keeps the stored value.";
806
+ return;
807
+ case "channel-id":
808
+ this.#providerFormDraft.channelId = trimmed || undefined;
809
+ this.#providerFormStep = "authorized-user-id";
810
+ this.#status = "INFO — Enter the optional authorized user ID; blank keeps the stored value.";
811
+ return;
812
+ case "authorized-user-id":
813
+ this.#providerFormDraft.authorizedUserId = trimmed || undefined;
814
+ this.#prepareProviderDraft();
815
+ return;
816
+ case "bot-secret":
817
+ case "app-secret":
818
+ return;
819
+ }
820
+ }
821
+
822
+ #advanceProviderSecret(value: SecretValue): void {
823
+ this.#providerSecretInput.clear();
824
+ if (this.#providerFormStep === "bot-secret") {
825
+ this.#providerFormDraft.botToken = { action: "replace", value };
826
+ this.#providerFormStep = this.#selectedProvider === "slack" ? "app-action" : "application-id";
827
+ this.#status =
828
+ this.#selectedProvider === "slack"
829
+ ? "INFO — Choose Slack app token action: keep, replace, or remove."
830
+ : "INFO — Enter the Discord application ID; blank keeps the stored value.";
831
+ return;
832
+ }
833
+ if (this.#providerFormStep === "app-secret") {
834
+ this.#providerFormDraft.appToken = { action: "replace", value };
835
+ this.#providerFormStep = "workspace-id";
836
+ this.#status = "INFO — Enter the Slack workspace ID; blank keeps the stored value.";
837
+ }
838
+ }
839
+
840
+ #prepareProviderDraft(): void {
841
+ const botToken = this.#providerFormDraft.botToken;
842
+ if (!botToken || (this.#selectedProvider === "slack" && !this.#providerFormDraft.appToken)) {
843
+ this.#status = "ERROR — Provider secret actions are incomplete.";
844
+ return;
845
+ }
846
+ const input: NotificationsProviderSetupInput =
847
+ this.#selectedProvider === "discord"
848
+ ? {
849
+ provider: "discord",
850
+ botToken,
851
+ applicationId: this.#providerFormDraft.applicationId,
852
+ guildId: this.#providerFormDraft.guildId,
853
+ parentChannelId: this.#providerFormDraft.parentChannelId,
854
+ }
855
+ : {
856
+ provider: "slack",
857
+ botToken,
858
+ appToken: this.#providerFormDraft.appToken,
859
+ workspaceId: this.#providerFormDraft.workspaceId,
860
+ channelId: this.#providerFormDraft.channelId,
861
+ authorizedUserId: this.#providerFormDraft.authorizedUserId,
862
+ };
863
+ this.#runGuarded(
864
+ `Preparing ${this.#selectedProvider} configuration.`,
865
+ () => this.operations.prepareProviderConfiguration(input),
866
+ draft => {
867
+ this.#providerFormDraft = {};
868
+ this.#providerPrepared = draft;
869
+ this.#mode = "provider-review";
870
+ this.#selectedIndex = 0;
871
+ this.#status = `INFO — Review secret-safe ${draft.provider} configuration before saving.`;
872
+ },
873
+ draft => this.operations.discardProviderConfiguration(draft),
874
+ );
875
+ }
876
+
546
877
  #startPreflight(token: SecretValue): void {
547
878
  const chatId = this.#chatInput.getValue().trim() || undefined;
548
879
  const preferences = this.#state.preferences;
@@ -621,11 +952,11 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
621
952
  this.#cancellableWork = pending;
622
953
  }
623
954
 
624
- #refreshHealth(probe: boolean): void {
955
+ #refreshHealth(probe: boolean, provider?: NotificationProvider): void {
625
956
  if (probe) {
626
957
  this.#runGuarded(
627
958
  "Health probe in progress. It cannot be cancelled once started.",
628
- () => this.operations.refreshHealth({ probe: true }),
959
+ () => this.operations.refreshHealth({ probe: true, provider }),
629
960
  health => {
630
961
  this.#state = { ...this.#state, health };
631
962
  this.#status = `${statusLabel(health.overall)} — ${this.#healthSummary(health)}`;
@@ -635,7 +966,7 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
635
966
  }
636
967
  this.#status = "PENDING — Refreshing notification health.";
637
968
  this.#runCancellable(
638
- signal => this.operations.refreshHealth({ probe: false, signal }),
969
+ signal => this.operations.refreshHealth({ probe: false, provider, signal }),
639
970
  health => {
640
971
  this.#state = { ...this.#state, health };
641
972
  this.#status = `${statusLabel(health.overall)} — ${this.#healthSummary(health)}`;
@@ -643,14 +974,22 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
643
974
  );
644
975
  }
645
976
 
646
- #runGuarded<T>(pending: string, work: () => Promise<T>, complete: (result: T) => Promise<void> | void): void {
977
+ #runGuarded<T>(
978
+ pending: string,
979
+ work: () => Promise<T>,
980
+ complete: (result: T) => Promise<void> | void,
981
+ disposeResult?: (result: T) => Promise<void> | void,
982
+ ): void {
647
983
  if (this.#guarded || this.#disposed) return;
648
984
  this.#guarded = true;
649
985
  this.#status = `PENDING — ${pending} Navigation is locked; it may already have started or delivered.`;
650
986
  void (async () => {
651
987
  try {
652
988
  const result = await work();
653
- if (this.#disposed) return;
989
+ if (this.#disposed) {
990
+ await disposeResult?.(result);
991
+ return;
992
+ }
654
993
  await complete(result);
655
994
  } catch {
656
995
  if (!this.#disposed) {
@@ -663,20 +1002,32 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
663
1002
  }
664
1003
 
665
1004
  async #afterDurableMutation(): Promise<boolean> {
666
- await this.operations.reconcileCurrentSession();
667
- const health = await this.operations.refreshHealth({ probe: false });
668
- if (this.#disposed) return false;
669
- this.#state = { ...this.#state, health };
670
- await this.#loadState();
671
- return !this.#disposed;
1005
+ try {
1006
+ await this.operations.reconcileCurrentSession();
1007
+ const health = await this.operations.refreshHealth({ probe: false });
1008
+ if (this.#disposed) return false;
1009
+ this.#state = { ...this.#state, health };
1010
+ await this.#loadState();
1011
+ return !this.#disposed;
1012
+ } catch {
1013
+ if (!this.#disposed) {
1014
+ this.#status = "WARNING — Configuration was saved, but post-commit reconciliation or refresh failed.";
1015
+ }
1016
+ return false;
1017
+ }
672
1018
  }
673
1019
 
674
1020
  async #refreshAfterOperation(): Promise<boolean> {
675
- const health = await this.operations.refreshHealth({ probe: false });
676
- if (this.#disposed) return false;
677
- this.#state = { ...this.#state, health };
678
- await this.#loadState();
679
- return !this.#disposed;
1021
+ try {
1022
+ const health = await this.operations.refreshHealth({ probe: false });
1023
+ if (this.#disposed) return false;
1024
+ this.#state = { ...this.#state, health };
1025
+ await this.#loadState();
1026
+ return !this.#disposed;
1027
+ } catch {
1028
+ if (!this.#disposed) this.#status = "WARNING — The operation completed, but state refresh failed.";
1029
+ return false;
1030
+ }
680
1031
  }
681
1032
 
682
1033
  #enableGlobally(): void {
@@ -686,7 +1037,56 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
686
1037
  async result => {
687
1038
  if (!(await this.#afterDurableMutation())) return;
688
1039
 
689
- this.#status = `OK — ${safeDetail(result.message, "Global notifications enabled using stored credentials.")}`;
1040
+ this.#status = `${mutationStatus(result)} — ${safeDetail(result.message, "Global notifications enabled using stored credentials.")}`;
1041
+ },
1042
+ );
1043
+ }
1044
+
1045
+ #setSelectedProviderDesired(): void {
1046
+ const view = this.#state.status[this.#selectedProvider];
1047
+ const enabled = !view.desiredEnabled;
1048
+ this.#runGuarded(
1049
+ `${enabled ? "Enabling" : "Disabling"} ${this.#selectedProvider} desired intent.`,
1050
+ () => this.operations.setProviderDesired(this.#selectedProvider, enabled),
1051
+ async result => {
1052
+ if (!(await this.#afterDurableMutation())) return;
1053
+ this.#mode = "provider-actions";
1054
+ this.#selectedIndex = 0;
1055
+ this.#status = `${mutationStatus(result)} — ${safeDetail(result.message, "Provider intent updated.")}`;
1056
+ },
1057
+ );
1058
+ }
1059
+
1060
+ #commitProviderConfiguration(): void {
1061
+ const draft = this.#providerPrepared;
1062
+ if (!draft) {
1063
+ this.#mode = "provider-actions";
1064
+ this.#status = "ERROR — The provider draft expired; re-enter secret actions.";
1065
+ return;
1066
+ }
1067
+ this.#runGuarded(
1068
+ `Saving ${draft.provider} configuration.`,
1069
+ () => this.operations.commitProviderConfiguration(draft),
1070
+ async result => {
1071
+ this.#providerPrepared = undefined;
1072
+ if (!(await this.#afterDurableMutation())) return;
1073
+ this.#mode = "provider-actions";
1074
+ this.#selectedIndex = 0;
1075
+ this.#status = `${mutationStatus(result)} — ${safeDetail(result.message, "Provider configuration updated.")}`;
1076
+ },
1077
+ );
1078
+ }
1079
+
1080
+ #removeSelectedProvider(): void {
1081
+ const provider = this.#selectedProvider;
1082
+ this.#runGuarded(
1083
+ `Removing ${provider} configuration.`,
1084
+ () => this.operations.removeProvider(provider),
1085
+ async result => {
1086
+ if (!(await this.#afterDurableMutation())) return;
1087
+ this.#mode = "home";
1088
+ this.#selectedIndex = 0;
1089
+ this.#status = `${mutationStatus(result)} — ${safeDetail(result.message, "Provider removal completed.")}`;
690
1090
  },
691
1091
  );
692
1092
  }
@@ -702,7 +1102,7 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
702
1102
  if (!(await this.#afterDurableMutation())) return;
703
1103
  this.#mode = "home";
704
1104
  this.#selectedIndex = 0;
705
- this.#status = `OK — ${safeDetail(result.message, "Notifications disabled globally.")}`;
1105
+ this.#status = `${mutationStatus(result)} — ${safeDetail(result.message, "Notifications disabled globally.")}`;
706
1106
  },
707
1107
  );
708
1108
  return;
@@ -715,15 +1115,14 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
715
1115
  if (!(await this.#afterDurableMutation())) return;
716
1116
  this.#mode = "home";
717
1117
  this.#selectedIndex = 0;
718
- this.#status = `OK — ${safeDetail(
1118
+ this.#status = `${mutationStatus(result)} — ${safeDetail(
719
1119
  result.message,
720
- result.globallyDisabled
721
- ? "Telegram removed and global notifications disabled."
722
- : "Telegram removed; other adapters remain enabled.",
1120
+ "Telegram removed without changing the global master or sibling providers.",
723
1121
  )}`;
724
1122
  },
725
1123
  );
726
1124
  }
1125
+ if (action === "remove-provider") this.#removeSelectedProvider();
727
1126
  }
728
1127
 
729
1128
  #restoreBlockedConfiguration(): void {
@@ -782,8 +1181,10 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
782
1181
  this.#selectedIndex = 1;
783
1182
  this.#status =
784
1183
  action === "disable"
785
- ? "WARNING — Disable globally stops configured adapters. Confirm explicitly to continue."
786
- : "WARNING — Remove only Telegram credentials. Discord and Slack remain unchanged.";
1184
+ ? "WARNING — Disable globally preserves provider configuration and desired intent but stops active runtimes. Confirm explicitly."
1185
+ : action === "remove-provider"
1186
+ ? `WARNING — Remove only ${this.#selectedProvider} configuration and set its desired intent off. Siblings remain unchanged.`
1187
+ : "WARNING — Remove only Telegram credentials. Discord and Slack remain unchanged.";
787
1188
  }
788
1189
 
789
1190
  #setSessionLocal(enabled: boolean): void {
@@ -804,15 +1205,15 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
804
1205
  );
805
1206
  }
806
1207
 
807
- #sendTest(): void {
1208
+ #sendTest(provider?: NotificationProvider): void {
808
1209
  this.#runGuarded(
809
1210
  "Sending a notification test.",
810
- () => this.operations.sendTest(),
1211
+ () => this.operations.sendTest(provider),
811
1212
  async result => {
812
1213
  this.#lastTest = result;
813
1214
  if (!(await this.#refreshAfterOperation())) return;
814
1215
 
815
- this.#status = `${result.ok ? "OK" : "ERROR"} — Test ${result.ok ? "delivered" : "failed"}: ${safeDetail(
1216
+ this.#status = `${result.ok ? "OK" : result.uncertain ? "WARNING" : "ERROR"} — Test ${result.ok ? "delivered" : result.uncertain ? "outcome uncertain" : "failed"}: ${safeDetail(
816
1217
  result.detail,
817
1218
  "No delivery detail returned.",
818
1219
  )}`;
@@ -864,7 +1265,6 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
864
1265
  this.#clearDrafts();
865
1266
  if (result.status === "blocked_identity") {
866
1267
  this.#blockedCommit = result;
867
- if (!(await this.#refreshAfterOperation())) return;
868
1268
  this.#confirmation = "blocked_identity";
869
1269
  this.#mode = "confirmation";
870
1270
  this.#selectedIndex = 0;
@@ -872,6 +1272,14 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
872
1272
  result.message,
873
1273
  "Configuration saved but activation blocked by a foreign daemon.",
874
1274
  )}`;
1275
+ await this.#refreshAfterOperation();
1276
+ return;
1277
+ }
1278
+ if (result.status === "activation_failed" || result.status === "observer_failed") {
1279
+ if (!(await this.#afterDurableMutation())) return;
1280
+ this.#mode = "home";
1281
+ this.#selectedIndex = 0;
1282
+ this.#status = `WARNING — ${safeDetail(result.message, "Telegram configuration was saved with a post-commit failure.")}`;
875
1283
  return;
876
1284
  }
877
1285
  if (!(await this.#afterDurableMutation())) return;
@@ -899,7 +1307,7 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
899
1307
  this.#clearDrafts();
900
1308
  this.#mode = "home";
901
1309
  this.#selectedIndex = 0;
902
- this.#status = `OK — ${safeDetail(result.message, "Telegram configuration saved inactive; foreign daemon untouched.")}`;
1310
+ this.#status = `${result.status === "observer_failed" ? "WARNING" : "OK"} — ${safeDetail(result.message, "Telegram configuration saved inactive; foreign daemon untouched.")}`;
903
1311
  },
904
1312
  );
905
1313
  }
@@ -958,20 +1366,62 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
958
1366
  {
959
1367
  id: "telegram",
960
1368
  label: "Telegram",
961
- description: "Configure a masked bot token and optionally validate or discover a private-chat destination.",
1369
+ description: "Configure pairing, desired intent, health, tests, and runtime state.",
962
1370
  },
963
1371
  {
964
- id: "external",
965
- label: "Discord (managed elsewhere)",
966
- description:
967
- "Discord credentials are configured by the Discord provider integration, not this Telegram setup flow.",
1372
+ id: "discord",
1373
+ label: "Discord",
1374
+ description: "Configure the bot token and Discord routing IDs in this Settings surface.",
968
1375
  },
969
1376
  {
970
- id: "external",
971
- label: "Slack (managed elsewhere)",
1377
+ id: "slack",
1378
+ label: "Slack",
1379
+ description: "Configure bot/app tokens and Slack routing IDs in this Settings surface.",
1380
+ },
1381
+ ];
1382
+ }
1383
+
1384
+ #selectedProviderActions(): readonly Action[] {
1385
+ const view = this.#state.status[this.#selectedProvider];
1386
+ return [
1387
+ {
1388
+ id: "provider-configure",
1389
+ label: view.quarantined
1390
+ ? `Repair ${this.#selectedProvider}`
1391
+ : view.configured
1392
+ ? `Edit ${this.#selectedProvider}`
1393
+ : `Configure ${this.#selectedProvider}`,
1394
+ description: "Use explicit keep, replace, or remove secret actions and selected-provider CAS persistence.",
1395
+ },
1396
+ {
1397
+ id: "provider-toggle",
1398
+ label: `${view.desiredEnabled ? "Disable" : "Enable"} ${this.#selectedProvider} desired intent`,
1399
+ description: "Changes only this provider's durable desired intent; the global master stays separate.",
1400
+ },
1401
+ {
1402
+ id: "provider-probe",
1403
+ label: `Probe ${this.#selectedProvider}`,
1404
+ description: "Run the selected provider's REST-only diagnostic without opening a socket transport.",
1405
+ },
1406
+ {
1407
+ id: "provider-test",
1408
+ label: `Test ${this.#selectedProvider}`,
1409
+ description: "Send one selected-provider test and report delivered, failed, or uncertain truthfully.",
1410
+ },
1411
+ {
1412
+ id: "provider-remove",
1413
+ label: `Remove ${this.#selectedProvider}`,
972
1414
  description:
973
- "Slack credentials are configured by the Slack provider integration, not this Telegram setup flow.",
1415
+ "Remove only this provider after explicit confirmation; siblings and the global master remain unchanged.",
974
1416
  },
1417
+ { id: "cancel", label: "Back to Notifications", description: "Return without changing settings." },
1418
+ ];
1419
+ }
1420
+
1421
+ #providerReviewActions(): readonly Action[] {
1422
+ return [
1423
+ { id: "save", label: "Save and activate", description: "Commit one selected-provider batch, then activate." },
1424
+ { id: "cancel", label: "Discard draft", description: "Clear all transient secret capabilities." },
975
1425
  ];
976
1426
  }
977
1427
 
@@ -979,9 +1429,8 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
979
1429
  return [
980
1430
  {
981
1431
  id: "configure",
982
- label: this.#state.status.telegram.configured ? "Reconfigure Telegram" : "Configure Telegram",
983
- description:
984
- "Enter a masked Telegram credential and optionally a private-chat destination; guided discovery is available.",
1432
+ label: "Configure notification providers",
1433
+ description: "Set up or repair Telegram, Discord, and Slack.",
985
1434
  },
986
1435
  {
987
1436
  id: "enable",
@@ -1008,12 +1457,12 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
1008
1457
  {
1009
1458
  id: "probe",
1010
1459
  label: "Probe health",
1011
- description: "Optionally check Telegram reachability; once started, the probe runs to completion.",
1460
+ description: "Infer exactly one effective provider or require an explicit provider selection.",
1012
1461
  },
1013
1462
  {
1014
1463
  id: "test",
1015
1464
  label: "Send test notification",
1016
- description: "Sends one test; it may already be delivered once started.",
1465
+ description: "Infer exactly one effective provider or require an explicit provider selection.",
1017
1466
  },
1018
1467
  {
1019
1468
  id: "recover",
@@ -1119,7 +1568,7 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
1119
1568
  const lines = [theme.bold(theme.fg("accent", "Choose a notification provider"))];
1120
1569
  this.#appendWrapped(
1121
1570
  lines,
1122
- "Select a provider to configure. Telegram setup uses a masked token and an optional private-chat ID; Discord and Slack credentials are managed by their provider integrations.",
1571
+ "Select Telegram, Discord, or Slack for configuration, desired intent, health, test, and removal actions.",
1123
1572
  width,
1124
1573
  "muted",
1125
1574
  );
@@ -1128,6 +1577,95 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
1128
1577
  return lines;
1129
1578
  }
1130
1579
 
1580
+ #renderProviderActions(width: number): string[] {
1581
+ const view = this.#state.status[this.#selectedProvider];
1582
+ const lines = [theme.bold(theme.fg("accent", `${this.#selectedProvider} notification settings`))];
1583
+ this.#appendWrapped(
1584
+ lines,
1585
+ `configured: ${view.configured ? "yes" : "no"} · repair: ${view.quarantined ? "required" : "no"} · desired: ${view.desiredEnabled ? "on" : "off"} · effective: ${view.effectiveEnabled ? "yes" : "no"} · destination: ${view.channel ?? "(unset)"}`,
1586
+ width,
1587
+ "muted",
1588
+ );
1589
+ if (view.issues.length > 0) {
1590
+ this.#appendWrapped(
1591
+ lines,
1592
+ `issues: ${view.issues.map(issue => `${issue.path}:${issue.code}`).join(", ")}`,
1593
+ width,
1594
+ "muted",
1595
+ );
1596
+ }
1597
+ lines.push("");
1598
+ this.#renderActionList(lines, width, this.#selectedProviderActions());
1599
+ return lines;
1600
+ }
1601
+
1602
+ #providerFormPrompt(): string {
1603
+ switch (this.#providerFormStep) {
1604
+ case "bot-action":
1605
+ return "Bot token action (keep | replace | remove)";
1606
+ case "bot-secret":
1607
+ return "Replacement bot token";
1608
+ case "app-action":
1609
+ return "Slack app token action (keep | replace | remove)";
1610
+ case "app-secret":
1611
+ return "Replacement Slack app token";
1612
+ case "application-id":
1613
+ return "Discord application ID (blank keeps stored)";
1614
+ case "guild-id":
1615
+ return "Discord guild ID (blank keeps stored)";
1616
+ case "parent-channel-id":
1617
+ return "Discord parent channel ID (blank keeps stored)";
1618
+ case "workspace-id":
1619
+ return "Slack workspace ID (blank keeps stored)";
1620
+ case "channel-id":
1621
+ return "Slack channel ID (blank keeps stored)";
1622
+ case "authorized-user-id":
1623
+ return "Slack authorized user ID (optional; blank keeps stored)";
1624
+ }
1625
+ }
1626
+
1627
+ #renderProviderForm(width: number): string[] {
1628
+ const lines = [theme.bold(theme.fg("accent", `${this.#selectedProvider} configuration`))];
1629
+ this.#appendWrapped(lines, this.#providerFormPrompt(), width, "muted");
1630
+ lines.push("");
1631
+ if (this.#providerStepUsesSecret()) {
1632
+ this.#providerSecretInput.focused = this.focused;
1633
+ for (const line of this.#providerSecretInput.render(width)) lines.push(line);
1634
+ } else {
1635
+ this.#providerTextInput.focused = this.focused;
1636
+ for (const line of this.#providerTextInput.render(width)) lines.push(line);
1637
+ }
1638
+ lines.push("");
1639
+ this.#appendStatus(lines, width);
1640
+ lines.push(theme.fg("dim", " Enter to continue · Esc to discard all transient provider secrets"));
1641
+ return lines;
1642
+ }
1643
+
1644
+ #renderProviderReview(width: number): string[] {
1645
+ const draft = this.#providerPrepared;
1646
+ const lines = [
1647
+ theme.bold(theme.fg("accent", `Review ${draft?.provider ?? this.#selectedProvider} configuration`)),
1648
+ ];
1649
+ if (draft) {
1650
+ lines.push(
1651
+ this.#truncate(
1652
+ ` bot token: ${draft.botTokenDisposition} ${maskedToken(draft.botTokenMask, "(unset)")}${draft.appTokenDisposition ? ` · app token: ${draft.appTokenDisposition} ${maskedToken(draft.appTokenMask, "(unset)")}` : ""}`,
1653
+ width,
1654
+ ),
1655
+ );
1656
+ const routing =
1657
+ draft.provider === "discord"
1658
+ ? `application ${draft.applicationIdDisplay ?? "(unset)"} · guild ${draft.guildIdDisplay ?? "(unset)"} · parent ${draft.parentChannelIdDisplay ?? "(unset)"}`
1659
+ : `workspace ${draft.workspaceIdDisplay ?? "(unset)"} · channel ${draft.channelIdDisplay ?? "(unset)"} · authorized user ${draft.authorizedUserIdDisplay ?? "(unset)"}`;
1660
+ lines.push(this.#truncate(` ${routing}`, width));
1661
+ }
1662
+ lines.push("");
1663
+ this.#appendStatus(lines, width);
1664
+ lines.push("");
1665
+ this.#renderActionList(lines, width, this.#providerReviewActions());
1666
+ return lines;
1667
+ }
1668
+
1131
1669
  #renderChatEntry(width: number): string[] {
1132
1670
  const lines = [theme.bold(theme.fg("accent", "Telegram setup — private chat ID (optional)"))];
1133
1671
  this.#appendWrapped(
@@ -1222,14 +1760,15 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
1222
1760
  #renderConfirmation(width: number): string[] {
1223
1761
  const blocked = this.#confirmation === "blocked_identity";
1224
1762
  const removing = this.#confirmation === "remove";
1763
+ const removingProvider = this.#confirmation === "remove-provider";
1225
1764
  const lines = [
1226
1765
  theme.bold(
1227
1766
  theme.fg(
1228
1767
  "accent",
1229
1768
  blocked
1230
1769
  ? "Telegram activation blocked by foreign daemon"
1231
- : removing
1232
- ? "Remove Telegram configuration?"
1770
+ : removing || removingProvider
1771
+ ? `Remove ${removingProvider ? this.#selectedProvider : "Telegram"} configuration?`
1233
1772
  : "Disable notifications globally?",
1234
1773
  ),
1235
1774
  ),
@@ -1238,9 +1777,9 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
1238
1777
  lines,
1239
1778
  blocked
1240
1779
  ? "Configuration saved but activation blocked by a foreign daemon. Restore the CAS-protected previous configuration, or keep the saved configuration inactive."
1241
- : removing
1242
- ? "This removes only Telegram credentials. Configured Discord and Slack adapters remain unchanged."
1243
- : "This disables all globally configured notification adapters. It does not change a session-local preference.",
1780
+ : removing || removingProvider
1781
+ ? `This removes only ${removingProvider ? this.#selectedProvider : "Telegram"} configuration and sets that provider's desired intent off. Siblings and the global master remain unchanged.`
1782
+ : "This disables the global master while preserving every provider's configuration and desired intent.",
1244
1783
  width,
1245
1784
  "muted",
1246
1785
  );
@@ -1274,30 +1813,36 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
1274
1813
  const lines = [theme.bold(theme.fg("accent", "Notifications"))];
1275
1814
  lines.push(
1276
1815
  this.#truncate(
1277
- ` Global: ${status.enabled ? "enabled" : "disabled"} · configured: ${status.globallyConfigured ? "yes" : "no"} · Telegram: ${
1278
- status.telegram.configured ? "configured" : "not configured"
1279
- }`,
1280
- width,
1281
- ),
1282
- );
1283
- lines.push(
1284
- this.#truncate(
1285
- ` Session: ${session.effectiveEnabled ? "ACTIVE" : "inactive"} · local: ${session.locallyEnabled ? "on" : "off"} · runtime: ${
1286
- session.running ? "running" : "stopped"
1287
- } · environment: ${session.environment}`,
1816
+ ` Global master: ${status.enabled ? "enabled" : "disabled"} · any provider effective: ${status.anyProviderEffective ? "yes" : "no"}`,
1288
1817
  width,
1289
1818
  ),
1290
1819
  );
1291
- if (width >= 100) {
1820
+ for (const provider of ["telegram", "discord", "slack"] as const) {
1821
+ const view = status[provider];
1822
+ const checkedHealth = health?.provider === provider ? statusLabel(health.overall) : "not checked";
1823
+ const test =
1824
+ this.#lastTest?.adapter === provider
1825
+ ? this.#lastTest.ok
1826
+ ? "OK"
1827
+ : this.#lastTest.uncertain
1828
+ ? "UNCERTAIN"
1829
+ : "ERROR"
1830
+ : "not run";
1292
1831
  lines.push(
1293
1832
  this.#truncate(
1294
- ` Telegram identity: ${status.telegram.botTokenMasked} · fingerprint: ${status.telegram.tokenFingerprint ?? "(not set)"} · chat: ${
1295
- status.telegram.channel ?? "(not set)"
1296
- }`,
1833
+ ` ${provider}: ${view.quarantined ? "NEEDS REPAIR" : view.configured ? "configured" : "not configured"} · desired ${view.desiredEnabled ? "on" : "off"} (${view.desiredSource}) · effective ${view.effectiveEnabled ? "yes" : "no"} · runtime ${view.runtime ?? "inactive"} · health ${checkedHealth} · test ${test} · destination ${view.channel ?? "(unset)"}`,
1297
1834
  width,
1298
1835
  ),
1299
1836
  );
1300
1837
  }
1838
+ lines.push(
1839
+ this.#truncate(
1840
+ ` Session: ${session.genericSessionEnabled ? "ACTIVE" : "inactive"} · local: ${session.locallyEnabled ? "on" : "off"} · runtime: ${
1841
+ session.running ? "running" : "stopped"
1842
+ } · admission: ${session.genericEligibilitySource}`,
1843
+ width,
1844
+ ),
1845
+ );
1301
1846
  if (health && width >= 120) {
1302
1847
  lines.push(
1303
1848
  this.#truncate(
@@ -1327,7 +1872,7 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
1327
1872
  if (this.#lastTest && width >= 120) {
1328
1873
  lines.push(
1329
1874
  this.#truncate(
1330
- ` Last in-editor test: ${this.#lastTest.ok ? "OK" : "ERROR"} — ${safeDetail(this.#lastTest.detail, "No detail returned.")}`,
1875
+ ` Last in-editor test: ${this.#lastTest.ok ? "OK" : this.#lastTest.uncertain ? "UNCERTAIN" : "ERROR"} — ${safeDetail(this.#lastTest.detail, "No detail returned.")}`,
1331
1876
  width,
1332
1877
  ),
1333
1878
  );
@@ -1338,11 +1883,12 @@ export class NotificationsSettingsEditorComponent implements Component, Focusabl
1338
1883
  #renderActionList(lines: string[], width: number, actions: readonly Action[]): void {
1339
1884
  if (actions.length === 0) return;
1340
1885
  this.#selectedIndex = Math.max(0, Math.min(this.#selectedIndex, actions.length - 1));
1886
+ const visibleActions = width < 100 ? 2 : VISIBLE_ACTIONS;
1341
1887
  const start = Math.max(
1342
1888
  0,
1343
- Math.min(this.#selectedIndex - Math.floor(VISIBLE_ACTIONS / 2), actions.length - VISIBLE_ACTIONS),
1889
+ Math.min(this.#selectedIndex - Math.floor(visibleActions / 2), actions.length - visibleActions),
1344
1890
  );
1345
- const end = Math.min(actions.length, start + VISIBLE_ACTIONS);
1891
+ const end = Math.min(actions.length, start + visibleActions);
1346
1892
  for (let index = start; index < end; index += 1) {
1347
1893
  const action = actions[index];
1348
1894
  if (!action) continue;