@gajae-code/coding-agent 0.12.7 → 0.12.10

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 (117) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/types/cli/notify-cli.d.ts +1 -0
  3. package/dist/types/commands/notify.d.ts +4 -0
  4. package/dist/types/commands/sdk.d.ts +23 -0
  5. package/dist/types/config/file-lock.d.ts +3 -0
  6. package/dist/types/config/model-discovery-manager.d.ts +6 -1
  7. package/dist/types/config/model-registry.d.ts +2 -0
  8. package/dist/types/config/resolve-config-value.d.ts +2 -1
  9. package/dist/types/config/settings-schema.d.ts +17 -1
  10. package/dist/types/discovery/agents-md.d.ts +11 -1
  11. package/dist/types/main.d.ts +1 -0
  12. package/dist/types/modes/components/hook-selector.d.ts +20 -0
  13. package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
  14. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  15. package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
  16. package/dist/types/modes/types.d.ts +6 -0
  17. package/dist/types/sdk/broker/broker.d.ts +5 -1
  18. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  20. package/dist/types/sdk/bus/config.d.ts +75 -113
  21. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  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 +54 -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 +8 -1
  31. package/dist/types/sdk/client/discovery.d.ts +2 -1
  32. package/dist/types/sdk/host/query/handlers.d.ts +2 -0
  33. package/dist/types/sdk/index.d.ts +1 -0
  34. package/dist/types/sdk/providers.d.ts +22 -0
  35. package/dist/types/sdk/session-directory.d.ts +1 -1
  36. package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
  37. package/dist/types/session/internal/managed-session-storage.d.ts +19 -1
  38. package/dist/types/session/internal/native-publish-outcome.d.ts +3 -2
  39. package/dist/types/session/internal/session-open-errors.d.ts +5 -0
  40. package/dist/types/session/session-manager.d.ts +3 -1
  41. package/dist/types/session/session-storage.d.ts +2 -0
  42. package/dist/types/system-prompt.d.ts +12 -1
  43. package/dist/types/tools/composer-bash-policy.d.ts +9 -1
  44. package/package.json +10 -8
  45. package/scripts/build-sdk-package-smoke.ts +51 -0
  46. package/scripts/capture-notifications-settings-showcase.ts +1 -1
  47. package/scripts/verify-gjc-sdk-canonicalization.ts +14 -1
  48. package/src/cli/notify-cli.ts +215 -99
  49. package/src/commands/notify.ts +19 -8
  50. package/src/commands/sdk.ts +54 -0
  51. package/src/config/file-lock.ts +6 -0
  52. package/src/config/model-discovery-manager.ts +50 -8
  53. package/src/config/model-profiles.ts +8 -0
  54. package/src/config/model-registry.ts +866 -76
  55. package/src/config/resolve-config-value.ts +10 -8
  56. package/src/config/settings-schema.ts +25 -15
  57. package/src/config/settings.ts +15 -15
  58. package/src/discovery/agents-md.ts +76 -19
  59. package/src/gjc-runtime/psmux-detect.ts +12 -16
  60. package/src/gjc-runtime/session-state-sidecar.ts +12 -2
  61. package/src/gjc-runtime/tmux-sessions.ts +8 -1
  62. package/src/internal-urls/docs-index.generated.ts +10 -9
  63. package/src/main.ts +43 -7
  64. package/src/modes/DESIGN.md +11 -11
  65. package/src/modes/components/extensions/inspector-panel.ts +9 -5
  66. package/src/modes/components/hook-selector.ts +155 -44
  67. package/src/modes/components/model-selector.ts +242 -49
  68. package/src/modes/components/notifications-settings-editor.ts +631 -85
  69. package/src/modes/controllers/extension-ui-controller.ts +114 -15
  70. package/src/modes/controllers/selector-controller.ts +525 -93
  71. package/src/modes/print-mode.ts +3 -0
  72. package/src/modes/types.ts +6 -0
  73. package/src/prompts/system/system-prompt.md +1 -0
  74. package/src/sdk/broker/broker.ts +54 -10
  75. package/src/sdk/broker/lifecycle-ledger.ts +34 -1
  76. package/src/sdk/broker/lifecycle.ts +47 -50
  77. package/src/sdk/bus/chat-daemon-cli.ts +12 -5
  78. package/src/sdk/bus/chat-daemon-control.ts +35 -6
  79. package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
  80. package/src/sdk/bus/config.ts +534 -286
  81. package/src/sdk/bus/daemon-paths.ts +2 -0
  82. package/src/sdk/bus/discord-live-provider.ts +57 -2
  83. package/src/sdk/bus/discord-provider.ts +21 -0
  84. package/src/sdk/bus/index.ts +97 -35
  85. package/src/sdk/bus/notification-orchestration.ts +371 -68
  86. package/src/sdk/bus/notification-service.ts +571 -137
  87. package/src/sdk/bus/session-control.ts +119 -44
  88. package/src/sdk/bus/slack-live-provider.ts +78 -2
  89. package/src/sdk/bus/slack-provider.ts +24 -0
  90. package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
  91. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  92. package/src/sdk/bus/telegram-daemon-control.ts +3 -3
  93. package/src/sdk/bus/telegram-daemon.ts +23 -15
  94. package/src/sdk/client/discovery.ts +10 -4
  95. package/src/sdk/host/query/handlers.ts +20 -1
  96. package/src/sdk/index.ts +1 -0
  97. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  98. package/src/sdk/protocol/operation-registry.ts +5 -2
  99. package/src/sdk/providers.ts +51 -0
  100. package/src/sdk/session-directory.ts +1 -0
  101. package/src/sdk/session.ts +34 -14
  102. package/src/session/agent-session.ts +109 -37
  103. package/src/session/internal/managed-session-scope.ts +981 -450
  104. package/src/session/internal/managed-session-storage.ts +418 -47
  105. package/src/session/internal/native-publish-outcome.ts +15 -3
  106. package/src/session/internal/session-open-errors.ts +11 -0
  107. package/src/session/session-manager.ts +27 -3
  108. package/src/session/session-storage.ts +72 -5
  109. package/src/slash-commands/builtin-registry.ts +117 -15
  110. package/src/system-prompt.ts +28 -15
  111. package/src/task/index.ts +9 -3
  112. package/src/tools/ask.ts +1 -1
  113. package/src/tools/bash.ts +4 -2
  114. package/src/tools/composer-bash-policy.ts +21 -5
  115. package/src/tools/telegram-send.ts +4 -2
  116. package/src/utils/command-args.ts +19 -30
  117. package/src/utils/lang-from-path.ts +2 -4
@@ -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,61 @@ export function sanitizeDiagnostic(text: string, token?: string): string {
52
63
  return out.replace(TELEGRAM_TOKEN_PATTERN, "<redacted>");
53
64
  }
54
65
 
66
+ export interface NotificationDiagnosticEvent {
67
+ timestamp: string;
68
+ operation: string;
69
+ phase: string;
70
+ outcome: string;
71
+ reason: string;
72
+ pid?: number;
73
+ incarnation?: string;
74
+ ageMs?: number;
75
+ detail?: string;
76
+ }
77
+
78
+ /** Append a bounded, private, secret-safe daemon diagnostic event. */
79
+ export async function writeNotificationDiagnostic(
80
+ settings: Pick<Settings, "getAgentDir">,
81
+ event: Omit<NotificationDiagnosticEvent, "timestamp"> & { timestamp?: string },
82
+ ): Promise<void> {
83
+ const file = daemonPaths(settings.getAgentDir()).diagnostic;
84
+ const dir = path.dirname(file);
85
+ let events: NotificationDiagnosticEvent[] = [];
86
+ try {
87
+ const parsed = JSON.parse(await fsPromises.readFile(file, "utf8")) as { events?: unknown };
88
+ if (Array.isArray(parsed.events)) events = parsed.events as NotificationDiagnosticEvent[];
89
+ } catch {
90
+ // A missing or corrupt diagnostic history must not block notification recovery.
91
+ }
92
+ const safe: NotificationDiagnosticEvent = {
93
+ timestamp: event.timestamp ?? new Date().toISOString(),
94
+ operation: event.operation.slice(0, 80),
95
+ phase: event.phase.slice(0, 80),
96
+ outcome: event.outcome.slice(0, 80),
97
+ reason: event.reason.slice(0, 120),
98
+ ...(event.pid === undefined ? {} : { pid: event.pid }),
99
+ ...(event.incarnation === undefined ? {} : { incarnation: event.incarnation.slice(0, 120) }),
100
+ ...(event.ageMs === undefined ? {} : { ageMs: Math.max(0, Math.floor(event.ageMs)) }),
101
+ ...(event.detail === undefined ? {} : { detail: sanitizeDiagnostic(event.detail).slice(0, 512) }),
102
+ };
103
+ await fsPromises.mkdir(dir, { recursive: true, mode: 0o700 });
104
+ await fsPromises.writeFile(file, `${JSON.stringify({ version: 1, events: [...events, safe].slice(-64) })}\n`, {
105
+ mode: 0o600,
106
+ });
107
+ }
108
+
109
+ function sanitizeProviderDiagnostic(text: string, cfg: NotificationConfig, provider: NotificationProvider): string {
110
+ const secrets =
111
+ provider === "telegram"
112
+ ? [cfg.botToken]
113
+ : provider === "discord"
114
+ ? [cfg.discord.botToken]
115
+ : [cfg.slack.botToken, cfg.slack.appToken];
116
+ let detail = text;
117
+ for (const secret of secrets) detail = sanitizeDiagnostic(detail, secret);
118
+ return detail;
119
+ }
120
+
55
121
  /** Identity evidence required to remove precisely the endpoint that was inspected. */
56
122
  export interface NotificationEndpointFileIdentity {
57
123
  dev: bigint;
@@ -159,8 +225,12 @@ export interface NotificationServiceDeps {
159
225
  fs?: NotificationServiceFs;
160
226
  now?: () => number;
161
227
  pidAlive?: (pid: number) => boolean;
228
+ pidIncarnation?: (pid: number) => string | undefined;
162
229
  fetchImpl?: typeof fetch;
163
230
  apiBase?: string;
231
+ createDiscordDiagnostic?: (config: { applicationId: string; botToken: string }) => DiscordDiagnosticProvider;
232
+ createSlackDiagnostic?: (config: { appToken: string; botToken: string }) => SlackDiagnosticProvider;
233
+ providerRuntimeStatus?: (provider: NotificationProvider) => Promise<NotificationRuntime> | NotificationRuntime;
164
234
  }
165
235
 
166
236
  function defaultPidAlive(pid: number): boolean {
@@ -187,6 +257,12 @@ export interface AdapterConfigView {
187
257
  botTokenMasked: string;
188
258
  channel: string | undefined;
189
259
  configured: boolean;
260
+ quarantined: boolean;
261
+ desiredEnabled: boolean;
262
+ desiredSource: ProviderResolution["desiredSource"];
263
+ effectiveEnabled: boolean;
264
+ issues: ProviderResolution["issues"];
265
+ runtime?: NotificationRuntime;
190
266
  }
191
267
 
192
268
  export interface NotificationStatusReport {
@@ -194,13 +270,30 @@ export interface NotificationStatusReport {
194
270
  redact: boolean;
195
271
  verbosity: "lean" | "verbose";
196
272
  globallyConfigured: boolean;
273
+ anyProviderComplete: boolean;
274
+ anyProviderEffective: boolean;
197
275
  telegram: AdapterConfigView & { tokenFingerprint: string | undefined };
198
276
  discord: AdapterConfigView;
199
277
  slack: AdapterConfigView;
200
278
  }
201
279
 
202
- function adapterConfigured(token: string | undefined, channel: string | undefined): boolean {
203
- return Boolean(token?.trim()) && Boolean(channel?.trim());
280
+ function adapterView(
281
+ cfg: NotificationConfig,
282
+ provider: NotificationProvider,
283
+ token: string | undefined,
284
+ channel: string | undefined,
285
+ ): AdapterConfigView {
286
+ const resolution = resolveNotificationProvider(cfg, provider);
287
+ return {
288
+ botTokenMasked: maskToken(token),
289
+ channel,
290
+ configured: resolution.configured,
291
+ quarantined: resolution.quarantined,
292
+ desiredEnabled: resolution.desiredEnabled,
293
+ desiredSource: resolution.desiredSource,
294
+ effectiveEnabled: resolution.effectiveEnabled,
295
+ issues: resolution.issues,
296
+ };
204
297
  }
205
298
 
206
299
  /** Build a secret-safe structured status snapshot from settings. */
@@ -210,43 +303,40 @@ export function buildNotificationStatusReport(settings: Settings): NotificationS
210
303
  enabled: cfg.enabled,
211
304
  redact: cfg.redact,
212
305
  verbosity: cfg.verbosity,
213
- globallyConfigured: isGloballyConfigured(cfg),
306
+ globallyConfigured: cfg.enabled && hasAnyCompleteProvider(cfg),
307
+ anyProviderComplete: hasAnyCompleteProvider(cfg),
308
+ anyProviderEffective: hasAnyEffectivelyEnabledProvider(cfg),
214
309
  telegram: {
215
- botTokenMasked: maskToken(cfg.botToken),
216
- channel: cfg.chatId,
217
- configured: isTelegramConfigured(cfg),
310
+ ...adapterView(cfg, "telegram", cfg.botToken, cfg.chatId),
218
311
  tokenFingerprint: cfg.botToken?.trim() ? tokenFingerprint(cfg.botToken) : undefined,
219
312
  },
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
- },
313
+ discord: adapterView(cfg, "discord", cfg.discord.botToken, cfg.discord.parentChannelId),
314
+ slack: adapterView(cfg, "slack", cfg.slack.botToken, cfg.slack.channelId),
230
315
  };
231
316
  }
232
317
 
233
318
  /** Render a status report as human-readable lines (no secrets). */
234
319
  export function formatNotificationStatusReport(report: NotificationStatusReport): string {
235
- const yesNo = (v: boolean): string => (v ? "yes" : "no");
320
+ const yesNo = (value: boolean): string => (value ? "yes" : "no");
321
+ const provider = (name: NotificationProvider, view: AdapterConfigView): string[] => [
322
+ ` ${name}.configured: ${yesNo(view.configured)}`,
323
+ ` ${name}.needsRepair: ${yesNo(view.quarantined)}`,
324
+ ` ${name}.desired: ${view.desiredEnabled ? "on" : "off"} (${view.desiredSource})`,
325
+ ` ${name}.effective: ${yesNo(view.effectiveEnabled)}`,
326
+ ` ${name}.botToken: ${view.botTokenMasked}`,
327
+ ` ${name}.destination: ${view.channel ?? "(unset)"}`,
328
+ ];
236
329
  return [
237
330
  "Notifications",
238
331
  ` enabled: ${report.enabled}`,
239
- ` globally configured: ${yesNo(report.globallyConfigured)}`,
332
+ ` any provider effective: ${yesNo(report.anyProviderEffective)}`,
333
+ ` any provider complete: ${yesNo(report.anyProviderComplete)}`,
240
334
  ` redact: ${report.redact}`,
241
335
  ` verbosity: ${report.verbosity}`,
242
- ` telegram.botToken: ${report.telegram.botTokenMasked}`,
243
- ` telegram.chatId: ${report.telegram.channel ?? "(unset)"}`,
336
+ ...provider("telegram", report.telegram),
244
337
  ` 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)"}`,
338
+ ...provider("discord", report.discord),
339
+ ...provider("slack", report.slack),
250
340
  ].join("\n");
251
341
  }
252
342
 
@@ -508,6 +598,8 @@ export interface EndpointHealth {
508
598
  export interface NotificationHealthReport {
509
599
  overall: HealthLevel;
510
600
  configured: boolean;
601
+ provider?: NotificationProvider;
602
+ resolution?: ProviderResolution;
511
603
  checks: HealthCheck[];
512
604
  daemon: DaemonHealth;
513
605
  endpoints: EndpointHealth;
@@ -518,20 +610,25 @@ export interface HealthOptions {
518
610
  settings: Settings;
519
611
  stateRoot?: string;
520
612
  deps?: NotificationServiceDeps;
521
- /** When true and Telegram is configured, probe the Bot API (getMe) for reachability. */
613
+ provider?: NotificationProvider;
614
+ /** When true, use the selected provider's REST-only diagnostic path. */
522
615
  probe?: boolean;
616
+ signal?: AbortSignal;
523
617
  }
524
618
 
525
619
  async function probeTelegramReachability(
526
620
  fetchImpl: typeof fetch,
527
621
  apiBase: string,
528
622
  token: string,
623
+ signal?: AbortSignal,
529
624
  ): Promise<{ ok: boolean; detail: string }> {
625
+ if (signal?.aborted) return { ok: false, detail: "Telegram probe cancelled." };
530
626
  try {
531
627
  const response = await fetchImpl(`${apiBase.replace(/\/$/, "")}/bot${token}/getMe`, {
532
628
  method: "POST",
533
629
  headers: { "content-type": "application/json" },
534
630
  body: "{}",
631
+ signal,
535
632
  });
536
633
  const payload = (await response.json().catch(() => undefined)) as
537
634
  | { ok?: boolean; description?: string; result?: { username?: string } }
@@ -544,8 +641,78 @@ async function probeTelegramReachability(
544
641
  ok: false,
545
642
  detail: sanitizeDiagnostic(payload?.description ?? `Telegram getMe failed (HTTP ${response.status})`, token),
546
643
  };
547
- } catch (err) {
548
- return { ok: false, detail: sanitizeDiagnostic(err instanceof Error ? err.message : "network error", token) };
644
+ } catch (error) {
645
+ if (signal?.aborted) return { ok: false, detail: "Telegram probe cancelled." };
646
+ return { ok: false, detail: sanitizeDiagnostic(error instanceof Error ? error.message : "network error", token) };
647
+ }
648
+ }
649
+
650
+ function effectiveProviders(cfg: NotificationConfig): NotificationProvider[] {
651
+ return (["telegram", "discord", "slack"] as const).filter(provider => isProviderEffectivelyEnabled(cfg, provider));
652
+ }
653
+
654
+ function selectNotificationProvider(
655
+ cfg: NotificationConfig,
656
+ requested: NotificationProvider | undefined,
657
+ ): NotificationProvider | undefined {
658
+ if (requested) return requested;
659
+ const effective = effectiveProviders(cfg);
660
+ return effective.length === 1 ? effective[0] : undefined;
661
+ }
662
+
663
+ async function probeSelectedProvider(
664
+ cfg: NotificationConfig,
665
+ provider: NotificationProvider,
666
+ deps: NotificationServiceDeps,
667
+ signal?: AbortSignal,
668
+ ): Promise<{ ok: boolean; detail: string }> {
669
+ try {
670
+ let result: { ok: boolean; detail: string };
671
+ if (provider === "telegram" && isTelegramComplete(cfg)) {
672
+ result = await probeTelegramReachability(
673
+ deps.fetchImpl ?? globalThis.fetch,
674
+ deps.apiBase ?? DEFAULT_API_BASE,
675
+ cfg.botToken,
676
+ signal,
677
+ );
678
+ } else if (provider === "discord" && isDiscordComplete(cfg)) {
679
+ const adapter =
680
+ deps.createDiscordDiagnostic?.({
681
+ applicationId: cfg.discord.applicationId,
682
+ botToken: cfg.discord.botToken,
683
+ }) ??
684
+ new DiscordLiveProvider({
685
+ applicationId: cfg.discord.applicationId,
686
+ botToken: cfg.discord.botToken,
687
+ fetchImpl: deps.fetchImpl,
688
+ });
689
+ result = await adapter.probeConfiguration(signal);
690
+ } else if (provider === "slack" && isSlackComplete(cfg)) {
691
+ const adapter =
692
+ deps.createSlackDiagnostic?.({ appToken: cfg.slack.appToken, botToken: cfg.slack.botToken }) ??
693
+ new SlackLiveProvider({
694
+ appToken: cfg.slack.appToken,
695
+ botToken: cfg.slack.botToken,
696
+ fetch: deps.fetchImpl,
697
+ });
698
+ const probe = await adapter.probeConfiguration(signal);
699
+ result =
700
+ probe.ok && (!probe.teamId || probe.teamId !== cfg.slack.workspaceId)
701
+ ? { ok: false, detail: "Slack workspace identity does not match the configured workspace ID." }
702
+ : probe;
703
+ } else {
704
+ result = { ok: false, detail: `${provider} configuration is unavailable.` };
705
+ }
706
+ return { ...result, detail: sanitizeProviderDiagnostic(result.detail, cfg, provider) };
707
+ } catch (error) {
708
+ return {
709
+ ok: false,
710
+ detail: sanitizeProviderDiagnostic(
711
+ error instanceof Error ? error.message : `${provider} diagnostic failed.`,
712
+ cfg,
713
+ provider,
714
+ ),
715
+ };
549
716
  }
550
717
  }
551
718
 
@@ -563,11 +730,25 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
563
730
  const stateRoot = opts.stateRoot ?? defaultStateRoot();
564
731
 
565
732
  const cfg: NotificationConfig = getNotificationConfig(opts.settings);
566
- const configured = isGloballyConfigured(cfg);
567
- const telegramConfigured = isTelegramConfigured(cfg);
733
+ const provider = selectNotificationProvider(cfg, opts.provider);
734
+ const resolution = provider ? resolveNotificationProvider(cfg, provider) : undefined;
735
+ const configured = resolution?.configured ?? hasAnyCompleteProvider(cfg);
736
+ const telegramConfigured = isTelegramComplete(cfg);
568
737
  const checks: HealthCheck[] = [];
569
738
 
570
- if (!cfg.enabled) {
739
+ if (provider && resolution?.quarantined) {
740
+ checks.push({ name: "config", level: "error", detail: `${provider} configuration needs repair` });
741
+ } else if (provider && !resolution?.configured) {
742
+ checks.push({ name: "config", level: "warn", detail: `${provider} is not fully configured` });
743
+ } else if (provider && !resolution?.desiredEnabled) {
744
+ checks.push({ name: "intent", level: "warn", detail: `${provider} desired intent is off` });
745
+ } else if (provider && !resolution?.effectiveEnabled) {
746
+ checks.push({
747
+ name: "config",
748
+ level: "warn",
749
+ detail: `${provider} is not effective while the global master is off`,
750
+ });
751
+ } else if (!cfg.enabled) {
571
752
  checks.push({
572
753
  name: "config",
573
754
  level: "warn",
@@ -575,8 +756,24 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
575
756
  });
576
757
  } else if (!configured) {
577
758
  checks.push({ name: "config", level: "warn", detail: "no notification adapter is fully configured" });
759
+ } else if (!provider && effectiveProviders(cfg).length === 0) {
760
+ checks.push({
761
+ name: "config",
762
+ level: "warn",
763
+ detail: "no configured provider currently has desired intent enabled",
764
+ });
765
+ } else if (!provider && effectiveProviders(cfg).length > 1) {
766
+ checks.push({
767
+ name: "selection",
768
+ level: "warn",
769
+ detail: "select a provider because multiple providers are effective",
770
+ });
578
771
  } else {
579
- checks.push({ name: "config", level: "ok", detail: "enabled with at least one configured adapter" });
772
+ checks.push({
773
+ name: "config",
774
+ level: "ok",
775
+ detail: provider ? `${provider} is effective` : "enabled with one configured adapter",
776
+ });
580
777
  }
581
778
 
582
779
  // Daemon ownership state (offline; corrupt or unreadable state degrades to a
@@ -612,26 +809,28 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
612
809
  currentGeneration: DAEMON_GENERATION,
613
810
  generationRelation: daemonGenerationRelation(state),
614
811
  };
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` });
812
+ if (!provider || provider === "telegram") {
813
+ if (daemonStateUnreadable) {
814
+ checks.push({ name: "daemon", level: "warn", detail: "daemon ownership record is corrupt or unreadable" });
815
+ } else if (!state) {
816
+ checks.push({ name: "daemon", level: "ok", detail: "no daemon ownership record (none running)" });
817
+ } else if (!daemon.alive) {
818
+ checks.push({
819
+ name: "daemon",
820
+ level: "warn",
821
+ detail: `daemon owner pid ${daemon.pid} is not alive; run recovery to clear the stale lock`,
822
+ });
823
+ } else if (!daemon.heartbeatFresh) {
824
+ checks.push({ name: "daemon", level: "warn", detail: `daemon pid ${daemon.pid} heartbeat is stale` });
825
+ } else if (telegramConfigured && !daemon.identityMatches) {
826
+ checks.push({
827
+ name: "daemon",
828
+ level: "warn",
829
+ detail: "a live daemon owns a different bot token or chat id",
830
+ });
831
+ } else {
832
+ checks.push({ name: "daemon", level: "ok", detail: `daemon pid ${daemon.pid} alive with a fresh heartbeat` });
833
+ }
635
834
  }
636
835
 
637
836
  // Per-session endpoint discovery files.
@@ -667,54 +866,68 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
667
866
  unknown: unknownEndpoints,
668
867
  unreadable,
669
868
  };
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
- });
869
+ if (!provider || provider === "telegram") {
870
+ if (dead > 0 || unreadable > 0) {
871
+ checks.push({
872
+ name: "endpoints",
873
+ level: "warn",
874
+ detail: `${dead} dead / ${unreadable} unreadable of ${endpoints.total} endpoint file(s); run recovery`,
875
+ });
876
+ } else {
877
+ checks.push({
878
+ name: "endpoints",
879
+ level: "ok",
880
+ detail: `${live} live, ${unknownEndpoints} unverified endpoint file(s)`,
881
+ });
882
+ }
883
+ if (
884
+ telegramConfigured &&
885
+ daemon.present &&
886
+ daemon.alive &&
887
+ daemon.heartbeatFresh &&
888
+ daemon.identityMatches &&
889
+ !daemon.stopped &&
890
+ endpoints.total === 0
891
+ ) {
892
+ checks.push({
893
+ name: "local_endpoint",
894
+ level: "warn",
895
+ detail:
896
+ "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.",
897
+ });
898
+ }
698
899
  }
699
900
 
700
- // Optional network reachability probe.
901
+ // Optional selected REST-only reachability probe.
701
902
  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
- });
903
+ if (opts.probe) {
904
+ if (!provider) {
905
+ reachability = { probed: false, ok: false, detail: "provider selection required" };
906
+ checks.push({ name: "reachability", level: "error", detail: "provider selection required" });
907
+ } else if (!resolution?.configured || resolution.quarantined) {
908
+ reachability = { probed: false, ok: false, detail: `${provider} is unavailable` };
909
+ checks.push({ name: "reachability", level: "error", detail: `${provider} is unavailable` });
910
+ } else {
911
+ const result = await probeSelectedProvider(cfg, provider, deps, opts.signal);
912
+ reachability = { probed: true, ...result };
913
+ checks.push({
914
+ name: "reachability",
915
+ level: result.ok ? "ok" : "error",
916
+ detail: `${provider}: ${result.detail}`,
917
+ });
918
+ }
714
919
  }
715
920
 
716
921
  const overall = checks.reduce<HealthLevel>((acc, check) => worst(acc, check.level), "ok");
717
- return { overall, configured, checks, daemon, endpoints, reachability };
922
+ return {
923
+ overall,
924
+ configured,
925
+ ...(provider ? { provider, resolution } : {}),
926
+ checks,
927
+ daemon,
928
+ endpoints,
929
+ reachability,
930
+ };
718
931
  }
719
932
 
720
933
  /** Render a health report as human-readable lines (no secrets). */
@@ -731,66 +944,198 @@ export function formatNotificationHealthReport(report: NotificationHealthReport)
731
944
 
732
945
  export interface NotificationTestResult {
733
946
  ok: boolean;
734
- adapter: "telegram";
735
- chatId: string | undefined;
947
+ adapter?: NotificationProvider;
948
+ destination?: string;
736
949
  detail: string;
950
+ uncertain?: boolean;
737
951
  }
738
952
 
739
953
  export interface TestOptions {
740
954
  settings: Settings;
741
955
  deps?: NotificationServiceDeps;
956
+ provider?: NotificationProvider;
742
957
  text?: string;
958
+ signal?: AbortSignal;
743
959
  }
744
960
 
745
- /** Send a one-off test notification through the configured Telegram adapter. */
961
+ async function selectedProviderRuntimeReady(
962
+ provider: NotificationProvider,
963
+ deps: NotificationServiceDeps,
964
+ ): Promise<boolean> {
965
+ if (!deps.providerRuntimeStatus) return false;
966
+ const status = await deps.providerRuntimeStatus(provider);
967
+ return status === "ready" || status === "attached";
968
+ }
969
+
970
+ /** Send a one-off test through exactly one durable, effective provider. */
746
971
  export async function sendNotificationTest(opts: TestOptions): Promise<NotificationTestResult> {
747
972
  const deps = opts.deps ?? {};
748
973
  const cfg = getNotificationConfig(opts.settings);
749
- if (!isTelegramConfigured(cfg)) {
974
+ const provider = selectNotificationProvider(cfg, opts.provider);
975
+ if (!provider) {
750
976
  return {
751
977
  ok: false,
752
- adapter: "telegram",
753
- chatId: cfg.chatId,
754
- detail: "Telegram is not configured (need notifications.enabled + botToken + chatId). Run `gjc notify setup`.",
978
+ detail:
979
+ effectiveProviders(cfg).length === 0
980
+ ? "No notification provider is effective. Configure and enable one provider first."
981
+ : "Multiple notification providers are effective; select one with --provider.",
755
982
  };
756
983
  }
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
- }
984
+ const resolution = resolveNotificationProvider(cfg, provider);
985
+ if (!resolution.effectiveEnabled) {
772
986
  return {
773
987
  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
- ),
988
+ adapter: provider,
989
+ detail: resolution.quarantined
990
+ ? `${provider} configuration needs repair.`
991
+ : `${provider} is unavailable because configuration, desired intent, or the global master is off.`,
780
992
  };
781
- } catch (err) {
993
+ }
994
+ try {
995
+ if (!(await selectedProviderRuntimeReady(provider, deps))) {
996
+ return { ok: false, adapter: provider, detail: `${provider} runtime is not ready or attached.` };
997
+ }
998
+ } catch (error) {
782
999
  return {
783
1000
  ok: false,
784
- adapter: "telegram",
785
- chatId: cfg.chatId,
786
- detail: sanitizeDiagnostic(err instanceof Error ? err.message : "network error", cfg.botToken),
1001
+ adapter: provider,
1002
+ detail: sanitizeProviderDiagnostic(
1003
+ error instanceof Error ? error.message : `${provider} runtime readiness check failed.`,
1004
+ cfg,
1005
+ provider,
1006
+ ),
787
1007
  };
788
1008
  }
1009
+ if (opts.signal?.aborted)
1010
+ return { ok: false, adapter: provider, detail: `${provider} notification test cancelled.` };
1011
+ const text = opts.text ?? "GJC notifications test message. If you can read this, delivery works.";
1012
+ if (provider === "telegram" && isTelegramComplete(cfg)) {
1013
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
1014
+ const apiBase = (deps.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
1015
+ try {
1016
+ const response = await fetchImpl(`${apiBase}/bot${cfg.botToken}/sendMessage`, {
1017
+ method: "POST",
1018
+ headers: { "content-type": "application/json" },
1019
+ body: JSON.stringify({ chat_id: cfg.chatId, text }),
1020
+ signal: opts.signal,
1021
+ });
1022
+ const payload = (await response.json().catch(() => undefined)) as
1023
+ | { ok?: boolean; description?: string; result?: { message_id?: unknown } }
1024
+ | undefined;
1025
+ const messageId = payload?.result?.message_id;
1026
+ if (response.ok && payload?.ok === true && typeof messageId === "number" && Number.isSafeInteger(messageId)) {
1027
+ return {
1028
+ ok: true,
1029
+ adapter: "telegram",
1030
+ destination: cfg.chatId,
1031
+ detail: `delivered to chat ${cfg.chatId}`,
1032
+ };
1033
+ }
1034
+ const uncertain = response.ok && payload?.ok !== false;
1035
+ return {
1036
+ ok: false,
1037
+ adapter: "telegram",
1038
+ destination: cfg.chatId,
1039
+ ...(uncertain ? { uncertain: true } : {}),
1040
+ detail: sanitizeDiagnostic(
1041
+ uncertain
1042
+ ? "Telegram may have accepted the message but returned no usable message receipt."
1043
+ : (payload?.description ?? `Telegram sendMessage failed (HTTP ${response.status})`),
1044
+ cfg.botToken,
1045
+ ),
1046
+ };
1047
+ } catch (error) {
1048
+ return {
1049
+ ok: false,
1050
+ adapter: "telegram",
1051
+ destination: cfg.chatId,
1052
+ uncertain: true,
1053
+ detail: opts.signal?.aborted
1054
+ ? "Telegram notification delivery is uncertain because cancellation raced with dispatch."
1055
+ : sanitizeDiagnostic(error instanceof Error ? error.message : "network error", cfg.botToken),
1056
+ };
1057
+ }
1058
+ }
1059
+ if (provider === "discord" && isDiscordComplete(cfg)) {
1060
+ try {
1061
+ const adapter =
1062
+ deps.createDiscordDiagnostic?.({
1063
+ applicationId: cfg.discord.applicationId,
1064
+ botToken: cfg.discord.botToken,
1065
+ }) ??
1066
+ new DiscordLiveProvider({
1067
+ applicationId: cfg.discord.applicationId,
1068
+ botToken: cfg.discord.botToken,
1069
+ fetchImpl: deps.fetchImpl,
1070
+ });
1071
+ const result = await adapter.sendOneShotTest({
1072
+ channelId: cfg.discord.parentChannelId,
1073
+ message: text,
1074
+ signal: opts.signal,
1075
+ });
1076
+ return {
1077
+ ok: result.ok,
1078
+ adapter: "discord",
1079
+ destination: cfg.discord.parentChannelId,
1080
+ detail: sanitizeProviderDiagnostic(result.detail, cfg, "discord"),
1081
+ ...(result.uncertain ? { uncertain: true } : {}),
1082
+ };
1083
+ } catch (error) {
1084
+ return {
1085
+ ok: false,
1086
+ adapter: "discord",
1087
+ destination: cfg.discord.parentChannelId,
1088
+ uncertain: true,
1089
+ detail: sanitizeProviderDiagnostic(
1090
+ error instanceof Error ? error.message : "Discord notification delivery failed.",
1091
+ cfg,
1092
+ "discord",
1093
+ ),
1094
+ };
1095
+ }
1096
+ }
1097
+ if (provider === "slack" && isSlackComplete(cfg)) {
1098
+ try {
1099
+ const adapter =
1100
+ deps.createSlackDiagnostic?.({ appToken: cfg.slack.appToken, botToken: cfg.slack.botToken }) ??
1101
+ new SlackLiveProvider({
1102
+ appToken: cfg.slack.appToken,
1103
+ botToken: cfg.slack.botToken,
1104
+ fetch: deps.fetchImpl,
1105
+ });
1106
+ const result = await adapter.sendOneShotTest({
1107
+ channel: cfg.slack.channelId,
1108
+ message: text,
1109
+ idempotencyKey: crypto.randomUUID(),
1110
+ signal: opts.signal,
1111
+ });
1112
+ return {
1113
+ ok: result.ok,
1114
+ adapter: "slack",
1115
+ destination: cfg.slack.channelId,
1116
+ detail: sanitizeProviderDiagnostic(result.detail, cfg, "slack"),
1117
+ ...(result.uncertain ? { uncertain: true } : {}),
1118
+ };
1119
+ } catch (error) {
1120
+ return {
1121
+ ok: false,
1122
+ adapter: "slack",
1123
+ destination: cfg.slack.channelId,
1124
+ uncertain: true,
1125
+ detail: sanitizeProviderDiagnostic(
1126
+ error instanceof Error ? error.message : "Slack notification delivery failed.",
1127
+ cfg,
1128
+ "slack",
1129
+ ),
1130
+ };
1131
+ }
1132
+ }
1133
+ return { ok: false, adapter: provider, detail: `${provider} configuration is unavailable.` };
789
1134
  }
790
1135
 
791
1136
  /** Render a test result as a single human-readable line (no secrets). */
792
1137
  export function formatNotificationTestResult(result: NotificationTestResult): string {
793
- return `Notification test (${result.adapter}): ${result.ok ? "OK" : "FAILED"} — ${result.detail}`;
1138
+ return `Notification test (${result.adapter ?? "unselected"}): ${result.ok ? "OK" : result.uncertain ? "UNCERTAIN" : "FAILED"} — ${result.detail}`;
794
1139
  }
795
1140
 
796
1141
  // --- recovery -----------------------------------------------------------
@@ -825,6 +1170,9 @@ export interface NotificationRecoveryReport {
825
1170
  action: DaemonRecoveryAction;
826
1171
  detail: string;
827
1172
  ownerId: string | undefined;
1173
+ blockingReason?: string;
1174
+ markerAgeMs?: number;
1175
+ forceCommand?: string;
828
1176
  pid: number | undefined;
829
1177
  };
830
1178
  }
@@ -833,6 +1181,7 @@ export interface RecoveryOptions {
833
1181
  settings: Settings;
834
1182
  stateRoot?: string;
835
1183
  deps?: NotificationServiceDeps;
1184
+ forceDaemonLock?: boolean;
836
1185
  }
837
1186
 
838
1187
  export interface DaemonTransitionLock {
@@ -866,6 +1215,7 @@ type TransitionMarkerFs = {
866
1215
  writeFile?(file: string, data: string, opts?: WriteFileOptions): Promise<void>;
867
1216
  readEndpointFile?(file: string): Promise<NotificationEndpointFile>;
868
1217
  exactUnlink?(file: string, identity: NotificationEndpointFileIdentity): Promise<NotificationExactUnlinkResult>;
1218
+ stat?(file: string): Promise<{ mtimeMs: number }>;
869
1219
  };
870
1220
 
871
1221
  async function readTransitionMarker(
@@ -1017,6 +1367,36 @@ function defaultTransitionPidIncarnation(pid: number): string | undefined {
1017
1367
  return processIncarnation(pid);
1018
1368
  }
1019
1369
 
1370
+ const TRANSITION_MARKER_GRACE_MS = 3 * HEARTBEAT_TTL_MS;
1371
+
1372
+ async function detachStaleTransitionMarker(input: {
1373
+ fs: TransitionMarkerFs;
1374
+ path: string;
1375
+ now: number;
1376
+ pidAlive: (pid: number) => boolean;
1377
+ pidIncarnation: (pid: number) => string | undefined;
1378
+ }): Promise<boolean> {
1379
+ const snapshot = await readTransitionMarker(input.fs, input.path);
1380
+ if (!snapshot) return false;
1381
+ let marker: unknown;
1382
+ try {
1383
+ marker = JSON.parse(snapshot.raw);
1384
+ } catch {
1385
+ return false;
1386
+ }
1387
+ if (!isDaemonTransitionLock(marker)) return false;
1388
+ const currentIncarnation = input.pidIncarnation(marker.pid);
1389
+ if (
1390
+ input.pidAlive(marker.pid) ||
1391
+ !isProcessIncarnation(marker.incarnation) ||
1392
+ (isProcessIncarnation(currentIncarnation) && currentIncarnation === marker.incarnation)
1393
+ )
1394
+ return false;
1395
+ const ageMs = input.now - marker.createdAt;
1396
+ if (!Number.isFinite(ageMs) || ageMs <= TRANSITION_MARKER_GRACE_MS) return false;
1397
+ return await detachTransitionMarker(input.fs, input.path, snapshot);
1398
+ }
1399
+
1020
1400
  /**
1021
1401
  * Owner-bound removal of a dead daemon's lock. Closes the classic
1022
1402
  * check-then-unlink TOCTOU: a naive `unlink(lock)` after observing a dead owner
@@ -1031,20 +1411,36 @@ async function removeDeadOwnerLock(
1031
1411
  paths: DaemonPaths,
1032
1412
  pidAlive: (pid: number) => boolean,
1033
1413
  expected: NormalizedDaemonState,
1414
+ now: () => number,
1415
+ pidIncarnation: (pid: number) => string | undefined,
1034
1416
  ): Promise<"cleared" | "contended" | "superseded" | "now-alive" | "unlink-failed"> {
1035
- const transition = await acquireDaemonTransitionLock({
1417
+ let transition = await acquireDaemonTransitionLock({
1036
1418
  fs,
1037
1419
  path: paths.steal,
1038
1420
  pid: process.pid,
1039
1421
  pidAlive: defaultTransitionPidAlive,
1040
1422
  pidIncarnation: defaultTransitionPidIncarnation,
1041
1423
  });
1424
+ if (!transition) {
1425
+ await detachStaleTransitionMarker({
1426
+ fs,
1427
+ path: paths.steal,
1428
+ now: now(),
1429
+ pidAlive,
1430
+ pidIncarnation,
1431
+ });
1432
+ transition = await acquireDaemonTransitionLock({
1433
+ fs,
1434
+ path: paths.steal,
1435
+ pid: process.pid,
1436
+ pidAlive: defaultTransitionPidAlive,
1437
+ pidIncarnation: defaultTransitionPidIncarnation,
1438
+ });
1439
+ }
1042
1440
  if (!transition) return "contended";
1043
1441
  try {
1044
1442
  const current = await readDaemonStateFile(fs, paths.state);
1045
- if (!current || current.ownerId !== expected.ownerId || current.pid !== expected.pid) {
1046
- return "superseded";
1047
- }
1443
+ if (!current || current.ownerId !== expected.ownerId || current.pid !== expected.pid) return "superseded";
1048
1444
  if (pidAlive(current.pid)) return "now-alive";
1049
1445
  if (!(await daemonTransitionLockIsHeld({ fs, path: paths.steal, lock: transition }))) return "contended";
1050
1446
  try {
@@ -1072,6 +1468,7 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
1072
1468
  const deps = opts.deps ?? {};
1073
1469
  const fs = deps.fs ?? nodeServiceFs;
1074
1470
  const pidAlive = deps.pidAlive ?? defaultPidAlive;
1471
+ const now = deps.now ?? Date.now;
1075
1472
  const stateRoot = opts.stateRoot ?? defaultStateRoot();
1076
1473
 
1077
1474
  const dir = endpointDir(stateRoot);
@@ -1171,7 +1568,14 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
1171
1568
  pid: state.pid,
1172
1569
  };
1173
1570
  } else if (hasLock) {
1174
- const outcome = await removeDeadOwnerLock(fs, paths, pidAlive, state);
1571
+ const outcome = await removeDeadOwnerLock(
1572
+ fs,
1573
+ paths,
1574
+ pidAlive,
1575
+ state,
1576
+ now,
1577
+ deps.pidIncarnation ?? defaultTransitionPidIncarnation,
1578
+ );
1175
1579
  const action: DaemonRecoveryAction =
1176
1580
  outcome === "cleared"
1177
1581
  ? "cleared-dead-owner-lock"
@@ -1190,9 +1594,35 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
1190
1594
  : outcome === "superseded"
1191
1595
  ? "a new daemon owner took over during recovery; lock left untouched"
1192
1596
  : outcome === "contended"
1193
- ? "another daemon is starting or stealing the lock; lock left untouched"
1597
+ ? `recovery blocked by transition marker; lock left untouched${opts.forceDaemonLock ? " even after forced stale-marker retry" : ""}`
1194
1598
  : `could not remove lock of dead owner pid ${state.pid}`;
1195
- daemon = { action, detail, ownerId: state.ownerId, pid: state.pid };
1599
+ const blockingReason = outcome === "contended" ? "transition-marker-unavailable-or-contended" : undefined;
1600
+ const markerAgeMs =
1601
+ blockingReason && fs.stat
1602
+ ? (await fs.stat(paths.steal).catch(() => undefined))?.mtimeMs
1603
+ ? now() - (await fs.stat(paths.steal).catch(() => undefined))!.mtimeMs
1604
+ : undefined
1605
+ : undefined;
1606
+ if (blockingReason) {
1607
+ await writeNotificationDiagnostic(opts.settings, {
1608
+ operation: "notify.recovery",
1609
+ phase: "recovery",
1610
+ outcome: action,
1611
+ reason: blockingReason,
1612
+ pid: state.pid,
1613
+ ageMs: markerAgeMs,
1614
+ detail,
1615
+ });
1616
+ }
1617
+ daemon = {
1618
+ action,
1619
+ detail,
1620
+ ownerId: state.ownerId,
1621
+ pid: state.pid,
1622
+ ...(blockingReason
1623
+ ? { blockingReason, markerAgeMs, forceCommand: "gjc notify recovery --force-daemon-lock" }
1624
+ : {}),
1625
+ };
1196
1626
  } else {
1197
1627
  daemon = {
1198
1628
  action: "none",
@@ -1232,5 +1662,9 @@ export function formatNotificationRecoveryReport(report: NotificationRecoveryRep
1232
1662
  for (const unknown of report.endpointsRetainedUnknown ?? [])
1233
1663
  lines.push(` - retained unverified cleanup path ${unknown}`);
1234
1664
  lines.push(` daemon: ${report.daemon.action} — ${report.daemon.detail}`);
1665
+ if (report.daemon.blockingReason) lines.push(` - blocking reason: ${report.daemon.blockingReason}`);
1666
+ if (report.daemon.markerAgeMs !== undefined)
1667
+ lines.push(` - transition marker age: ${report.daemon.markerAgeMs}ms`);
1668
+ if (report.daemon.forceCommand) lines.push(` - safe escape: ${report.daemon.forceCommand}`);
1235
1669
  return lines.join("\n");
1236
1670
  }