@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
@@ -20,14 +20,25 @@ import type { Settings } from "../../config/settings";
20
20
  import { isProcessIncarnation, processIncarnation } from "../broker/process-incarnation";
21
21
  import {
22
22
  getNotificationConfig,
23
- isDiscordConfigured,
24
- isGloballyConfigured,
25
- isTelegramConfigured,
23
+ hasAnyCompleteProvider,
24
+ hasAnyEffectivelyEnabledProvider,
25
+ isDiscordComplete,
26
+ isProviderEffectivelyEnabled,
27
+ isSlackComplete,
28
+ isTelegramComplete,
26
29
  maskToken,
27
30
  type NotificationConfig,
31
+ type NotificationProvider,
32
+ type NotificationRuntime,
33
+ type ProviderResolution,
34
+ resolveNotificationProvider,
28
35
  tokenFingerprint,
29
36
  } from "./config";
30
37
  import { type DaemonPaths, daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
38
+ import { DiscordLiveProvider } from "./discord-live-provider";
39
+ import type { DiscordDiagnosticProvider } from "./discord-provider";
40
+ import { SlackLiveProvider } from "./slack-live-provider";
41
+ import type { SlackDiagnosticProvider } from "./slack-provider";
31
42
  import { type OwnerFreshnessSnapshot, readOwnerFreshnessSnapshot, type TelegramDaemonFs } from "./telegram-daemon";
32
43
  import { DAEMON_GENERATION } from "./telegram-daemon-contract";
33
44
 
@@ -52,6 +63,18 @@ export function sanitizeDiagnostic(text: string, token?: string): string {
52
63
  return out.replace(TELEGRAM_TOKEN_PATTERN, "<redacted>");
53
64
  }
54
65
 
66
+ function sanitizeProviderDiagnostic(text: string, cfg: NotificationConfig, provider: NotificationProvider): string {
67
+ const secrets =
68
+ provider === "telegram"
69
+ ? [cfg.botToken]
70
+ : provider === "discord"
71
+ ? [cfg.discord.botToken]
72
+ : [cfg.slack.botToken, cfg.slack.appToken];
73
+ let detail = text;
74
+ for (const secret of secrets) detail = sanitizeDiagnostic(detail, secret);
75
+ return detail;
76
+ }
77
+
55
78
  /** Identity evidence required to remove precisely the endpoint that was inspected. */
56
79
  export interface NotificationEndpointFileIdentity {
57
80
  dev: bigint;
@@ -161,6 +184,9 @@ export interface NotificationServiceDeps {
161
184
  pidAlive?: (pid: number) => boolean;
162
185
  fetchImpl?: typeof fetch;
163
186
  apiBase?: string;
187
+ createDiscordDiagnostic?: (config: { applicationId: string; botToken: string }) => DiscordDiagnosticProvider;
188
+ createSlackDiagnostic?: (config: { appToken: string; botToken: string }) => SlackDiagnosticProvider;
189
+ providerRuntimeStatus?: (provider: NotificationProvider) => Promise<NotificationRuntime> | NotificationRuntime;
164
190
  }
165
191
 
166
192
  function defaultPidAlive(pid: number): boolean {
@@ -187,6 +213,12 @@ export interface AdapterConfigView {
187
213
  botTokenMasked: string;
188
214
  channel: string | undefined;
189
215
  configured: boolean;
216
+ quarantined: boolean;
217
+ desiredEnabled: boolean;
218
+ desiredSource: ProviderResolution["desiredSource"];
219
+ effectiveEnabled: boolean;
220
+ issues: ProviderResolution["issues"];
221
+ runtime?: NotificationRuntime;
190
222
  }
191
223
 
192
224
  export interface NotificationStatusReport {
@@ -194,13 +226,30 @@ export interface NotificationStatusReport {
194
226
  redact: boolean;
195
227
  verbosity: "lean" | "verbose";
196
228
  globallyConfigured: boolean;
229
+ anyProviderComplete: boolean;
230
+ anyProviderEffective: boolean;
197
231
  telegram: AdapterConfigView & { tokenFingerprint: string | undefined };
198
232
  discord: AdapterConfigView;
199
233
  slack: AdapterConfigView;
200
234
  }
201
235
 
202
- function adapterConfigured(token: string | undefined, channel: string | undefined): boolean {
203
- return Boolean(token?.trim()) && Boolean(channel?.trim());
236
+ function adapterView(
237
+ cfg: NotificationConfig,
238
+ provider: NotificationProvider,
239
+ token: string | undefined,
240
+ channel: string | undefined,
241
+ ): AdapterConfigView {
242
+ const resolution = resolveNotificationProvider(cfg, provider);
243
+ return {
244
+ botTokenMasked: maskToken(token),
245
+ channel,
246
+ configured: resolution.configured,
247
+ quarantined: resolution.quarantined,
248
+ desiredEnabled: resolution.desiredEnabled,
249
+ desiredSource: resolution.desiredSource,
250
+ effectiveEnabled: resolution.effectiveEnabled,
251
+ issues: resolution.issues,
252
+ };
204
253
  }
205
254
 
206
255
  /** Build a secret-safe structured status snapshot from settings. */
@@ -210,43 +259,40 @@ export function buildNotificationStatusReport(settings: Settings): NotificationS
210
259
  enabled: cfg.enabled,
211
260
  redact: cfg.redact,
212
261
  verbosity: cfg.verbosity,
213
- globallyConfigured: isGloballyConfigured(cfg),
262
+ globallyConfigured: cfg.enabled && hasAnyCompleteProvider(cfg),
263
+ anyProviderComplete: hasAnyCompleteProvider(cfg),
264
+ anyProviderEffective: hasAnyEffectivelyEnabledProvider(cfg),
214
265
  telegram: {
215
- botTokenMasked: maskToken(cfg.botToken),
216
- channel: cfg.chatId,
217
- configured: isTelegramConfigured(cfg),
266
+ ...adapterView(cfg, "telegram", cfg.botToken, cfg.chatId),
218
267
  tokenFingerprint: cfg.botToken?.trim() ? tokenFingerprint(cfg.botToken) : undefined,
219
268
  },
220
- discord: {
221
- botTokenMasked: maskToken(cfg.discord.botToken),
222
- channel: cfg.discord.parentChannelId,
223
- configured: isDiscordConfigured(cfg),
224
- },
225
- slack: {
226
- botTokenMasked: maskToken(cfg.slack.botToken),
227
- channel: cfg.slack.channelId,
228
- configured: adapterConfigured(cfg.slack.botToken, cfg.slack.channelId),
229
- },
269
+ discord: adapterView(cfg, "discord", cfg.discord.botToken, cfg.discord.parentChannelId),
270
+ slack: adapterView(cfg, "slack", cfg.slack.botToken, cfg.slack.channelId),
230
271
  };
231
272
  }
232
273
 
233
274
  /** Render a status report as human-readable lines (no secrets). */
234
275
  export function formatNotificationStatusReport(report: NotificationStatusReport): string {
235
- const yesNo = (v: boolean): string => (v ? "yes" : "no");
276
+ const yesNo = (value: boolean): string => (value ? "yes" : "no");
277
+ const provider = (name: NotificationProvider, view: AdapterConfigView): string[] => [
278
+ ` ${name}.configured: ${yesNo(view.configured)}`,
279
+ ` ${name}.needsRepair: ${yesNo(view.quarantined)}`,
280
+ ` ${name}.desired: ${view.desiredEnabled ? "on" : "off"} (${view.desiredSource})`,
281
+ ` ${name}.effective: ${yesNo(view.effectiveEnabled)}`,
282
+ ` ${name}.botToken: ${view.botTokenMasked}`,
283
+ ` ${name}.destination: ${view.channel ?? "(unset)"}`,
284
+ ];
236
285
  return [
237
286
  "Notifications",
238
287
  ` enabled: ${report.enabled}`,
239
- ` globally configured: ${yesNo(report.globallyConfigured)}`,
288
+ ` any provider effective: ${yesNo(report.anyProviderEffective)}`,
289
+ ` any provider complete: ${yesNo(report.anyProviderComplete)}`,
240
290
  ` redact: ${report.redact}`,
241
291
  ` verbosity: ${report.verbosity}`,
242
- ` telegram.botToken: ${report.telegram.botTokenMasked}`,
243
- ` telegram.chatId: ${report.telegram.channel ?? "(unset)"}`,
292
+ ...provider("telegram", report.telegram),
244
293
  ` telegram.fingerprint: ${report.telegram.tokenFingerprint ?? "(unset)"}`,
245
- ` telegram.configured: ${yesNo(report.telegram.configured)}`,
246
- ` discord.botToken: ${report.discord.botTokenMasked}`,
247
- ` discord.parentChannelId: ${report.discord.channel ?? "(unset)"}`,
248
- ` slack.botToken: ${report.slack.botTokenMasked}`,
249
- ` slack.channelId: ${report.slack.channel ?? "(unset)"}`,
294
+ ...provider("discord", report.discord),
295
+ ...provider("slack", report.slack),
250
296
  ].join("\n");
251
297
  }
252
298
 
@@ -508,6 +554,8 @@ export interface EndpointHealth {
508
554
  export interface NotificationHealthReport {
509
555
  overall: HealthLevel;
510
556
  configured: boolean;
557
+ provider?: NotificationProvider;
558
+ resolution?: ProviderResolution;
511
559
  checks: HealthCheck[];
512
560
  daemon: DaemonHealth;
513
561
  endpoints: EndpointHealth;
@@ -518,20 +566,25 @@ export interface HealthOptions {
518
566
  settings: Settings;
519
567
  stateRoot?: string;
520
568
  deps?: NotificationServiceDeps;
521
- /** When true and Telegram is configured, probe the Bot API (getMe) for reachability. */
569
+ provider?: NotificationProvider;
570
+ /** When true, use the selected provider's REST-only diagnostic path. */
522
571
  probe?: boolean;
572
+ signal?: AbortSignal;
523
573
  }
524
574
 
525
575
  async function probeTelegramReachability(
526
576
  fetchImpl: typeof fetch,
527
577
  apiBase: string,
528
578
  token: string,
579
+ signal?: AbortSignal,
529
580
  ): Promise<{ ok: boolean; detail: string }> {
581
+ if (signal?.aborted) return { ok: false, detail: "Telegram probe cancelled." };
530
582
  try {
531
583
  const response = await fetchImpl(`${apiBase.replace(/\/$/, "")}/bot${token}/getMe`, {
532
584
  method: "POST",
533
585
  headers: { "content-type": "application/json" },
534
586
  body: "{}",
587
+ signal,
535
588
  });
536
589
  const payload = (await response.json().catch(() => undefined)) as
537
590
  | { ok?: boolean; description?: string; result?: { username?: string } }
@@ -544,8 +597,78 @@ async function probeTelegramReachability(
544
597
  ok: false,
545
598
  detail: sanitizeDiagnostic(payload?.description ?? `Telegram getMe failed (HTTP ${response.status})`, token),
546
599
  };
547
- } catch (err) {
548
- return { ok: false, detail: sanitizeDiagnostic(err instanceof Error ? err.message : "network error", token) };
600
+ } catch (error) {
601
+ if (signal?.aborted) return { ok: false, detail: "Telegram probe cancelled." };
602
+ return { ok: false, detail: sanitizeDiagnostic(error instanceof Error ? error.message : "network error", token) };
603
+ }
604
+ }
605
+
606
+ function effectiveProviders(cfg: NotificationConfig): NotificationProvider[] {
607
+ return (["telegram", "discord", "slack"] as const).filter(provider => isProviderEffectivelyEnabled(cfg, provider));
608
+ }
609
+
610
+ function selectNotificationProvider(
611
+ cfg: NotificationConfig,
612
+ requested: NotificationProvider | undefined,
613
+ ): NotificationProvider | undefined {
614
+ if (requested) return requested;
615
+ const effective = effectiveProviders(cfg);
616
+ return effective.length === 1 ? effective[0] : undefined;
617
+ }
618
+
619
+ async function probeSelectedProvider(
620
+ cfg: NotificationConfig,
621
+ provider: NotificationProvider,
622
+ deps: NotificationServiceDeps,
623
+ signal?: AbortSignal,
624
+ ): Promise<{ ok: boolean; detail: string }> {
625
+ try {
626
+ let result: { ok: boolean; detail: string };
627
+ if (provider === "telegram" && isTelegramComplete(cfg)) {
628
+ result = await probeTelegramReachability(
629
+ deps.fetchImpl ?? globalThis.fetch,
630
+ deps.apiBase ?? DEFAULT_API_BASE,
631
+ cfg.botToken,
632
+ signal,
633
+ );
634
+ } else if (provider === "discord" && isDiscordComplete(cfg)) {
635
+ const adapter =
636
+ deps.createDiscordDiagnostic?.({
637
+ applicationId: cfg.discord.applicationId,
638
+ botToken: cfg.discord.botToken,
639
+ }) ??
640
+ new DiscordLiveProvider({
641
+ applicationId: cfg.discord.applicationId,
642
+ botToken: cfg.discord.botToken,
643
+ fetchImpl: deps.fetchImpl,
644
+ });
645
+ result = await adapter.probeConfiguration(signal);
646
+ } else if (provider === "slack" && isSlackComplete(cfg)) {
647
+ const adapter =
648
+ deps.createSlackDiagnostic?.({ appToken: cfg.slack.appToken, botToken: cfg.slack.botToken }) ??
649
+ new SlackLiveProvider({
650
+ appToken: cfg.slack.appToken,
651
+ botToken: cfg.slack.botToken,
652
+ fetch: deps.fetchImpl,
653
+ });
654
+ const probe = await adapter.probeConfiguration(signal);
655
+ result =
656
+ probe.ok && (!probe.teamId || probe.teamId !== cfg.slack.workspaceId)
657
+ ? { ok: false, detail: "Slack workspace identity does not match the configured workspace ID." }
658
+ : probe;
659
+ } else {
660
+ result = { ok: false, detail: `${provider} configuration is unavailable.` };
661
+ }
662
+ return { ...result, detail: sanitizeProviderDiagnostic(result.detail, cfg, provider) };
663
+ } catch (error) {
664
+ return {
665
+ ok: false,
666
+ detail: sanitizeProviderDiagnostic(
667
+ error instanceof Error ? error.message : `${provider} diagnostic failed.`,
668
+ cfg,
669
+ provider,
670
+ ),
671
+ };
549
672
  }
550
673
  }
551
674
 
@@ -563,11 +686,25 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
563
686
  const stateRoot = opts.stateRoot ?? defaultStateRoot();
564
687
 
565
688
  const cfg: NotificationConfig = getNotificationConfig(opts.settings);
566
- const configured = isGloballyConfigured(cfg);
567
- const telegramConfigured = isTelegramConfigured(cfg);
689
+ const provider = selectNotificationProvider(cfg, opts.provider);
690
+ const resolution = provider ? resolveNotificationProvider(cfg, provider) : undefined;
691
+ const configured = resolution?.configured ?? hasAnyCompleteProvider(cfg);
692
+ const telegramConfigured = isTelegramComplete(cfg);
568
693
  const checks: HealthCheck[] = [];
569
694
 
570
- if (!cfg.enabled) {
695
+ if (provider && resolution?.quarantined) {
696
+ checks.push({ name: "config", level: "error", detail: `${provider} configuration needs repair` });
697
+ } else if (provider && !resolution?.configured) {
698
+ checks.push({ name: "config", level: "warn", detail: `${provider} is not fully configured` });
699
+ } else if (provider && !resolution?.desiredEnabled) {
700
+ checks.push({ name: "intent", level: "warn", detail: `${provider} desired intent is off` });
701
+ } else if (provider && !resolution?.effectiveEnabled) {
702
+ checks.push({
703
+ name: "config",
704
+ level: "warn",
705
+ detail: `${provider} is not effective while the global master is off`,
706
+ });
707
+ } else if (!cfg.enabled) {
571
708
  checks.push({
572
709
  name: "config",
573
710
  level: "warn",
@@ -575,8 +712,24 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
575
712
  });
576
713
  } else if (!configured) {
577
714
  checks.push({ name: "config", level: "warn", detail: "no notification adapter is fully configured" });
715
+ } else if (!provider && effectiveProviders(cfg).length === 0) {
716
+ checks.push({
717
+ name: "config",
718
+ level: "warn",
719
+ detail: "no configured provider currently has desired intent enabled",
720
+ });
721
+ } else if (!provider && effectiveProviders(cfg).length > 1) {
722
+ checks.push({
723
+ name: "selection",
724
+ level: "warn",
725
+ detail: "select a provider because multiple providers are effective",
726
+ });
578
727
  } else {
579
- checks.push({ name: "config", level: "ok", detail: "enabled with at least one configured adapter" });
728
+ checks.push({
729
+ name: "config",
730
+ level: "ok",
731
+ detail: provider ? `${provider} is effective` : "enabled with one configured adapter",
732
+ });
580
733
  }
581
734
 
582
735
  // Daemon ownership state (offline; corrupt or unreadable state degrades to a
@@ -612,26 +765,28 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
612
765
  currentGeneration: DAEMON_GENERATION,
613
766
  generationRelation: daemonGenerationRelation(state),
614
767
  };
615
- if (daemonStateUnreadable) {
616
- checks.push({ name: "daemon", level: "warn", detail: "daemon ownership record is corrupt or unreadable" });
617
- } else if (!state) {
618
- checks.push({ name: "daemon", level: "ok", detail: "no daemon ownership record (none running)" });
619
- } else if (!daemon.alive) {
620
- checks.push({
621
- name: "daemon",
622
- level: "warn",
623
- detail: `daemon owner pid ${daemon.pid} is not alive; run recovery to clear the stale lock`,
624
- });
625
- } else if (!daemon.heartbeatFresh) {
626
- checks.push({ name: "daemon", level: "warn", detail: `daemon pid ${daemon.pid} heartbeat is stale` });
627
- } else if (telegramConfigured && !daemon.identityMatches) {
628
- checks.push({
629
- name: "daemon",
630
- level: "warn",
631
- detail: "a live daemon owns a different bot token or chat id",
632
- });
633
- } else {
634
- checks.push({ name: "daemon", level: "ok", detail: `daemon pid ${daemon.pid} alive with a fresh heartbeat` });
768
+ if (!provider || provider === "telegram") {
769
+ if (daemonStateUnreadable) {
770
+ checks.push({ name: "daemon", level: "warn", detail: "daemon ownership record is corrupt or unreadable" });
771
+ } else if (!state) {
772
+ checks.push({ name: "daemon", level: "ok", detail: "no daemon ownership record (none running)" });
773
+ } else if (!daemon.alive) {
774
+ checks.push({
775
+ name: "daemon",
776
+ level: "warn",
777
+ detail: `daemon owner pid ${daemon.pid} is not alive; run recovery to clear the stale lock`,
778
+ });
779
+ } else if (!daemon.heartbeatFresh) {
780
+ checks.push({ name: "daemon", level: "warn", detail: `daemon pid ${daemon.pid} heartbeat is stale` });
781
+ } else if (telegramConfigured && !daemon.identityMatches) {
782
+ checks.push({
783
+ name: "daemon",
784
+ level: "warn",
785
+ detail: "a live daemon owns a different bot token or chat id",
786
+ });
787
+ } else {
788
+ checks.push({ name: "daemon", level: "ok", detail: `daemon pid ${daemon.pid} alive with a fresh heartbeat` });
789
+ }
635
790
  }
636
791
 
637
792
  // Per-session endpoint discovery files.
@@ -667,54 +822,68 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
667
822
  unknown: unknownEndpoints,
668
823
  unreadable,
669
824
  };
670
- if (dead > 0 || unreadable > 0) {
671
- checks.push({
672
- name: "endpoints",
673
- level: "warn",
674
- detail: `${dead} dead / ${unreadable} unreadable of ${endpoints.total} endpoint file(s); run recovery`,
675
- });
676
- } else {
677
- checks.push({
678
- name: "endpoints",
679
- level: "ok",
680
- detail: `${live} live, ${unknownEndpoints} unverified endpoint file(s)`,
681
- });
682
- }
683
- if (
684
- telegramConfigured &&
685
- daemon.present &&
686
- daemon.alive &&
687
- daemon.heartbeatFresh &&
688
- daemon.identityMatches &&
689
- !daemon.stopped &&
690
- endpoints.total === 0
691
- ) {
692
- checks.push({
693
- name: "local_endpoint",
694
- level: "warn",
695
- detail:
696
- "No local notification endpoint for this working directory. In this GJC terminal run /notify on; if it does not report notifications enabled, start a new local GJC session. Do not re-pair Telegram.",
697
- });
825
+ if (!provider || provider === "telegram") {
826
+ if (dead > 0 || unreadable > 0) {
827
+ checks.push({
828
+ name: "endpoints",
829
+ level: "warn",
830
+ detail: `${dead} dead / ${unreadable} unreadable of ${endpoints.total} endpoint file(s); run recovery`,
831
+ });
832
+ } else {
833
+ checks.push({
834
+ name: "endpoints",
835
+ level: "ok",
836
+ detail: `${live} live, ${unknownEndpoints} unverified endpoint file(s)`,
837
+ });
838
+ }
839
+ if (
840
+ telegramConfigured &&
841
+ daemon.present &&
842
+ daemon.alive &&
843
+ daemon.heartbeatFresh &&
844
+ daemon.identityMatches &&
845
+ !daemon.stopped &&
846
+ endpoints.total === 0
847
+ ) {
848
+ checks.push({
849
+ name: "local_endpoint",
850
+ level: "warn",
851
+ detail:
852
+ "No local notification endpoint for this working directory. In this GJC terminal run /notify on; if it does not report notifications enabled, start a new local GJC session. Do not re-pair Telegram.",
853
+ });
854
+ }
698
855
  }
699
856
 
700
- // Optional network reachability probe.
857
+ // Optional selected REST-only reachability probe.
701
858
  let reachability = { probed: false, ok: false, detail: "not probed" };
702
- if (opts.probe && telegramConfigured) {
703
- const result = await probeTelegramReachability(
704
- deps.fetchImpl ?? globalThis.fetch,
705
- deps.apiBase ?? DEFAULT_API_BASE,
706
- cfg.botToken,
707
- );
708
- reachability = { probed: true, ...result };
709
- checks.push({
710
- name: "reachability",
711
- level: result.ok ? "ok" : "error",
712
- detail: `Telegram: ${result.detail}`,
713
- });
859
+ if (opts.probe) {
860
+ if (!provider) {
861
+ reachability = { probed: false, ok: false, detail: "provider selection required" };
862
+ checks.push({ name: "reachability", level: "error", detail: "provider selection required" });
863
+ } else if (!resolution?.configured || resolution.quarantined) {
864
+ reachability = { probed: false, ok: false, detail: `${provider} is unavailable` };
865
+ checks.push({ name: "reachability", level: "error", detail: `${provider} is unavailable` });
866
+ } else {
867
+ const result = await probeSelectedProvider(cfg, provider, deps, opts.signal);
868
+ reachability = { probed: true, ...result };
869
+ checks.push({
870
+ name: "reachability",
871
+ level: result.ok ? "ok" : "error",
872
+ detail: `${provider}: ${result.detail}`,
873
+ });
874
+ }
714
875
  }
715
876
 
716
877
  const overall = checks.reduce<HealthLevel>((acc, check) => worst(acc, check.level), "ok");
717
- return { overall, configured, checks, daemon, endpoints, reachability };
878
+ return {
879
+ overall,
880
+ configured,
881
+ ...(provider ? { provider, resolution } : {}),
882
+ checks,
883
+ daemon,
884
+ endpoints,
885
+ reachability,
886
+ };
718
887
  }
719
888
 
720
889
  /** Render a health report as human-readable lines (no secrets). */
@@ -731,66 +900,198 @@ export function formatNotificationHealthReport(report: NotificationHealthReport)
731
900
 
732
901
  export interface NotificationTestResult {
733
902
  ok: boolean;
734
- adapter: "telegram";
735
- chatId: string | undefined;
903
+ adapter?: NotificationProvider;
904
+ destination?: string;
736
905
  detail: string;
906
+ uncertain?: boolean;
737
907
  }
738
908
 
739
909
  export interface TestOptions {
740
910
  settings: Settings;
741
911
  deps?: NotificationServiceDeps;
912
+ provider?: NotificationProvider;
742
913
  text?: string;
914
+ signal?: AbortSignal;
915
+ }
916
+
917
+ async function selectedProviderRuntimeReady(
918
+ provider: NotificationProvider,
919
+ deps: NotificationServiceDeps,
920
+ ): Promise<boolean> {
921
+ if (!deps.providerRuntimeStatus) return false;
922
+ const status = await deps.providerRuntimeStatus(provider);
923
+ return status === "ready" || status === "attached";
743
924
  }
744
925
 
745
- /** Send a one-off test notification through the configured Telegram adapter. */
926
+ /** Send a one-off test through exactly one durable, effective provider. */
746
927
  export async function sendNotificationTest(opts: TestOptions): Promise<NotificationTestResult> {
747
928
  const deps = opts.deps ?? {};
748
929
  const cfg = getNotificationConfig(opts.settings);
749
- if (!isTelegramConfigured(cfg)) {
930
+ const provider = selectNotificationProvider(cfg, opts.provider);
931
+ if (!provider) {
750
932
  return {
751
933
  ok: false,
752
- adapter: "telegram",
753
- chatId: cfg.chatId,
754
- detail: "Telegram is not configured (need notifications.enabled + botToken + chatId). Run `gjc notify setup`.",
934
+ detail:
935
+ effectiveProviders(cfg).length === 0
936
+ ? "No notification provider is effective. Configure and enable one provider first."
937
+ : "Multiple notification providers are effective; select one with --provider.",
755
938
  };
756
939
  }
757
- const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
758
- const apiBase = (deps.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
759
- const text = opts.text ?? "GJC notifications test message. If you can read this, delivery works.";
760
- try {
761
- const response = await fetchImpl(`${apiBase}/bot${cfg.botToken}/sendMessage`, {
762
- method: "POST",
763
- headers: { "content-type": "application/json" },
764
- body: JSON.stringify({ chat_id: cfg.chatId, text }),
765
- });
766
- const payload = (await response.json().catch(() => undefined)) as
767
- | { ok?: boolean; description?: string }
768
- | undefined;
769
- if (response.ok && payload?.ok) {
770
- return { ok: true, adapter: "telegram", chatId: cfg.chatId, detail: `delivered to chat ${cfg.chatId}` };
771
- }
940
+ const resolution = resolveNotificationProvider(cfg, provider);
941
+ if (!resolution.effectiveEnabled) {
772
942
  return {
773
943
  ok: false,
774
- adapter: "telegram",
775
- chatId: cfg.chatId,
776
- detail: sanitizeDiagnostic(
777
- payload?.description ?? `Telegram sendMessage failed (HTTP ${response.status})`,
778
- cfg.botToken,
779
- ),
944
+ adapter: provider,
945
+ detail: resolution.quarantined
946
+ ? `${provider} configuration needs repair.`
947
+ : `${provider} is unavailable because configuration, desired intent, or the global master is off.`,
780
948
  };
781
- } catch (err) {
949
+ }
950
+ try {
951
+ if (!(await selectedProviderRuntimeReady(provider, deps))) {
952
+ return { ok: false, adapter: provider, detail: `${provider} runtime is not ready or attached.` };
953
+ }
954
+ } catch (error) {
782
955
  return {
783
956
  ok: false,
784
- adapter: "telegram",
785
- chatId: cfg.chatId,
786
- detail: sanitizeDiagnostic(err instanceof Error ? err.message : "network error", cfg.botToken),
957
+ adapter: provider,
958
+ detail: sanitizeProviderDiagnostic(
959
+ error instanceof Error ? error.message : `${provider} runtime readiness check failed.`,
960
+ cfg,
961
+ provider,
962
+ ),
787
963
  };
788
964
  }
965
+ if (opts.signal?.aborted)
966
+ return { ok: false, adapter: provider, detail: `${provider} notification test cancelled.` };
967
+ const text = opts.text ?? "GJC notifications test message. If you can read this, delivery works.";
968
+ if (provider === "telegram" && isTelegramComplete(cfg)) {
969
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
970
+ const apiBase = (deps.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
971
+ try {
972
+ const response = await fetchImpl(`${apiBase}/bot${cfg.botToken}/sendMessage`, {
973
+ method: "POST",
974
+ headers: { "content-type": "application/json" },
975
+ body: JSON.stringify({ chat_id: cfg.chatId, text }),
976
+ signal: opts.signal,
977
+ });
978
+ const payload = (await response.json().catch(() => undefined)) as
979
+ | { ok?: boolean; description?: string; result?: { message_id?: unknown } }
980
+ | undefined;
981
+ const messageId = payload?.result?.message_id;
982
+ if (response.ok && payload?.ok === true && typeof messageId === "number" && Number.isSafeInteger(messageId)) {
983
+ return {
984
+ ok: true,
985
+ adapter: "telegram",
986
+ destination: cfg.chatId,
987
+ detail: `delivered to chat ${cfg.chatId}`,
988
+ };
989
+ }
990
+ const uncertain = response.ok && payload?.ok !== false;
991
+ return {
992
+ ok: false,
993
+ adapter: "telegram",
994
+ destination: cfg.chatId,
995
+ ...(uncertain ? { uncertain: true } : {}),
996
+ detail: sanitizeDiagnostic(
997
+ uncertain
998
+ ? "Telegram may have accepted the message but returned no usable message receipt."
999
+ : (payload?.description ?? `Telegram sendMessage failed (HTTP ${response.status})`),
1000
+ cfg.botToken,
1001
+ ),
1002
+ };
1003
+ } catch (error) {
1004
+ return {
1005
+ ok: false,
1006
+ adapter: "telegram",
1007
+ destination: cfg.chatId,
1008
+ uncertain: true,
1009
+ detail: opts.signal?.aborted
1010
+ ? "Telegram notification delivery is uncertain because cancellation raced with dispatch."
1011
+ : sanitizeDiagnostic(error instanceof Error ? error.message : "network error", cfg.botToken),
1012
+ };
1013
+ }
1014
+ }
1015
+ if (provider === "discord" && isDiscordComplete(cfg)) {
1016
+ try {
1017
+ const adapter =
1018
+ deps.createDiscordDiagnostic?.({
1019
+ applicationId: cfg.discord.applicationId,
1020
+ botToken: cfg.discord.botToken,
1021
+ }) ??
1022
+ new DiscordLiveProvider({
1023
+ applicationId: cfg.discord.applicationId,
1024
+ botToken: cfg.discord.botToken,
1025
+ fetchImpl: deps.fetchImpl,
1026
+ });
1027
+ const result = await adapter.sendOneShotTest({
1028
+ channelId: cfg.discord.parentChannelId,
1029
+ message: text,
1030
+ signal: opts.signal,
1031
+ });
1032
+ return {
1033
+ ok: result.ok,
1034
+ adapter: "discord",
1035
+ destination: cfg.discord.parentChannelId,
1036
+ detail: sanitizeProviderDiagnostic(result.detail, cfg, "discord"),
1037
+ ...(result.uncertain ? { uncertain: true } : {}),
1038
+ };
1039
+ } catch (error) {
1040
+ return {
1041
+ ok: false,
1042
+ adapter: "discord",
1043
+ destination: cfg.discord.parentChannelId,
1044
+ uncertain: true,
1045
+ detail: sanitizeProviderDiagnostic(
1046
+ error instanceof Error ? error.message : "Discord notification delivery failed.",
1047
+ cfg,
1048
+ "discord",
1049
+ ),
1050
+ };
1051
+ }
1052
+ }
1053
+ if (provider === "slack" && isSlackComplete(cfg)) {
1054
+ try {
1055
+ const adapter =
1056
+ deps.createSlackDiagnostic?.({ appToken: cfg.slack.appToken, botToken: cfg.slack.botToken }) ??
1057
+ new SlackLiveProvider({
1058
+ appToken: cfg.slack.appToken,
1059
+ botToken: cfg.slack.botToken,
1060
+ fetch: deps.fetchImpl,
1061
+ });
1062
+ const result = await adapter.sendOneShotTest({
1063
+ channel: cfg.slack.channelId,
1064
+ message: text,
1065
+ idempotencyKey: crypto.randomUUID(),
1066
+ signal: opts.signal,
1067
+ });
1068
+ return {
1069
+ ok: result.ok,
1070
+ adapter: "slack",
1071
+ destination: cfg.slack.channelId,
1072
+ detail: sanitizeProviderDiagnostic(result.detail, cfg, "slack"),
1073
+ ...(result.uncertain ? { uncertain: true } : {}),
1074
+ };
1075
+ } catch (error) {
1076
+ return {
1077
+ ok: false,
1078
+ adapter: "slack",
1079
+ destination: cfg.slack.channelId,
1080
+ uncertain: true,
1081
+ detail: sanitizeProviderDiagnostic(
1082
+ error instanceof Error ? error.message : "Slack notification delivery failed.",
1083
+ cfg,
1084
+ "slack",
1085
+ ),
1086
+ };
1087
+ }
1088
+ }
1089
+ return { ok: false, adapter: provider, detail: `${provider} configuration is unavailable.` };
789
1090
  }
790
1091
 
791
1092
  /** Render a test result as a single human-readable line (no secrets). */
792
1093
  export function formatNotificationTestResult(result: NotificationTestResult): string {
793
- return `Notification test (${result.adapter}): ${result.ok ? "OK" : "FAILED"} — ${result.detail}`;
1094
+ return `Notification test (${result.adapter ?? "unselected"}): ${result.ok ? "OK" : result.uncertain ? "UNCERTAIN" : "FAILED"} — ${result.detail}`;
794
1095
  }
795
1096
 
796
1097
  // --- recovery -----------------------------------------------------------