@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
@@ -7,11 +7,18 @@ import { createInterface } from "node:readline/promises";
7
7
  import { APP_NAME } from "@gajae-code/utils/dirs";
8
8
  import chalk from "chalk";
9
9
  import { Settings, type SettingsAtomicPatch } from "../config/settings";
10
- import { type EnsureChatDaemonResult, ensureDiscordDaemon, ensureSlackDaemon } from "../sdk/bus/chat-daemon-control";
10
+ import {
11
+ ChatDaemonController,
12
+ type EnsureChatDaemonResult,
13
+ ensureDiscordDaemon,
14
+ ensureSlackDaemon,
15
+ } from "../sdk/bus/chat-daemon-control";
11
16
  import { getNotificationConfig, maskToken, tokenFingerprint } from "../sdk/bus/config";
12
17
  import {
13
18
  clearTelegramActivationMarker,
14
19
  createTelegramActivationMarker,
20
+ mutateNotificationProvider,
21
+ type NotificationProviderRuntimeAuthority,
15
22
  observedTelegramActivationMarker,
16
23
  type ProposedTelegramIdentity,
17
24
  persistTelegramActivationMarker,
@@ -35,6 +42,7 @@ import {
35
42
  resolveTelegramSetupPreflight,
36
43
  } from "../sdk/bus/telegram-daemon";
37
44
  import { runDaemonInternal } from "../sdk/bus/telegram-daemon-cli";
45
+ import { TelegramDaemonController } from "../sdk/bus/telegram-daemon-control";
38
46
  import {
39
47
  runTelegramSetup as runTelegramPairingSetup,
40
48
  type TelegramSetupPreflight,
@@ -93,75 +101,99 @@ export interface NotifyCommandDeps {
93
101
  }
94
102
 
95
103
  export function parseNotifyArgs(args: string[]): NotifyCommandArgs | undefined {
96
- if (args.length === 0 || args[0] !== "notify") {
97
- return undefined;
98
- }
99
-
104
+ if (args.length === 0 || args[0] !== "notify") return undefined;
100
105
  const action = args[1];
101
- if (action === "setup" || action === "status") {
106
+ const providerValue = (value: string | undefined): NotifySetupProvider | undefined =>
107
+ value === "telegram" || value === "discord" || value === "slack" ? value : undefined;
108
+ const parseFlags = (
109
+ rest: string[],
110
+ valueFlags: ReadonlySet<string>,
111
+ booleanFlags: ReadonlySet<string>,
112
+ ): Map<string, string | true> | undefined => {
113
+ const parsed = new Map<string, string | true>();
114
+ for (let index = 0; index < rest.length; index++) {
115
+ const flag = rest[index];
116
+ if (!flag?.startsWith("--") || parsed.has(flag)) return undefined;
117
+ if (booleanFlags.has(flag)) {
118
+ parsed.set(flag, true);
119
+ continue;
120
+ }
121
+ if (!valueFlags.has(flag)) return undefined;
122
+ const value = rest[++index];
123
+ if (!value || value.startsWith("--")) return undefined;
124
+ parsed.set(flag, value);
125
+ }
126
+ return parsed;
127
+ };
128
+
129
+ if (action === "setup") {
102
130
  const rest = args.slice(2);
103
- const flag = (name: string): string | undefined => {
104
- const i = rest.indexOf(name);
105
- return i >= 0 ? rest[i + 1] : undefined;
131
+ const positional = rest[0]?.startsWith("--") ? undefined : rest.shift();
132
+ const provider = positional === undefined ? undefined : providerValue(positional);
133
+ if (positional !== undefined && !provider) return undefined;
134
+ const flags = parseFlags(
135
+ rest,
136
+ new Set([
137
+ "--token",
138
+ "--chat-id",
139
+ "--discord-bot-token",
140
+ "--discord-application-id",
141
+ "--discord-guild-id",
142
+ "--discord-parent-channel-id",
143
+ "--slack-bot-token",
144
+ "--slack-app-token",
145
+ "--slack-workspace-id",
146
+ "--slack-channel-id",
147
+ "--slack-authorized-user-id",
148
+ ]),
149
+ new Set(["--redact"]),
150
+ );
151
+ if (!flags) return undefined;
152
+ const value = (name: string): string | undefined => {
153
+ const found = flags.get(name);
154
+ return typeof found === "string" ? found : undefined;
106
155
  };
107
- const valueFlags = [
108
- "--token",
109
- "--chat-id",
110
- "--discord-bot-token",
111
- "--discord-application-id",
112
- "--discord-guild-id",
113
- "--discord-parent-channel-id",
114
- "--slack-bot-token",
115
- "--slack-app-token",
116
- "--slack-workspace-id",
117
- "--slack-channel-id",
118
- "--slack-authorized-user-id",
119
- ];
120
- if (
121
- valueFlags.some(name => {
122
- const index = rest.indexOf(name);
123
- const value = index >= 0 ? rest[index + 1] : undefined;
124
- return index >= 0 && (!value || value.startsWith("--"));
125
- })
126
- )
127
- return undefined;
128
- const provider = rest[0]?.startsWith("--") ? undefined : rest[0];
129
- if (provider !== undefined && provider !== "telegram" && provider !== "discord" && provider !== "slack") {
130
- return undefined;
131
- }
132
156
  return {
133
157
  action,
134
- rawArgs: rest,
158
+ rawArgs: args.slice(2),
135
159
  ...(provider ? { provider } : {}),
136
- token: flag("--token"),
137
- chatId: flag("--chat-id"),
138
- ...(flag("--discord-bot-token") ? { discordBotToken: flag("--discord-bot-token") } : {}),
139
- ...(flag("--discord-application-id") ? { discordApplicationId: flag("--discord-application-id") } : {}),
140
- ...(flag("--discord-guild-id") ? { discordGuildId: flag("--discord-guild-id") } : {}),
141
- ...(flag("--discord-parent-channel-id")
142
- ? { discordParentChannelId: flag("--discord-parent-channel-id") }
143
- : {}),
144
- ...(flag("--slack-bot-token") ? { slackBotToken: flag("--slack-bot-token") } : {}),
145
- ...(flag("--slack-app-token") ? { slackAppToken: flag("--slack-app-token") } : {}),
146
- ...(flag("--slack-workspace-id") ? { slackWorkspaceId: flag("--slack-workspace-id") } : {}),
147
- ...(flag("--slack-channel-id") ? { slackChannelId: flag("--slack-channel-id") } : {}),
148
- ...(flag("--slack-authorized-user-id") ? { slackAuthorizedUserId: flag("--slack-authorized-user-id") } : {}),
149
- redact: rest.includes("--redact"),
160
+ token: value("--token"),
161
+ chatId: value("--chat-id"),
162
+ discordBotToken: value("--discord-bot-token"),
163
+ discordApplicationId: value("--discord-application-id"),
164
+ discordGuildId: value("--discord-guild-id"),
165
+ discordParentChannelId: value("--discord-parent-channel-id"),
166
+ slackBotToken: value("--slack-bot-token"),
167
+ slackAppToken: value("--slack-app-token"),
168
+ slackWorkspaceId: value("--slack-workspace-id"),
169
+ slackChannelId: value("--slack-channel-id"),
170
+ slackAuthorizedUserId: value("--slack-authorized-user-id"),
171
+ redact: flags.get("--redact") === true,
150
172
  };
151
173
  }
152
- if (action === "health" || action === "test" || action === "recovery") {
174
+ if (action === "status") {
175
+ return args.length === 2 ? { action, rawArgs: [] } : undefined;
176
+ }
177
+ if (action === "health" || action === "test") {
153
178
  const rest = args.slice(2);
154
- const flag = (name: string): string | undefined => {
155
- const i = rest.indexOf(name);
156
- return i >= 0 ? rest[i + 1] : undefined;
157
- };
179
+ const flags = parseFlags(
180
+ rest,
181
+ new Set(action === "health" ? ["--provider"] : ["--provider", "--message"]),
182
+ new Set(action === "health" ? ["--probe"] : []),
183
+ );
184
+ if (!flags) return undefined;
185
+ const rawProvider = flags.get("--provider");
186
+ const provider = typeof rawProvider === "string" ? providerValue(rawProvider) : undefined;
187
+ if (rawProvider !== undefined && !provider) return undefined;
158
188
  return {
159
189
  action,
160
190
  rawArgs: rest,
161
- probe: rest.includes("--probe"),
162
- message: flag("--message"),
191
+ ...(provider ? { provider } : {}),
192
+ probe: flags.get("--probe") === true,
193
+ message: typeof flags.get("--message") === "string" ? (flags.get("--message") as string) : undefined,
163
194
  };
164
195
  }
196
+ if (action === "recovery") return args.length === 2 ? { action, rawArgs: [] } : undefined;
165
197
  if (action === "daemon-internal") {
166
198
  return {
167
199
  action,
@@ -169,7 +201,6 @@ export function parseNotifyArgs(args: string[]): NotifyCommandArgs | undefined {
169
201
  rawArgs: args.slice(2),
170
202
  };
171
203
  }
172
-
173
204
  return undefined;
174
205
  }
175
206
 
@@ -273,18 +304,46 @@ async function runDiscordSetup(cmd: NotifyCommandArgs, deps: NotifyCommandDeps):
273
304
  deps,
274
305
  );
275
306
  const settings = await getSettings(deps);
276
- const patches: SettingsAtomicPatch[] = [
277
- { path: "notifications.discord.botToken", op: "set", value: botToken },
278
- { path: "notifications.discord.applicationId", op: "set", value: applicationId },
279
- { path: "notifications.discord.guildId", op: "set", value: guildId },
280
- { path: "notifications.discord.parentChannelId", op: "set", value: parentChannelId },
281
- { path: "notifications.enabled", op: "set", value: true },
282
- ];
283
- if (cmd.redact) patches.push({ path: "notifications.redact", op: "set", value: true });
284
- await settings.commitAtomicBatch(patches);
285
- const daemon = await ensureConfiguredProviderDaemon("discord", settings, deps);
307
+ let activationFailure: string | undefined;
308
+ let activationOutcome: EnsureChatDaemonResult | undefined;
309
+ const runtime: NotificationProviderRuntimeAuthority = {
310
+ activate: async provider => {
311
+ if (provider !== "discord") throw new Error("Unexpected provider activation request.");
312
+ try {
313
+ const result = await ensureConfiguredProviderDaemon("discord", settings, deps);
314
+ if (result === "disabled") throw new Error("Discord runtime did not activate.");
315
+ activationOutcome = result;
316
+ } catch (error) {
317
+ activationFailure = error instanceof Error ? error.message : "Discord runtime activation failed.";
318
+ throw error;
319
+ }
320
+ },
321
+ deactivate: async () => undefined,
322
+ };
323
+ const result = await mutateNotificationProvider({
324
+ settings,
325
+ mutation: {
326
+ provider: "discord",
327
+ botToken: { action: "replace", value: botToken },
328
+ applicationId,
329
+ guildId,
330
+ parentChannelId,
331
+ },
332
+ configureAndActivate: true,
333
+ ...(cmd.redact ? { redact: true } : {}),
334
+ runtime,
335
+ });
336
+ if (result.status === "commit_failed")
337
+ throw new Error("Discord configuration was not saved because the CAS commit failed.");
338
+ if (result.status !== "activated") {
339
+ const detail = `runtime activation failed: ${activationFailure ?? result.status}`;
340
+ process.stderr.write(`Discord configuration saved, but ${detail}.\n`);
341
+ if (deps.setExitCode) deps.setExitCode(1);
342
+ else process.exitCode = 1;
343
+ return;
344
+ }
286
345
  process.stdout.write(
287
- `Discord notifications enabled. botToken=${maskToken(botToken)} applicationId=${applicationId} guildId=${guildId} parentChannelId=${parentChannelId} daemon=${daemon}\n`,
346
+ `Discord configuration saved and activated. botToken=${maskToken(botToken)} applicationId=${applicationId} guildId=${guildId} parentChannelId=${parentChannelId} daemon=${activationOutcome ?? "attached"}\n`,
288
347
  );
289
348
  }
290
349
 
@@ -295,21 +354,47 @@ async function runSlackSetup(cmd: NotifyCommandArgs, deps: NotifyCommandDeps): P
295
354
  const channelId = await promptSetupValue(cmd.slackChannelId, "--slack-channel-id", false, deps);
296
355
  const authorizedUserId = cmd.slackAuthorizedUserId?.trim() || undefined;
297
356
  const settings = await getSettings(deps);
298
- const patches: SettingsAtomicPatch[] = [
299
- { path: "notifications.slack.botToken", op: "set", value: botToken },
300
- { path: "notifications.slack.appToken", op: "set", value: appToken },
301
- { path: "notifications.slack.workspaceId", op: "set", value: workspaceId },
302
- { path: "notifications.slack.channelId", op: "set", value: channelId },
303
- authorizedUserId === undefined
304
- ? { path: "notifications.slack.authorizedUserId", op: "unset" }
305
- : { path: "notifications.slack.authorizedUserId", op: "set", value: authorizedUserId },
306
- { path: "notifications.enabled", op: "set", value: true },
307
- ];
308
- if (cmd.redact) patches.push({ path: "notifications.redact", op: "set", value: true });
309
- await settings.commitAtomicBatch(patches);
310
- const daemon = await ensureConfiguredProviderDaemon("slack", settings, deps);
357
+ let activationFailure: string | undefined;
358
+ let activationOutcome: EnsureChatDaemonResult | undefined;
359
+ const runtime: NotificationProviderRuntimeAuthority = {
360
+ activate: async provider => {
361
+ if (provider !== "slack") throw new Error("Unexpected provider activation request.");
362
+ try {
363
+ const result = await ensureConfiguredProviderDaemon("slack", settings, deps);
364
+ if (result === "disabled") throw new Error("Slack runtime did not activate.");
365
+ activationOutcome = result;
366
+ } catch (error) {
367
+ activationFailure = error instanceof Error ? error.message : "Slack runtime activation failed.";
368
+ throw error;
369
+ }
370
+ },
371
+ deactivate: async () => undefined,
372
+ };
373
+ const result = await mutateNotificationProvider({
374
+ settings,
375
+ mutation: {
376
+ provider: "slack",
377
+ botToken: { action: "replace", value: botToken },
378
+ appToken: { action: "replace", value: appToken },
379
+ workspaceId,
380
+ channelId,
381
+ authorizedUserId,
382
+ },
383
+ configureAndActivate: true,
384
+ ...(cmd.redact ? { redact: true } : {}),
385
+ runtime,
386
+ });
387
+ if (result.status === "commit_failed")
388
+ throw new Error("Slack configuration was not saved because the CAS commit failed.");
389
+ if (result.status !== "activated") {
390
+ const detail = `runtime activation failed: ${activationFailure ?? result.status}`;
391
+ process.stderr.write(`Slack configuration saved, but ${detail}.\n`);
392
+ if (deps.setExitCode) deps.setExitCode(1);
393
+ else process.exitCode = 1;
394
+ return;
395
+ }
311
396
  process.stdout.write(
312
- `Slack notifications enabled. botToken=${maskToken(botToken)} appToken=${maskToken(appToken)} workspaceId=${workspaceId} channelId=${channelId} authorizedUserId=${authorizedUserId ?? "(unset; inbound denied)"} daemon=${daemon}\n`,
397
+ `Slack configuration saved and activated. botToken=${maskToken(botToken)} appToken=${maskToken(appToken)} workspaceId=${workspaceId} channelId=${channelId} authorizedUserId=${authorizedUserId ?? "(unset; inbound denied)"} daemon=${activationOutcome ?? "attached"}\n`,
313
398
  );
314
399
  }
315
400
 
@@ -365,6 +450,7 @@ async function runTelegramSetup(cmd: NotifyCommandArgs, deps: NotifyCommandDeps)
365
450
  if (result.pairingSource === "provided") {
366
451
  process.stdout.write(`Using provided chat id ${result.chatId} (non-interactive).\n`);
367
452
  }
453
+ let settingsCommitted = false;
368
454
  try {
369
455
  const proposedIdentity = deps.setupPreflight
370
456
  ? proposedIdentityFromSetupPreflight(deps.setupPreflight, token.trim(), result.chatId)
@@ -385,9 +471,11 @@ async function runTelegramSetup(cmd: NotifyCommandArgs, deps: NotifyCommandDeps)
385
471
  { path: "notifications.telegram.botToken", op: "set", value: token.trim() },
386
472
  { path: "notifications.telegram.chatId", op: "set", value: result.chatId },
387
473
  { path: "notifications.enabled", op: "set", value: true },
474
+ { path: "notifications.telegram.enabled", op: "set", value: true },
388
475
  ];
389
476
  if (deps.setupRedact ?? cmd.redact) patches.push({ path: "notifications.redact", op: "set", value: true });
390
477
  const receipt = await settings.commitAtomicBatch(patches);
478
+ settingsCommitted = true;
391
479
  const activationMarker = createTelegramActivationMarker({
392
480
  botToken: token.trim(),
393
481
  chatId: result.chatId,
@@ -422,6 +510,7 @@ async function runTelegramSetup(cmd: NotifyCommandArgs, deps: NotifyCommandDeps)
422
510
  });
423
511
  if (activation.status === "blocked_identity") {
424
512
  const restored = await activation.restore();
513
+ if (restored.status === "restored" || restored.status === "still_blocked") settingsCommitted = false;
425
514
  const detail =
426
515
  restored.status === "restored"
427
516
  ? "Telegram activation was blocked by a foreign daemon; previous settings were restored."
@@ -432,9 +521,18 @@ async function runTelegramSetup(cmd: NotifyCommandArgs, deps: NotifyCommandDeps)
432
521
  : "Telegram activation was blocked; refusing to report setup success.";
433
522
  throw new Error(detail);
434
523
  }
524
+ if (activation.status === "activation_failed") {
525
+ receipt.discard();
526
+ throw new Error(activation.message);
527
+ }
528
+ receipt.discard();
435
529
  } catch (error) {
436
530
  const detail = sanitizeDiagnostic(error instanceof Error ? error.message : "unknown persistence failure", token);
437
- throw new Error(`Unable to persist and activate Telegram notification settings: ${detail}`);
531
+ throw new Error(
532
+ settingsCommitted
533
+ ? `Telegram notification settings were saved, but activation or recovery failed: ${detail}`
534
+ : `Unable to persist and activate Telegram notification settings: ${detail}`,
535
+ );
438
536
  }
439
537
  process.stdout.write(
440
538
  `Notifications enabled. botToken=${maskToken(token)} chatId=${result.chatId} threaded=${result.threadedLabel}\n`,
@@ -586,6 +684,7 @@ async function runHealth(deps: NotifyCommandDeps, cmd: NotifyCommandArgs): Promi
586
684
  const settings = await getSettings(deps);
587
685
  const report = await checkNotificationHealth({
588
686
  settings,
687
+ provider: cmd.provider,
589
688
  probe: cmd.probe,
590
689
  deps: { fetchImpl: deps.fetchImpl, apiBase: deps.apiBase },
591
690
  });
@@ -598,8 +697,19 @@ async function runTest(deps: NotifyCommandDeps, cmd: NotifyCommandArgs): Promise
598
697
  const settings = await getSettings(deps);
599
698
  const result = await sendNotificationTest({
600
699
  settings,
700
+ provider: cmd.provider,
601
701
  text: cmd.message,
602
- deps: { fetchImpl: deps.fetchImpl, apiBase: deps.apiBase },
702
+ deps: {
703
+ fetchImpl: deps.fetchImpl,
704
+ apiBase: deps.apiBase,
705
+ providerRuntimeStatus: async provider => {
706
+ const status =
707
+ provider === "telegram"
708
+ ? await new TelegramDaemonController(settings).status()
709
+ : await new ChatDaemonController(settings, provider).status();
710
+ return status.health === "running" ? "ready" : "inactive";
711
+ },
712
+ },
603
713
  });
604
714
  process.stdout.write(`${formatNotificationTestResult(result)}\n`);
605
715
  if (!result.ok && deps.setExitCode) deps.setExitCode(1);
@@ -616,8 +726,8 @@ export function printNotifyHelp(): void {
616
726
  process.stdout.write(`${chalk.bold(`${APP_NAME} notify`)} - Configure Telegram, Discord, or Slack notifications
617
727
 
618
728
  ${chalk.bold("Interactive path:")}
619
- In a running GJC session, use /settings → Notifications for setup, health, test, recovery,
620
- reconnect, global enable/disable, adapter-local Telegram removal, and session on/off.
729
+ In a running GJC session, use /settings → Notifications for first-class Telegram, Discord,
730
+ and Slack configure/edit/repair, desired intent, health, test, removal, global master, and session controls.
621
731
  The CLI subcommands below remain the authoritative headless and automation fallback.
622
732
 
623
733
  ${chalk.bold("Usage:")}
@@ -625,15 +735,15 @@ ${chalk.bold("Usage:")}
625
735
  ${APP_NAME} notify setup discord --discord-bot-token <token> --discord-application-id <id> --discord-guild-id <id> --discord-parent-channel-id <id>
626
736
  ${APP_NAME} notify setup slack --slack-bot-token <token> --slack-app-token <token> --slack-workspace-id <id> --slack-channel-id <id> [--slack-authorized-user-id <id>]
627
737
  ${APP_NAME} notify status
628
- ${APP_NAME} notify health [--probe]
629
- ${APP_NAME} notify test [--message <text>]
738
+ ${APP_NAME} notify health [--provider telegram|discord|slack] [--probe]
739
+ ${APP_NAME} notify test [--provider telegram|discord|slack] [--message <text>]
630
740
  ${APP_NAME} notify recovery
631
741
 
632
742
  ${chalk.bold("Subcommands:")}
633
- setup Pair Telegram or save complete non-interactive Discord/Slack notification settings
634
- status Show notification configuration without secrets
635
- health Report config, daemon-ownership and endpoint health (--probe adds a Telegram reachability check)
636
- test Send a one-off test notification through the configured Telegram adapter
743
+ setup Pair Telegram or atomically save and activate complete Discord/Slack settings
744
+ status Show global master and provider configured/repair/desired/effective state without secrets
745
+ health Report selected provider state; --probe uses REST only and never opens Gateway/Socket Mode
746
+ test Send a one-off test through one selected or uniquely effective provider
637
747
  recovery Clear dead-owner daemon locks and stale per-session endpoint files (never touches a live owner)
638
748
 
639
749
  ${chalk.bold("Examples:")}
@@ -642,8 +752,8 @@ ${chalk.bold("Examples:")}
642
752
  ${APP_NAME} notify setup discord --discord-bot-token <token> --discord-application-id <id> --discord-guild-id <id> --discord-parent-channel-id <id>
643
753
  ${APP_NAME} notify setup slack --slack-bot-token <token> --slack-app-token <token> --slack-workspace-id <id> --slack-channel-id <id> [--slack-authorized-user-id <id>]
644
754
  ${APP_NAME} notify status
645
- ${APP_NAME} notify health --probe
646
- ${APP_NAME} notify test --message "hello from gjc"
755
+ ${APP_NAME} notify health --provider discord --probe
756
+ ${APP_NAME} notify test --provider slack --message "hello from gjc"
647
757
  ${APP_NAME} notify recovery
648
758
 
649
759
  ${chalk.bold("Threaded Mode:")}
@@ -40,7 +40,11 @@ export default class Notify extends Command {
40
40
  description: "Slack user id authorized for inbound replies and commands",
41
41
  }),
42
42
  redact: Flags.boolean({ description: "Enable redaction of remote notification content" }),
43
- probe: Flags.boolean({ description: "notify health: probe Telegram reachability (getMe)" }),
43
+ provider: Flags.string({
44
+ description: "notify health/test: select telegram, discord, or slack",
45
+ options: ["telegram", "discord", "slack"],
46
+ }),
47
+ probe: Flags.boolean({ description: "notify health: run the selected provider's REST diagnostic" }),
44
48
  message: Flags.string({ description: "notify test: custom message body" }),
45
49
  "owner-id": Flags.string({ description: "Internal: daemon owner id" }),
46
50
  "agent-dir": Flags.string({ description: "Internal: agent dir for the daemon" }),
@@ -64,16 +68,23 @@ export default class Notify extends Command {
64
68
  ...(agentDir ? ["--agent-dir", agentDir] : []),
65
69
  ...extra,
66
70
  ];
67
- const provider = extra[0];
71
+ const positionalProvider = action === "setup" ? extra[0] : undefined;
68
72
  if (
69
- action === "setup" &&
70
- provider !== undefined &&
71
- provider !== "telegram" &&
72
- provider !== "discord" &&
73
- provider !== "slack"
73
+ positionalProvider !== undefined &&
74
+ positionalProvider !== "telegram" &&
75
+ positionalProvider !== "discord" &&
76
+ positionalProvider !== "slack"
74
77
  ) {
75
- throw new Error(`Unknown notification provider: ${provider}`);
78
+ throw new Error(`Unknown notification provider: ${positionalProvider}`);
79
+ }
80
+ const providerFlag = flagRec.provider as string | undefined;
81
+ if (providerFlag && action !== "health" && action !== "test") {
82
+ throw new Error("--provider is valid only for notify health and notify test.");
83
+ }
84
+ if (action !== "setup" && action !== "daemon-internal" && extra.length > 0) {
85
+ throw new Error(`Unexpected notify arguments: ${extra.join(" ")}`);
76
86
  }
87
+ const provider = providerFlag ?? positionalProvider;
77
88
 
78
89
  const cmd: NotifyCommandArgs = {
79
90
  action: action as NotifyAction,
@@ -17,6 +17,10 @@ const DEFAULT_OPTIONS: Required<FileLockOptions> = {
17
17
 
18
18
  type LockInfo = FileLockOwnerToken;
19
19
 
20
+ export const FileLockTestHooks: {
21
+ afterParentMkdir?: (lockPath: string) => void | Promise<void>;
22
+ } = {};
23
+
20
24
  /**
21
25
  * Returns the OS-provided process start timestamp for PID-reuse detection.
22
26
  * `ps` is available on the supported Unix hosts (macOS and Linux), unlike
@@ -237,6 +241,8 @@ async function removeStaleLockForAcquire(lockPath: string, snapshot: LockStaleSn
237
241
 
238
242
  async function tryAcquireLock(lockPath: string): Promise<LockInfo | null> {
239
243
  await fs.mkdir(path.dirname(lockPath), { recursive: true });
244
+ const afterParentMkdir = FileLockTestHooks.afterParentMkdir;
245
+ if (afterParentMkdir) await afterParentMkdir(lockPath);
240
246
  try {
241
247
  await fs.mkdir(lockPath);
242
248
  return await writeLockInfo(lockPath);
@@ -37,6 +37,8 @@ export interface DiscoveryMergeInput {
37
37
  models: readonly Model<Api>[];
38
38
  state: ProviderDiscoveryState;
39
39
  warning?: string;
40
+ authGeneration?: string;
41
+ fetched?: boolean;
40
42
  }
41
43
 
42
44
  export interface ProviderDiscoveryCallbacks<TProvider extends DiscoveryProvider> {
@@ -44,7 +46,9 @@ export interface ProviderDiscoveryCallbacks<TProvider extends DiscoveryProvider>
44
46
  requiresAuth: (provider: TProvider) => boolean;
45
47
  peekApiKey: (provider: TProvider) => Promise<string | undefined>;
46
48
  isAuthenticated: (apiKey: string | undefined) => boolean;
47
- fetchModels: (provider: TProvider) => Promise<Model<Api>[]>;
49
+ fetchModels: (provider: TProvider, apiKey: string | undefined) => Promise<Model<Api>[]>;
50
+ getEvidenceGeneration?: (provider: TProvider) => string;
51
+ canPublishCache?: (provider: TProvider) => boolean;
48
52
  }
49
53
 
50
54
  /** Owns configured discovery inputs, status, cache lifecycle, and refresh generations. */
@@ -89,6 +93,11 @@ export class ModelDiscoveryManager<TProvider extends DiscoveryProvider> {
89
93
  const state = this.#states.get(provider);
90
94
  return state === undefined ? undefined : this.#snapshot(state);
91
95
  }
96
+ invalidate(provider: string): void {
97
+ this.#invalidate(provider);
98
+ this.#states.delete(provider);
99
+ this.#lastWarnings.delete(provider);
100
+ }
92
101
 
93
102
  loadCached(provider: TProvider, cacheDbPath?: string): readonly Model<Api>[] {
94
103
  const cache = readModelCache<Api>(provider.provider, 24 * 60 * 60 * 1000, Date.now, cacheDbPath);
@@ -133,9 +142,21 @@ export class ModelDiscoveryManager<TProvider extends DiscoveryProvider> {
133
142
  models: models.map(model => model.id),
134
143
  });
135
144
 
145
+ let authGeneration = callbacks.getEvidenceGeneration?.(provider);
146
+ let apiKey: string | undefined;
136
147
  if (callbacks.requiresAuth(provider)) {
137
- const apiKey = await callbacks.peekApiKey(provider);
148
+ apiKey = await callbacks.peekApiKey(provider);
149
+ const resolvedGeneration = callbacks.getEvidenceGeneration?.(provider);
138
150
  if (!this.isCurrent(token)) return this.#stale(token);
151
+ if (authGeneration !== resolvedGeneration) {
152
+ authGeneration = resolvedGeneration;
153
+ // Resolving a command-backed key can update the evidence generation. Keep
154
+ // the key resolved for this refresh so round-robin selection cannot switch
155
+ // credentials between the request and its published evidence.
156
+ if (!this.isCurrent(token) || authGeneration !== callbacks.getEvidenceGeneration?.(provider)) {
157
+ return this.#stale(token);
158
+ }
159
+ }
139
160
  if (!callbacks.isAuthenticated(apiKey)) return unauthenticated(cachedModels);
140
161
  }
141
162
 
@@ -145,10 +166,14 @@ export class ModelDiscoveryManager<TProvider extends DiscoveryProvider> {
145
166
  staticModels: [],
146
167
  cacheDbPath: callbacks.cacheDbPath,
147
168
  cacheTtlMs: 24 * 60 * 60 * 1000,
148
- canPublishCache: () => this.isCurrent(token),
169
+ canPublishCache: () =>
170
+ this.isCurrent(token) &&
171
+ (callbacks.getEvidenceGeneration === undefined ||
172
+ callbacks.getEvidenceGeneration(provider) === authGeneration) &&
173
+ (callbacks.canPublishCache?.(provider) ?? true),
149
174
  fetchDynamicModels: async () => {
150
175
  try {
151
- return await callbacks.fetchModels(provider);
176
+ return await callbacks.fetchModels(provider, apiKey);
152
177
  } catch (cause) {
153
178
  error = cause instanceof Error ? cause.message : String(cause);
154
179
  return null;
@@ -156,7 +181,11 @@ export class ModelDiscoveryManager<TProvider extends DiscoveryProvider> {
156
181
  },
157
182
  });
158
183
  const result = await manager.refresh(strategy);
159
- if (!this.isCurrent(token)) return this.#stale(token);
184
+ if (
185
+ !this.isCurrent(token) ||
186
+ (callbacks.getEvidenceGeneration !== undefined && callbacks.getEvidenceGeneration(provider) !== authGeneration)
187
+ )
188
+ return this.#stale(token);
160
189
  const status: ProviderDiscoveryStatus = error
161
190
  ? result.models.length > 0
162
191
  ? "cached"
@@ -166,7 +195,9 @@ export class ModelDiscoveryManager<TProvider extends DiscoveryProvider> {
166
195
  ? "cached"
167
196
  : "idle"
168
197
  : result.models.length > 0
169
- ? "ok"
198
+ ? result.stale
199
+ ? "cached"
200
+ : "ok"
170
201
  : "empty";
171
202
  const state: ProviderDiscoveryState = {
172
203
  provider: provider.provider,
@@ -177,7 +208,7 @@ export class ModelDiscoveryManager<TProvider extends DiscoveryProvider> {
177
208
  models: result.models.map(model => model.id),
178
209
  error,
179
210
  };
180
- return this.#complete(token, result.models, state, error);
211
+ return this.#complete(token, result.models, state, error, authGeneration, result.fetched);
181
212
  }
182
213
 
183
214
  #complete(
@@ -185,6 +216,8 @@ export class ModelDiscoveryManager<TProvider extends DiscoveryProvider> {
185
216
  models: readonly Model<Api>[],
186
217
  state: ProviderDiscoveryState,
187
218
  error?: string,
219
+ authGeneration?: string,
220
+ fetched?: boolean,
188
221
  ): DiscoveryMergeInput {
189
222
  const current = this.isCurrent(token);
190
223
  if (current) this.#states.set(token.provider, this.#snapshot(state));
@@ -193,7 +226,16 @@ export class ModelDiscoveryManager<TProvider extends DiscoveryProvider> {
193
226
  if (error) this.#lastWarnings.set(token.provider, error);
194
227
  else this.#lastWarnings.delete(token.provider);
195
228
  }
196
- return this.#snapshot({ provider: token.provider, token, current, models, state, warning });
229
+ return this.#snapshot({
230
+ provider: token.provider,
231
+ token,
232
+ current,
233
+ models,
234
+ state,
235
+ warning,
236
+ authGeneration,
237
+ fetched,
238
+ });
197
239
  }
198
240
 
199
241
  #stale(token: DiscoveryRefreshToken): DiscoveryMergeInput {