@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
@@ -49,39 +49,54 @@ export function readTelegramActivationMarkers(value?: unknown): TelegramActivati
49
49
  return markers;
50
50
  }
51
51
 
52
+ export type NotificationProvider = "telegram" | "discord" | "slack";
53
+ export type NotificationRuntime = "inactive" | "starting" | "ready" | "attached" | "blocked" | "failed";
54
+ export type ProviderSecretDisposition = "keep" | "replace" | "remove";
55
+ export type ProviderResolutionIssueCode = "missing" | "blank" | "wrong_type" | "invalid_container" | "contradictory";
56
+
57
+ export interface ProviderResolutionIssue {
58
+ path: string;
59
+ code: ProviderResolutionIssueCode;
60
+ }
61
+
62
+ export type NotificationProviderIssueCode = ProviderResolutionIssueCode;
63
+ export type NotificationProviderIssue = ProviderResolutionIssue;
64
+
65
+ export interface ProviderResolution {
66
+ provider: NotificationProvider;
67
+ configured: boolean;
68
+ quarantined: boolean;
69
+ desiredEnabled: boolean;
70
+ desiredSource: "explicit" | "legacy";
71
+ effectiveEnabled: boolean;
72
+ issues: readonly ProviderResolutionIssue[];
73
+ }
74
+ const NOTIFICATION_PROVIDERS = ["telegram", "discord", "slack"] as const;
75
+
52
76
  export interface NotificationSettingsSnapshot {
53
77
  enabled: boolean;
54
78
  telegram: {
79
+ enabled?: boolean;
55
80
  botToken?: string;
56
81
  chatId?: string;
57
82
  activation?: Record<string, unknown>;
58
83
  sound: "all" | "important" | "none";
59
- btw: {
60
- enabled: boolean;
61
- };
62
- rich: {
63
- enabled: boolean;
64
- };
65
- richDraft: {
66
- enabled: boolean;
67
- };
68
- toolActivity: {
69
- enabled: boolean;
70
- };
71
- streaming: {
72
- enabled: boolean;
73
- };
74
- topics: {
75
- nameTemplate?: string;
76
- };
84
+ btw: { enabled: boolean };
85
+ rich: { enabled: boolean };
86
+ richDraft: { enabled: boolean };
87
+ toolActivity: { enabled: boolean };
88
+ streaming: { enabled: boolean };
89
+ topics: { nameTemplate?: string };
77
90
  };
78
91
  discord: {
92
+ enabled?: boolean;
79
93
  botToken?: string;
80
94
  applicationId?: string;
81
95
  guildId?: string;
82
96
  parentChannelId?: string;
83
97
  };
84
98
  slack: {
99
+ enabled?: boolean;
85
100
  botToken?: string;
86
101
  appToken?: string;
87
102
  workspaceId?: string;
@@ -92,120 +107,301 @@ export interface NotificationSettingsSnapshot {
92
107
  verbosity: "lean" | "verbose";
93
108
  sessionScope: "all" | "primary";
94
109
  idleTimeoutMs: number;
110
+ /** Safe provider-local validation issues. Raw values never cross this boundary. */
111
+ providerIssues?: Partial<Record<NotificationProvider, readonly ProviderResolutionIssue[]>>;
95
112
  }
96
113
 
97
114
  /**
98
115
  * Narrow settings boundary for remote notification identity and behavior.
99
- * Implementations must return only user-global values, with schema defaults
100
- * applied; project settings and runtime overrides are intentionally excluded.
116
+ * Implementations return only user-global values; project settings and runtime
117
+ * overrides are intentionally excluded.
101
118
  */
102
119
  export interface NotificationSettingsReader {
103
120
  getNotificationSettingsSnapshot(): NotificationSettingsSnapshot;
104
121
  getAgentDir(): string;
105
122
  }
123
+
106
124
  function notificationConfigurationError(): Error {
107
125
  return new Error("gjc_notify_daemon_invalid_configuration");
108
126
  }
109
127
 
110
- function notificationSettingsObject(value: unknown): Record<string, unknown> {
128
+ type NotificationObject = Record<string, unknown>;
129
+
130
+ function notificationObject(value: unknown): NotificationObject {
111
131
  if (value === undefined) return {};
112
132
  if (!value || typeof value !== "object" || Array.isArray(value)) throw notificationConfigurationError();
113
- return value as Record<string, unknown>;
133
+ return value as NotificationObject;
114
134
  }
115
135
 
116
- function notificationSettingsString(value: unknown): string | undefined {
117
- if (value === undefined) return undefined;
118
- if (typeof value === "string") return value;
119
- throw notificationConfigurationError();
136
+ function providerObject(value: unknown, pathName: string, issues: ProviderResolutionIssue[]): NotificationObject {
137
+ if (value === undefined) return {};
138
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
139
+ issues.push({ path: pathName, code: "invalid_container" });
140
+ return {};
141
+ }
142
+ return value as NotificationObject;
120
143
  }
121
144
 
122
- function notificationSettingsBoolean(value: unknown, fallback: boolean): boolean {
123
- if (value === undefined) return fallback;
124
- if (typeof value === "boolean") return value;
125
- throw notificationConfigurationError();
145
+ function addProviderIssue(
146
+ issues: ProviderResolutionIssue[],
147
+ pathName: string,
148
+ code: ProviderResolutionIssueCode,
149
+ ): void {
150
+ if (!issues.some(issue => issue.path === pathName && issue.code === code)) issues.push({ path: pathName, code });
126
151
  }
127
152
 
128
- function notificationSettingsChoice<T extends string>(value: unknown, fallback: T, choices: readonly T[]): T {
153
+ function providerString(
154
+ container: NotificationObject,
155
+ key: string,
156
+ pathName: string,
157
+ issues: ProviderResolutionIssue[],
158
+ ): string | undefined {
159
+ const value = container[key];
160
+ if (value === undefined) return undefined;
161
+ if (typeof value !== "string") {
162
+ addProviderIssue(issues, pathName, "wrong_type");
163
+ return undefined;
164
+ }
165
+ if (value.trim().length === 0) {
166
+ addProviderIssue(issues, pathName, "blank");
167
+ return undefined;
168
+ }
169
+ return value;
170
+ }
171
+
172
+ function providerBoolean(
173
+ container: NotificationObject,
174
+ key: string,
175
+ pathName: string,
176
+ fallback: boolean,
177
+ issues: ProviderResolutionIssue[],
178
+ ): boolean {
179
+ const value = container[key];
180
+ if (value === undefined) return fallback;
181
+ if (typeof value !== "boolean") {
182
+ addProviderIssue(issues, pathName, "wrong_type");
183
+ return fallback;
184
+ }
185
+ return value;
186
+ }
187
+
188
+ function providerChoice<T extends string>(
189
+ container: NotificationObject,
190
+ key: string,
191
+ pathName: string,
192
+ fallback: T,
193
+ choices: readonly T[],
194
+ issues: ProviderResolutionIssue[],
195
+ ): T {
196
+ const value = container[key];
129
197
  if (value === undefined) return fallback;
130
198
  if (typeof value === "string" && choices.includes(value as T)) return value as T;
131
- throw notificationConfigurationError();
199
+ addProviderIssue(issues, pathName, "wrong_type");
200
+ return fallback;
132
201
  }
133
202
 
134
- function notificationIdleTimeoutMs(value: unknown): number {
135
- if (value === undefined) return 60_000;
136
- if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
137
- throw notificationConfigurationError();
203
+ function providerActivation(
204
+ container: NotificationObject,
205
+ pathName: string,
206
+ issues: ProviderResolutionIssue[],
207
+ ): Record<string, unknown> | undefined {
208
+ const value = container.activation;
209
+ if (value === undefined) return undefined;
210
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
211
+ addProviderIssue(issues, pathName, "invalid_container");
212
+ return undefined;
213
+ }
214
+ const safe = readTelegramActivationMarkers(value);
215
+ if (Object.keys(safe).length !== Object.keys(value as NotificationObject).length) {
216
+ addProviderIssue(issues, pathName, "contradictory");
217
+ }
218
+ return Object.keys(safe).length === 0 ? undefined : safe;
138
219
  }
139
220
 
140
- /**
141
- * Validate and snapshot the raw global notification configuration used by both
142
- * the interactive host and the lightweight daemon process.
143
- */
221
+ function providerTopics(
222
+ container: NotificationObject,
223
+ pathName: string,
224
+ issues: ProviderResolutionIssue[],
225
+ ): { nameTemplate?: string } {
226
+ const topics = providerObject(container.topics, pathName, issues);
227
+ return { nameTemplate: providerString(topics, "nameTemplate", `${pathName}.nameTemplate`, issues) };
228
+ }
229
+
230
+ function providerEnabled(
231
+ container: NotificationObject,
232
+ pathName: string,
233
+ issues: ProviderResolutionIssue[],
234
+ ): boolean | undefined {
235
+ const value = container.enabled;
236
+ if (value === undefined) return undefined;
237
+ if (typeof value !== "boolean") {
238
+ addProviderIssue(issues, pathName, "wrong_type");
239
+ return undefined;
240
+ }
241
+ return value;
242
+ }
243
+
244
+ function addMissingRequiredProviderIssues(
245
+ container: NotificationObject,
246
+ required: readonly string[],
247
+ basePath: string,
248
+ issues: ProviderResolutionIssue[],
249
+ explicitlyEnabled: boolean,
250
+ ): void {
251
+ const anyRequiredPresent = required.some(key => container[key] !== undefined);
252
+ if (!anyRequiredPresent && !explicitlyEnabled) return;
253
+ for (const key of required) {
254
+ if (container[key] === undefined) addProviderIssue(issues, `${basePath}.${key}`, "missing");
255
+ }
256
+ }
257
+
258
+ function providerIssuesSnapshot(
259
+ provider: NotificationProvider,
260
+ issues: readonly ProviderResolutionIssue[],
261
+ ): Partial<Record<NotificationProvider, readonly ProviderResolutionIssue[]>> | undefined {
262
+ return issues.length === 0 ? undefined : { [provider]: issues.map(issue => ({ ...issue })) };
263
+ }
264
+
265
+ /** Validate and snapshot raw global notification configuration without leaking invalid provider values. */
144
266
  export function parseNotificationSettingsSnapshot(rawConfig?: unknown): NotificationSettingsSnapshot {
145
- const root = notificationSettingsObject(rawConfig);
146
- const notifications = notificationSettingsObject(root.notifications);
147
- const telegram = notificationSettingsObject(notifications.telegram);
148
- const btw = notificationSettingsObject(telegram.btw);
149
- const rich = notificationSettingsObject(telegram.rich);
150
- const richDraft = notificationSettingsObject(telegram.richDraft);
151
- const toolActivity = notificationSettingsObject(telegram.toolActivity);
152
- const streaming = notificationSettingsObject(telegram.streaming);
153
- const topics = notificationSettingsObject(telegram.topics);
154
- const activation = readTelegramActivationMarkers(notificationSettingsObject(telegram.activation));
155
- const discord = notificationSettingsObject(notifications.discord);
156
- const slack = notificationSettingsObject(notifications.slack);
157
- const daemon = notificationSettingsObject(notifications.daemon);
158
- return {
159
- enabled: notificationSettingsBoolean(notifications.enabled, false),
160
- telegram: {
161
- botToken: notificationSettingsString(telegram.botToken),
162
- chatId: notificationSettingsString(telegram.chatId),
163
- ...(Object.keys(activation).length === 0 ? {} : { activation }),
164
- sound: notificationSettingsChoice<"all" | "important" | "none">(telegram.sound, "all", [
165
- "all",
166
- "important",
167
- "none",
168
- ]),
169
- btw: {
170
- enabled: notificationSettingsBoolean(btw.enabled, true),
171
- },
172
- rich: {
173
- enabled: notificationSettingsBoolean(rich.enabled, true),
174
- },
175
- richDraft: {
176
- enabled: notificationSettingsBoolean(richDraft.enabled, false),
177
- },
178
- toolActivity: {
179
- enabled: notificationSettingsBoolean(toolActivity.enabled, false),
180
- },
181
- streaming: {
182
- enabled: notificationSettingsBoolean(streaming.enabled, true),
183
- },
184
- topics: {
185
- nameTemplate: notificationSettingsString(topics.nameTemplate),
186
- },
267
+ const root = notificationObject(rawConfig);
268
+ const notifications = notificationObject(root.notifications);
269
+ const telegramIssues: ProviderResolutionIssue[] = [];
270
+ const discordIssues: ProviderResolutionIssue[] = [];
271
+ const slackIssues: ProviderResolutionIssue[] = [];
272
+ const telegram = providerObject(notifications.telegram, "notifications.telegram", telegramIssues);
273
+ const discord = providerObject(notifications.discord, "notifications.discord", discordIssues);
274
+ const slack = providerObject(notifications.slack, "notifications.slack", slackIssues);
275
+ const btw = providerObject(telegram.btw, "notifications.telegram.btw", telegramIssues);
276
+ const rich = providerObject(telegram.rich, "notifications.telegram.rich", telegramIssues);
277
+ const richDraft = providerObject(telegram.richDraft, "notifications.telegram.richDraft", telegramIssues);
278
+ const toolActivity = providerObject(telegram.toolActivity, "notifications.telegram.toolActivity", telegramIssues);
279
+ const streaming = providerObject(telegram.streaming, "notifications.telegram.streaming", telegramIssues);
280
+ const activation = providerActivation(telegram, "notifications.telegram.activation", telegramIssues);
281
+ const daemon = notificationObject(notifications.daemon);
282
+ const telegramEnabled = providerEnabled(telegram, "notifications.telegram.enabled", telegramIssues);
283
+ const discordEnabled = providerEnabled(discord, "notifications.discord.enabled", discordIssues);
284
+ const slackEnabled = providerEnabled(slack, "notifications.slack.enabled", slackIssues);
285
+ addMissingRequiredProviderIssues(
286
+ telegram,
287
+ ["botToken", "chatId"],
288
+ "notifications.telegram",
289
+ telegramIssues,
290
+ telegramEnabled === true,
291
+ );
292
+ addMissingRequiredProviderIssues(
293
+ discord,
294
+ ["botToken", "applicationId", "guildId", "parentChannelId"],
295
+ "notifications.discord",
296
+ discordIssues,
297
+ discordEnabled === true,
298
+ );
299
+ addMissingRequiredProviderIssues(
300
+ slack,
301
+ ["botToken", "appToken", "workspaceId", "channelId"],
302
+ "notifications.slack",
303
+ slackIssues,
304
+ slackEnabled === true,
305
+ );
306
+ const telegramSnapshot: NotificationSettingsSnapshot["telegram"] = {
307
+ ...(telegramEnabled === undefined ? {} : { enabled: telegramEnabled }),
308
+ botToken: providerString(telegram, "botToken", "notifications.telegram.botToken", telegramIssues),
309
+ chatId: providerString(telegram, "chatId", "notifications.telegram.chatId", telegramIssues),
310
+ ...(activation === undefined ? {} : { activation }),
311
+ sound: providerChoice(
312
+ telegram,
313
+ "sound",
314
+ "notifications.telegram.sound",
315
+ "all",
316
+ ["all", "important", "none"],
317
+ telegramIssues,
318
+ ),
319
+ btw: { enabled: providerBoolean(btw, "enabled", "notifications.telegram.btw.enabled", true, telegramIssues) },
320
+ rich: { enabled: providerBoolean(rich, "enabled", "notifications.telegram.rich.enabled", true, telegramIssues) },
321
+ richDraft: {
322
+ enabled: providerBoolean(
323
+ richDraft,
324
+ "enabled",
325
+ "notifications.telegram.richDraft.enabled",
326
+ false,
327
+ telegramIssues,
328
+ ),
187
329
  },
188
- discord: {
189
- botToken: notificationSettingsString(discord.botToken),
190
- applicationId: notificationSettingsString(discord.applicationId),
191
- guildId: notificationSettingsString(discord.guildId),
192
- parentChannelId: notificationSettingsString(discord.parentChannelId),
330
+ toolActivity: {
331
+ enabled: providerBoolean(
332
+ toolActivity,
333
+ "enabled",
334
+ "notifications.telegram.toolActivity.enabled",
335
+ false,
336
+ telegramIssues,
337
+ ),
193
338
  },
194
- slack: {
195
- botToken: notificationSettingsString(slack.botToken),
196
- appToken: notificationSettingsString(slack.appToken),
197
- workspaceId: notificationSettingsString(slack.workspaceId),
198
- channelId: notificationSettingsString(slack.channelId),
199
- authorizedUserId: notificationSettingsString(slack.authorizedUserId),
339
+ streaming: {
340
+ enabled: providerBoolean(
341
+ streaming,
342
+ "enabled",
343
+ "notifications.telegram.streaming.enabled",
344
+ true,
345
+ telegramIssues,
346
+ ),
200
347
  },
201
- redact: notificationSettingsBoolean(notifications.redact, false),
202
- verbosity: notificationSettingsChoice<"lean" | "verbose">(notifications.verbosity, "lean", ["lean", "verbose"]),
203
- sessionScope: notificationSettingsChoice<"all" | "primary">(notifications.sessionScope, "all", [
204
- "all",
205
- "primary",
206
- ]),
348
+ topics: providerTopics(telegram, "notifications.telegram.topics", telegramIssues),
349
+ };
350
+ const discordSnapshot: NotificationSettingsSnapshot["discord"] = {
351
+ ...(discordEnabled === undefined ? {} : { enabled: discordEnabled }),
352
+ botToken: providerString(discord, "botToken", "notifications.discord.botToken", discordIssues),
353
+ applicationId: providerString(discord, "applicationId", "notifications.discord.applicationId", discordIssues),
354
+ guildId: providerString(discord, "guildId", "notifications.discord.guildId", discordIssues),
355
+ parentChannelId: providerString(
356
+ discord,
357
+ "parentChannelId",
358
+ "notifications.discord.parentChannelId",
359
+ discordIssues,
360
+ ),
361
+ };
362
+ const slackSnapshot: NotificationSettingsSnapshot["slack"] = {
363
+ ...(slackEnabled === undefined ? {} : { enabled: slackEnabled }),
364
+ botToken: providerString(slack, "botToken", "notifications.slack.botToken", slackIssues),
365
+ appToken: providerString(slack, "appToken", "notifications.slack.appToken", slackIssues),
366
+ workspaceId: providerString(slack, "workspaceId", "notifications.slack.workspaceId", slackIssues),
367
+ channelId: providerString(slack, "channelId", "notifications.slack.channelId", slackIssues),
368
+ authorizedUserId: providerString(slack, "authorizedUserId", "notifications.slack.authorizedUserId", slackIssues),
369
+ };
370
+ const providerIssues: Partial<Record<NotificationProvider, readonly ProviderResolutionIssue[]>> = {
371
+ ...(providerIssuesSnapshot("telegram", telegramIssues) ?? {}),
372
+ ...(providerIssuesSnapshot("discord", discordIssues) ?? {}),
373
+ ...(providerIssuesSnapshot("slack", slackIssues) ?? {}),
374
+ };
375
+ const snapshot: NotificationSettingsSnapshot = {
376
+ enabled: notificationGlobalBoolean(notifications.enabled, false),
377
+ telegram: telegramSnapshot,
378
+ discord: discordSnapshot,
379
+ slack: slackSnapshot,
380
+ redact: notificationGlobalBoolean(notifications.redact, false),
381
+ verbosity: notificationGlobalChoice(notifications.verbosity, "lean", ["lean", "verbose"]),
382
+ sessionScope: notificationGlobalChoice(notifications.sessionScope, "all", ["all", "primary"]),
207
383
  idleTimeoutMs: notificationIdleTimeoutMs(daemon.idleTimeoutMs),
208
384
  };
385
+ if (Object.keys(providerIssues).length > 0) snapshot.providerIssues = providerIssues;
386
+ return snapshot;
387
+ }
388
+
389
+ function notificationGlobalBoolean(value: unknown, fallback: boolean): boolean {
390
+ if (value === undefined) return fallback;
391
+ if (typeof value !== "boolean") throw notificationConfigurationError();
392
+ return value;
393
+ }
394
+
395
+ function notificationGlobalChoice<T extends string>(value: unknown, fallback: T, choices: readonly T[]): T {
396
+ if (value === undefined) return fallback;
397
+ if (typeof value === "string" && choices.includes(value as T)) return value as T;
398
+ throw notificationConfigurationError();
399
+ }
400
+
401
+ function notificationIdleTimeoutMs(value: unknown): number {
402
+ if (value === undefined) return 60_000;
403
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
404
+ throw notificationConfigurationError();
209
405
  }
210
406
 
211
407
  export interface NotificationConfig {
@@ -213,13 +409,17 @@ export interface NotificationConfig {
213
409
  botToken?: string;
214
410
  activation?: TelegramActivationMarkers;
215
411
  chatId?: string;
412
+ /** Optional nested Telegram intent metadata; absent means legacy configuration. */
413
+ telegram?: { enabled?: boolean };
216
414
  discord: {
415
+ enabled?: boolean;
217
416
  botToken?: string;
218
417
  applicationId?: string;
219
418
  guildId?: string;
220
419
  parentChannelId?: string;
221
420
  };
222
421
  slack: {
422
+ enabled?: boolean;
223
423
  botToken?: string;
224
424
  appToken?: string;
225
425
  workspaceId?: string;
@@ -228,51 +428,41 @@ export interface NotificationConfig {
228
428
  };
229
429
  redact: boolean;
230
430
  verbosity: "lean" | "verbose";
231
- /**
232
- * Which sessions may register a notification endpoint. `all` (default)
233
- * preserves historical behavior; `primary` suppresses GJC-spawned children
234
- * (those carrying {@link SPAWN_PROVENANCE_ENV}) unless they explicitly opt in.
235
- */
236
431
  sessionScope: "all" | "primary";
237
432
  sound: "all" | "important" | "none";
238
433
  idleTimeoutMs: number;
239
- btw: {
240
- enabled: boolean;
241
- };
242
- rich: {
243
- enabled: boolean;
244
- };
245
- richDraft: {
246
- enabled: boolean;
247
- };
248
- toolActivity: {
249
- enabled: boolean;
250
- };
251
- streaming: {
252
- enabled: boolean;
253
- };
254
- topics: {
255
- /**
256
- * Optional Telegram forum-topic name template with `{repo}`, `{branch}`,
257
- * and `{title}` placeholders. Unset preserves the built-in
258
- * `{repo}/{branch} - {title}` composition (with its title/repo/branch
259
- * fallbacks).
260
- */
261
- nameTemplate?: string;
262
- };
434
+ btw: { enabled: boolean };
435
+ rich: { enabled: boolean };
436
+ richDraft: { enabled: boolean };
437
+ toolActivity: { enabled: boolean };
438
+ streaming: { enabled: boolean };
439
+ topics: { nameTemplate?: string };
440
+ providerIssues?: Partial<Record<NotificationProvider, readonly ProviderResolutionIssue[]>>;
263
441
  }
264
442
 
265
- /** Read typed global-only notification config from a narrow settings reader. */
266
- export function getNotificationConfig(settings: NotificationSettingsReader): NotificationConfig {
267
- const snapshot = settings.getNotificationSettingsSnapshot();
443
+ function notificationConfigFromSnapshot(snapshot: NotificationSettingsSnapshot): NotificationConfig {
268
444
  const activation = readTelegramActivationMarkers(snapshot.telegram.activation);
269
- return {
445
+ const config: NotificationConfig = {
270
446
  enabled: snapshot.enabled,
271
447
  botToken: snapshot.telegram.botToken,
272
448
  ...(Object.keys(activation).length === 0 ? {} : { activation }),
273
449
  chatId: snapshot.telegram.chatId,
274
- discord: snapshot.discord,
275
- slack: snapshot.slack,
450
+ ...(snapshot.telegram.enabled === undefined ? {} : { telegram: { enabled: snapshot.telegram.enabled } }),
451
+ discord: {
452
+ ...(snapshot.discord.enabled === undefined ? {} : { enabled: snapshot.discord.enabled }),
453
+ botToken: snapshot.discord.botToken,
454
+ applicationId: snapshot.discord.applicationId,
455
+ guildId: snapshot.discord.guildId,
456
+ parentChannelId: snapshot.discord.parentChannelId,
457
+ },
458
+ slack: {
459
+ ...(snapshot.slack.enabled === undefined ? {} : { enabled: snapshot.slack.enabled }),
460
+ botToken: snapshot.slack.botToken,
461
+ appToken: snapshot.slack.appToken,
462
+ workspaceId: snapshot.slack.workspaceId,
463
+ channelId: snapshot.slack.channelId,
464
+ authorizedUserId: snapshot.slack.authorizedUserId,
465
+ },
276
466
  redact: snapshot.redact,
277
467
  verbosity: snapshot.verbosity,
278
468
  sessionScope: snapshot.sessionScope,
@@ -285,40 +475,16 @@ export function getNotificationConfig(settings: NotificationSettingsReader): Not
285
475
  streaming: snapshot.telegram.streaming,
286
476
  topics: snapshot.telegram.topics,
287
477
  };
478
+ if (snapshot.providerIssues) config.providerIssues = snapshot.providerIssues;
479
+ return config;
288
480
  }
289
481
 
290
- const notificationConfigSchema = z
291
- .object({
292
- notifications: z
293
- .object({
294
- enabled: z.boolean().optional(),
295
- discord: z
296
- .object({
297
- botToken: z.string().optional(),
298
- applicationId: z.string().optional(),
299
- guildId: z.string().optional(),
300
- parentChannelId: z.string().optional(),
301
- })
302
- .passthrough()
303
- .optional(),
304
- slack: z
305
- .object({
306
- botToken: z.string().optional(),
307
- appToken: z.string().optional(),
308
- workspaceId: z.string().optional(),
309
- channelId: z.string().optional(),
310
- authorizedUserId: z.string().optional(),
311
- })
312
- .passthrough()
313
- .optional(),
314
- redact: z.boolean().optional(),
315
- verbosity: z.enum(["lean", "verbose"]).optional(),
316
- })
317
- .passthrough()
318
- .optional(),
319
- })
320
- .passthrough();
482
+ /** Read typed global-only notification config from a narrow settings reader. */
483
+ export function getNotificationConfig(settings: NotificationSettingsReader): NotificationConfig {
484
+ return notificationConfigFromSnapshot(settings.getNotificationSettingsSnapshot());
485
+ }
321
486
 
487
+ const notificationConfigSchema = z.object({}).passthrough();
322
488
  type NotificationConfigFile = z.infer<typeof notificationConfigSchema>;
323
489
 
324
490
  /** Read daemon settings through the canonical validated config-file boundary. */
@@ -326,119 +492,236 @@ export function loadNotificationConfigFile(agentDir: string): LoadResult<Notific
326
492
  return new ConfigFile("config", notificationConfigSchema, path.join(agentDir, "config.yml")).tryLoad();
327
493
  }
328
494
 
329
- export function notificationConfigFromFile(
330
- value: NotificationConfigFile,
331
- ): Pick<NotificationConfig, "enabled" | "discord" | "slack" | "redact" | "verbosity"> {
332
- const notifications = value.notifications;
333
- return {
334
- enabled: notifications?.enabled ?? false,
335
- discord: notifications?.discord ?? {},
336
- slack: notifications?.slack ?? {},
337
- redact: notifications?.redact ?? false,
338
- verbosity: notifications?.verbosity ?? "lean",
339
- };
495
+ export function notificationConfigFromFile(value: NotificationConfigFile): NotificationConfig {
496
+ return notificationConfigFromSnapshot(parseNotificationSettingsSnapshot(value));
340
497
  }
341
498
 
342
499
  export function hasNonBlankValue(value: string | undefined): boolean {
343
500
  return typeof value === "string" && value.trim().length > 0;
344
501
  }
345
502
 
346
- /**
347
- * Resolve live assistant streaming independently of generic notification
348
- * lifecycle enablement. Explicit environment values override the durable
349
- * Telegram preference; otherwise streaming is available only to an active
350
- * configured Telegram identity.
351
- */
352
- export function isNotificationStreamingEnabled(input: { cfg: NotificationConfig; env: NodeJS.ProcessEnv }): boolean {
353
- const override = input.env.GJC_NOTIFICATIONS_STREAM?.trim().toLowerCase();
354
- if (override === "1") return true;
355
- if (override === "0" || override === "off" || override === "false") return false;
356
- return (
357
- input.cfg.streaming.enabled && isTelegramConfigured(input.cfg) && !getCurrentTelegramActivationMarker(input.cfg)
358
- );
503
+ function providerRequiredFields(
504
+ cfg: NotificationConfig,
505
+ provider: NotificationProvider,
506
+ ): Readonly<Record<string, unknown>> {
507
+ if (provider === "telegram") return { botToken: cfg.botToken, chatId: cfg.chatId };
508
+ if (provider === "discord") {
509
+ return {
510
+ botToken: cfg.discord.botToken,
511
+ applicationId: cfg.discord.applicationId,
512
+ guildId: cfg.discord.guildId,
513
+ parentChannelId: cfg.discord.parentChannelId,
514
+ };
515
+ }
516
+ return {
517
+ botToken: cfg.slack.botToken,
518
+ appToken: cfg.slack.appToken,
519
+ workspaceId: cfg.slack.workspaceId,
520
+ channelId: cfg.slack.channelId,
521
+ };
522
+ }
523
+
524
+ function providerOptionalFields(
525
+ cfg: NotificationConfig,
526
+ provider: NotificationProvider,
527
+ ): Readonly<Record<string, unknown>> {
528
+ return provider === "slack" ? { authorizedUserId: cfg.slack.authorizedUserId } : {};
529
+ }
530
+
531
+ function providerConfigEnabled(cfg: NotificationConfig, provider: NotificationProvider): unknown {
532
+ if (provider === "telegram") return cfg.telegram?.enabled;
533
+ if (provider === "discord") return cfg.discord.enabled;
534
+ return cfg.slack.enabled;
359
535
  }
360
536
 
361
- /** Is Telegram configured with usable non-blank boundary credentials? */
362
- export function isTelegramConfigured(
537
+ function providerStoredIssues(
538
+ cfg: NotificationConfig,
539
+ provider: NotificationProvider,
540
+ ): readonly ProviderResolutionIssue[] {
541
+ return cfg.providerIssues?.[provider] ?? [];
542
+ }
543
+
544
+ function uniqueProviderIssues(issues: readonly ProviderResolutionIssue[]): ProviderResolutionIssue[] {
545
+ const result: ProviderResolutionIssue[] = [];
546
+ for (const issue of issues) {
547
+ if (!result.some(existing => existing.path === issue.path && existing.code === issue.code))
548
+ result.push({ ...issue });
549
+ }
550
+ return result;
551
+ }
552
+
553
+ function providerFacts(
554
+ cfg: NotificationConfig,
555
+ provider: NotificationProvider,
556
+ ): {
557
+ configured: boolean;
558
+ quarantined: boolean;
559
+ issues: ProviderResolutionIssue[];
560
+ } {
561
+ const required = providerRequiredFields(cfg, provider);
562
+ const optional = providerOptionalFields(cfg, provider);
563
+ const issues = [...providerStoredIssues(cfg, provider)];
564
+ const anyRequiredPresent = Object.values(required).some(value => value !== undefined);
565
+ const explicitlyEnabled = providerConfigEnabled(cfg, provider) === true;
566
+ for (const [key, value] of Object.entries(required)) {
567
+ const pathName = `notifications.${provider}.${key}`;
568
+ if (value === undefined) {
569
+ if (anyRequiredPresent || explicitlyEnabled) issues.push({ path: pathName, code: "missing" });
570
+ } else if (typeof value !== "string") issues.push({ path: pathName, code: "wrong_type" });
571
+ else if (value.trim().length === 0) issues.push({ path: pathName, code: "blank" });
572
+ }
573
+ for (const [key, value] of Object.entries(optional)) {
574
+ if (value === undefined) continue;
575
+ const pathName = `notifications.${provider}.${key}`;
576
+ if (typeof value !== "string") issues.push({ path: pathName, code: "wrong_type" });
577
+ else if (value.trim().length === 0) issues.push({ path: pathName, code: "blank" });
578
+ }
579
+ const uniqueIssues = uniqueProviderIssues(issues);
580
+ const configured = Object.values(required).every(value => typeof value === "string" && value.trim().length > 0);
581
+ const quarantined = uniqueIssues.some(issue => issue.code !== "missing");
582
+ return { configured, quarantined, issues: uniqueIssues };
583
+ }
584
+
585
+ export function isProviderComplete(cfg: NotificationConfig, provider: NotificationProvider): boolean {
586
+ return providerFacts(cfg, provider).configured;
587
+ }
588
+
589
+ export function isTelegramComplete(
363
590
  cfg: NotificationConfig,
364
591
  ): cfg is NotificationConfig & { botToken: string; chatId: string } {
365
- return cfg.enabled && hasNonBlankValue(cfg.botToken) && hasNonBlankValue(cfg.chatId);
592
+ return isProviderComplete(cfg, "telegram");
366
593
  }
367
594
 
368
- /** Is Discord configured with all credentials and routing identifiers required by its daemon. */
369
- export function isDiscordConfigured(cfg: Pick<NotificationConfig, "enabled" | "discord">): cfg is Pick<
370
- NotificationConfig,
371
- "enabled"
372
- > & {
595
+ export function isDiscordComplete(cfg: NotificationConfig): cfg is NotificationConfig & {
373
596
  discord: { botToken: string; applicationId: string; guildId: string; parentChannelId: string };
374
597
  } {
375
- return (
376
- cfg.enabled &&
377
- hasNonBlankValue(cfg.discord.botToken) &&
378
- hasNonBlankValue(cfg.discord.applicationId) &&
379
- hasNonBlankValue(cfg.discord.guildId) &&
380
- hasNonBlankValue(cfg.discord.parentChannelId)
381
- );
598
+ return isProviderComplete(cfg, "discord");
382
599
  }
383
600
 
384
- /** Is Slack configured with both SDK tokens and its workspace/channel routing identifiers. */
385
- export function isSlackConfigured(cfg: Pick<NotificationConfig, "enabled" | "slack">): cfg is Pick<
386
- NotificationConfig,
387
- "enabled"
388
- > & {
601
+ export function isSlackComplete(cfg: NotificationConfig): cfg is NotificationConfig & {
389
602
  slack: { botToken: string; appToken: string; workspaceId: string; channelId: string };
390
603
  } {
391
- return (
392
- cfg.enabled &&
393
- hasNonBlankValue(cfg.slack.botToken) &&
394
- hasNonBlankValue(cfg.slack.appToken) &&
395
- hasNonBlankValue(cfg.slack.workspaceId) &&
396
- hasNonBlankValue(cfg.slack.channelId)
397
- );
604
+ return isProviderComplete(cfg, "slack");
605
+ }
606
+
607
+ export function resolveNotificationProvider(
608
+ cfg: NotificationConfig,
609
+ provider: NotificationProvider,
610
+ ): ProviderResolution {
611
+ const facts = providerFacts(cfg, provider);
612
+ const configuredEnabled = providerConfigEnabled(cfg, provider);
613
+ const explicitIssue = facts.issues.some(issue => issue.path === `notifications.${provider}.enabled`);
614
+ const explicit = typeof configuredEnabled === "boolean" ? configuredEnabled : undefined;
615
+ const desiredSource: ProviderResolution["desiredSource"] =
616
+ explicit !== undefined || explicitIssue ? "explicit" : "legacy";
617
+ const desiredEnabled = explicitIssue ? false : (explicit ?? (facts.configured && !facts.quarantined));
618
+ return {
619
+ provider,
620
+ configured: facts.configured,
621
+ quarantined: facts.quarantined,
622
+ desiredEnabled,
623
+ desiredSource,
624
+ effectiveEnabled: cfg.enabled && desiredEnabled && facts.configured && !facts.quarantined,
625
+ issues: facts.issues,
626
+ };
627
+ }
628
+
629
+ export function isProviderEffectivelyEnabled(cfg: NotificationConfig, provider: NotificationProvider): boolean {
630
+ return resolveNotificationProvider(cfg, provider).effectiveEnabled;
398
631
  }
399
632
 
400
- /** Is global config sufficient for auto-on (enabled + at least one configured adapter)? */
401
- export function isGloballyConfigured(cfg: NotificationConfig): boolean {
402
- return cfg.enabled && (isTelegramConfigured(cfg) || isDiscordConfigured(cfg) || isSlackConfigured(cfg));
633
+ export function hasAnyCompleteProvider(cfg: NotificationConfig): boolean {
634
+ return NOTIFICATION_PROVIDERS.some(provider => isProviderComplete(cfg, provider));
635
+ }
636
+
637
+ export function hasAnyEffectivelyEnabledProvider(cfg: NotificationConfig): boolean {
638
+ return NOTIFICATION_PROVIDERS.some(provider => isProviderEffectivelyEnabled(cfg, provider));
403
639
  }
404
640
 
405
641
  /**
406
- * Per-run opt-out for completion notifications, honored before settings lookups.
407
- *
408
- * `GJC_NOTIFY=off` (also `0` / `false`, case-insensitive) suppresses the
409
- * completion notification surface for this process only. `config.yml` is
410
- * untouched and child processes inherit the env var, which lets non-interactive
411
- * fleet runs (`gjc -p --no-session`) stay silent even when a user-level/global
412
- * completion notification configuration is enabled.
642
+ * Resolve generic live-stream policy. This policy only governs automatic
643
+ * current-session frames; it never changes durable provider eligibility.
413
644
  */
645
+ export type GenericNotificationSessionSource =
646
+ | "hard_opt_out"
647
+ | "session_local_off"
648
+ | "explicit_env"
649
+ | "token_env"
650
+ | "configured_provider"
651
+ | "session_scope"
652
+ | "none";
653
+
654
+ export interface GenericNotificationSessionEligibility {
655
+ enabled: boolean;
656
+ source: GenericNotificationSessionSource;
657
+ }
658
+
659
+ export type GenericNotificationStreamSource =
660
+ | "session_not_admitted"
661
+ | "env_on"
662
+ | "env_off"
663
+ | "durable_telegram"
664
+ | "none";
665
+
666
+ export interface GenericNotificationStreamPolicy {
667
+ enabled: boolean;
668
+ source: GenericNotificationStreamSource;
669
+ }
670
+
671
+ export interface GenericNotificationSessionEligibilityInput {
672
+ cfg: NotificationConfig;
673
+ env: NodeJS.ProcessEnv;
674
+ sessionDisabled: boolean;
675
+ spawnedByGjc?: boolean;
676
+ }
677
+
678
+ export function resolveGenericNotificationSessionEligibility(
679
+ input: GenericNotificationSessionEligibilityInput,
680
+ ): GenericNotificationSessionEligibility {
681
+ if (input.env.GJC_NOTIFICATIONS === "0") return { enabled: false, source: "hard_opt_out" };
682
+ if (input.sessionDisabled) return { enabled: false, source: "session_local_off" };
683
+ if (input.env.GJC_NOTIFICATIONS === "1") return { enabled: true, source: "explicit_env" };
684
+ if (input.env.GJC_NOTIFICATIONS_TOKEN) return { enabled: true, source: "token_env" };
685
+ if (input.spawnedByGjc && input.cfg.sessionScope === "primary") {
686
+ return { enabled: false, source: "session_scope" };
687
+ }
688
+ if (hasAnyEffectivelyEnabledProvider(input.cfg)) return { enabled: true, source: "configured_provider" };
689
+ return { enabled: false, source: "none" };
690
+ }
691
+
692
+ export function resolveGenericNotificationStreamPolicy(input: {
693
+ cfg: NotificationConfig;
694
+ env: NodeJS.ProcessEnv;
695
+ genericSessionEnabled: boolean;
696
+ }): GenericNotificationStreamPolicy {
697
+ if (!input.genericSessionEnabled) return { enabled: false, source: "session_not_admitted" };
698
+ const override = input.env.GJC_NOTIFICATIONS_STREAM?.trim().toLowerCase();
699
+ if (override === "1") return { enabled: true, source: "env_on" };
700
+ if (override === "0" || override === "off" || override === "false") return { enabled: false, source: "env_off" };
701
+ const durableEnabled =
702
+ input.cfg.streaming.enabled &&
703
+ isProviderEffectivelyEnabled(input.cfg, "telegram") &&
704
+ !getCurrentTelegramActivationMarker(input.cfg);
705
+ return { enabled: durableEnabled, source: durableEnabled ? "durable_telegram" : "none" };
706
+ }
707
+
414
708
  export function completionNotifyDisabledByEnv(env: NodeJS.ProcessEnv): boolean {
415
- const v = env.GJC_NOTIFY?.trim().toLowerCase();
416
- return v === "off" || v === "0" || v === "false";
709
+ const value = env.GJC_NOTIFY?.trim().toLowerCase();
710
+ return value === "off" || value === "0" || value === "false";
417
711
  }
418
712
 
419
- /** Canonical host eligibility for the dormant notification session surface. */
420
713
  export interface NotificationHostEligibilityInput {
421
714
  env: NodeJS.ProcessEnv;
422
- /** False for host modes that cannot own a notification session endpoint. */
423
715
  hostModeSupported?: boolean;
424
- /** Task recursion depth; helper/subagent sessions must not own remote surfaces. */
425
716
  taskDepth?: number;
426
- /** Parent subagent id/prefix; present for helper/subagent sessions even when depth is omitted. */
427
717
  parentTaskPrefix?: string;
428
- /** Role-agent type/name; present for task sessions even if depth metadata is lost. */
429
718
  currentAgentType?: string;
430
- /** Canonical global session scope; absent preserves the default `all` behavior. */
431
719
  sessionScope?: NotificationConfig["sessionScope"];
432
- /** Whether this process was spawned by one of GJC's marked child spawn sites. */
433
720
  spawnedByGjc?: boolean;
434
721
  }
435
722
 
436
- /**
437
- * Resolve whether this host may receive the dormant notification controller.
438
- * This intentionally says nothing about whether an adapter is configured: an
439
- * eligible unconfigured host still gets a zero-side-effect control surface.
440
- */
441
- export function isNotificationHostEligible(input: NotificationHostEligibilityInput): boolean {
723
+ /** Generic host eligibility for the dormant automatic notification surface. */
724
+ export function isGenericNotificationHostEligible(input: NotificationHostEligibilityInput): boolean {
442
725
  if (completionNotifyDisabledByEnv(input.env)) return false;
443
726
  if (input.hostModeSupported === false) return false;
444
727
  if ((input.taskDepth ?? 0) > 0 || input.parentTaskPrefix || input.currentAgentType) return false;
@@ -448,31 +731,19 @@ export function isNotificationHostEligible(input: NotificationHostEligibilityInp
448
731
  return true;
449
732
  }
450
733
 
451
- /**
452
- * Legacy compatibility helper for callers that require both host eligibility
453
- * and a currently configured or explicit notification runtime.
454
- */
455
- export function shouldRegisterNotificationsExtension(input: {
734
+ export interface GenericNotificationRegistrationInput {
456
735
  env: NodeJS.ProcessEnv;
457
736
  cfg?: NotificationConfig;
458
- /** Task recursion depth; helper/subagent sessions must not spawn remote surfaces. */
459
737
  taskDepth?: number;
460
- /** Parent subagent id/prefix; present for helper/subagent sessions even when depth is omitted. */
461
738
  parentTaskPrefix?: string;
462
- /** Role-agent type/name; present for task sessions even if depth metadata is lost. */
463
739
  currentAgentType?: string;
464
- /**
465
- * True when this session was launched by one of GJC's own programmatic
466
- * separate-process child spawn sites (marked via {@link SPAWN_PROVENANCE_ENV}).
467
- * Under `notifications.sessionScope = "primary"` such children are suppressed
468
- * unless they explicitly opt in, so an interactive parent that fans out work
469
- * does not flood the paired chat with topics for children the user never
470
- * asked for. User-opened sessions (CLI/tmux/headless) never carry the marker.
471
- */
472
740
  spawnedByGjc?: boolean;
473
- }): boolean {
741
+ }
742
+
743
+ /** Generic registration admission; direct provider actions do not call this helper. */
744
+ export function shouldRegisterGenericNotificationsExtension(input: GenericNotificationRegistrationInput): boolean {
474
745
  if (
475
- !isNotificationHostEligible({
746
+ !isGenericNotificationHostEligible({
476
747
  env: input.env,
477
748
  taskDepth: input.taskDepth,
478
749
  parentTaskPrefix: input.parentTaskPrefix,
@@ -483,35 +754,12 @@ export function shouldRegisterNotificationsExtension(input: {
483
754
  ) {
484
755
  return false;
485
756
  }
486
- return (
487
- input.env.GJC_NOTIFICATIONS === "1" ||
488
- Boolean(input.env.GJC_NOTIFICATIONS_TOKEN) ||
489
- Boolean(input.cfg && isGloballyConfigured(input.cfg))
490
- );
757
+ if (input.env.GJC_NOTIFICATIONS === "1" || input.env.GJC_NOTIFICATIONS_TOKEN) return true;
758
+ return input.cfg !== undefined && hasAnyEffectivelyEnabledProvider(input.cfg);
491
759
  }
492
760
 
493
- /**
494
- * Resolve whether THIS session should run notifications.
495
- * Precedence (highest first):
496
- * 1) env.GJC_NOTIFICATIONS === "0" -> false (hard opt-out)
497
- * 2) sessionDisabled === true -> false (local /notify off)
498
- * 3) env.GJC_NOTIFICATIONS === "1" || env.GJC_NOTIFICATIONS_TOKEN present -> true (legacy explicit)
499
- * 4) spawned GJC child with `sessionScope=primary` -> false
500
- * 5) isGloballyConfigured(cfg) -> true (global auto-on)
501
- * 6) otherwise false
502
- */
503
- export function isSessionNotificationsEnabled(input: {
504
- cfg: NotificationConfig;
505
- env: NodeJS.ProcessEnv;
506
- sessionDisabled: boolean;
507
- /** This process was programmatically spawned by GJC (consumed at SDK startup). */
508
- spawnedByGjc?: boolean;
509
- }): boolean {
510
- if (input.env.GJC_NOTIFICATIONS === "0") return false;
511
- if (input.sessionDisabled) return false;
512
- if (input.env.GJC_NOTIFICATIONS === "1" || input.env.GJC_NOTIFICATIONS_TOKEN) return true;
513
- if (input.spawnedByGjc && input.cfg.sessionScope === "primary") return false;
514
- return isGloballyConfigured(input.cfg);
761
+ export function isGenericNotificationSessionEnabled(input: GenericNotificationSessionEligibilityInput): boolean {
762
+ return resolveGenericNotificationSessionEligibility(input).enabled;
515
763
  }
516
764
 
517
765
  /** Mask a bot token for display: first 4 chars + "…" + "(len N)"; "(unset)" when undefined/empty. Never reveal full token. */