@gajae-code/coding-agent 0.11.9 → 0.11.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 (59) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/dist/types/config/model-registry.d.ts +2 -0
  3. package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
  4. package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
  5. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  6. package/dist/types/sdk/bus/index.d.ts +8 -1
  7. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  8. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -7
  9. package/dist/types/sdk/bus/telegram-daemon.d.ts +74 -5
  10. package/dist/types/sdk/session.d.ts +7 -0
  11. package/dist/types/session/agent-session.d.ts +2 -0
  12. package/dist/types/session/internal/managed-session-scope.d.ts +1 -1
  13. package/dist/types/task/provider-retry-status.d.ts +13 -0
  14. package/dist/types/task/types.d.ts +3 -0
  15. package/dist/types/tools/index.d.ts +3 -1
  16. package/package.json +8 -8
  17. package/scripts/generate-sdk-operation-inventory.ts +1 -0
  18. package/src/cli/notify-cli.ts +1 -0
  19. package/src/config/model-profiles.ts +7 -7
  20. package/src/config/model-registry.ts +4 -2
  21. package/src/eval/js/tool-bridge.ts +1 -1
  22. package/src/gjc-runtime/deep-interview-draft.ts +2 -1
  23. package/src/gjc-runtime/deep-interview-recorder.ts +16 -1
  24. package/src/gjc-runtime/state-writer.ts +1 -1
  25. package/src/hooks/skill-state.ts +23 -1
  26. package/src/internal-urls/docs-index.generated.ts +3 -3
  27. package/src/modes/controllers/selector-controller.ts +38 -8
  28. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  29. package/src/prompts/agents/architect.md +8 -1
  30. package/src/prompts/agents/critic.md +8 -1
  31. package/src/prompts/agents/planner.md +8 -1
  32. package/src/sdk/broker/identity.ts +22 -12
  33. package/src/sdk/bus/daemon-paths.ts +2 -0
  34. package/src/sdk/bus/index.ts +60 -5
  35. package/src/sdk/bus/notification-service.ts +19 -5
  36. package/src/sdk/bus/operator-runtime.ts +31 -5
  37. package/src/sdk/bus/telegram-cli.ts +6 -13
  38. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  39. package/src/sdk/bus/telegram-daemon-contract.ts +13 -7
  40. package/src/sdk/bus/telegram-daemon-control.ts +14 -5
  41. package/src/sdk/bus/telegram-daemon.ts +667 -118
  42. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  43. package/src/sdk/session.ts +12 -0
  44. package/src/session/agent-session.ts +54 -10
  45. package/src/session/internal/managed-session-scope.ts +133 -117
  46. package/src/skill-state/workflow-mutation-guard.ts +41 -7
  47. package/src/task/executor.ts +141 -39
  48. package/src/task/provider-retry-status.ts +88 -0
  49. package/src/task/render.ts +45 -26
  50. package/src/task/types.ts +3 -0
  51. package/src/tools/bash-allowed-prefixes.ts +45 -1
  52. package/src/tools/index.ts +3 -1
  53. package/src/tools/subagent-render.ts +74 -16
  54. package/src/tools/subagent.ts +2 -0
  55. package/vendor/insane-search/MANIFEST.json +4 -1
  56. package/vendor/insane-search/engine/bias_check.py +5 -0
  57. package/vendor/insane-search/engine/fetch_chain.py +67 -1
  58. package/vendor/insane-search/engine/transport.py +13 -2
  59. package/vendor/insane-search/engine/validators.py +4 -1
@@ -64,8 +64,10 @@ import {
64
64
  import type { NotificationSessionStatus } from "../../sdk/bus/session-control";
65
65
  import {
66
66
  ensureTelegramDaemonRunningDetailed,
67
+ readNotificationRootRegistration,
67
68
  resolveTelegramSetupPreflight,
68
69
  unregisterNotificationRoot,
70
+ withNotificationRootRegistryFence,
69
71
  } from "../../sdk/bus/telegram-daemon";
70
72
  import { TelegramDaemonController } from "../../sdk/bus/telegram-daemon-control";
71
73
  import { runTelegramSetup, type TelegramSetupPreflight } from "../../sdk/bus/telegram-setup";
@@ -216,6 +218,12 @@ export interface NotificationsEditorAdapterContext {
216
218
  notifyConfigChanged?: () => Promise<void> | void;
217
219
  }
218
220
 
221
+ type TelegramDaemonStopResult = {
222
+ ok: boolean;
223
+ message: string;
224
+ before?: { health?: string };
225
+ };
226
+
219
227
  export interface NotificationsEditorOperationDependencies {
220
228
  getNotificationConfig: typeof getNotificationConfig;
221
229
  maskToken: typeof maskToken;
@@ -231,14 +239,17 @@ export interface NotificationsEditorOperationDependencies {
231
239
  reconcileCommittedTelegramConfiguration: typeof reconcileCommittedTelegramConfiguration;
232
240
  saveTelegramInactive: typeof saveTelegramInactive;
233
241
  removeTelegramConfiguration: typeof removeTelegramConfiguration;
242
+ readNotificationRootRegistration: typeof readNotificationRootRegistration;
234
243
  unregisterNotificationRoot: typeof unregisterNotificationRoot;
244
+ stopTelegramDaemonIfRootRegistryFenceMatches(input: {
245
+ settings: Settings;
246
+ registryFingerprint: string;
247
+ stop: () => Promise<TelegramDaemonStopResult>;
248
+ }): Promise<boolean>;
249
+
235
250
  reloadTelegramDaemon(settings: Settings): Promise<{ ok: boolean; message: string }>;
236
251
  restartTelegramDaemon(settings: Settings): Promise<{ ok: boolean; message: string }>;
237
- stopTelegramDaemon(settings: Settings): Promise<{
238
- ok: boolean;
239
- message: string;
240
- before?: { health?: string };
241
- }>;
252
+ stopTelegramDaemon(settings: Settings): Promise<TelegramDaemonStopResult>;
242
253
  }
243
254
 
244
255
  const notificationEditorOperationDependencies: NotificationsEditorOperationDependencies = {
@@ -256,7 +267,18 @@ const notificationEditorOperationDependencies: NotificationsEditorOperationDepen
256
267
  reconcileCommittedTelegramConfiguration,
257
268
  saveTelegramInactive,
258
269
  removeTelegramConfiguration,
270
+ readNotificationRootRegistration,
259
271
  unregisterNotificationRoot,
272
+ stopTelegramDaemonIfRootRegistryFenceMatches: async input =>
273
+ await withNotificationRootRegistryFence({
274
+ settings: input.settings,
275
+ registryFingerprint: input.registryFingerprint,
276
+ action: async () => {
277
+ const stopped = await input.stop();
278
+ if (!stopped.ok) throw new Error(stopped.message);
279
+ },
280
+ }),
281
+
260
282
  reloadTelegramDaemon: async settings =>
261
283
  await new TelegramDaemonController(settings).reload({ spawnIfStopped: false }),
262
284
  restartTelegramDaemon: async settings =>
@@ -625,14 +647,22 @@ export function createNotificationsEditorOperations(
625
647
  stopAndUnregister: async () => {
626
648
  if (controller) await controller.enterBlockedRuntime(sessionContext());
627
649
  runtimePrepared = true;
650
+ const registration = await services.readNotificationRootRegistration({
651
+ settings: ctx.settings,
652
+ sessionId: ctx.sessionManager.getSessionId(),
653
+ });
628
654
  const unregistered = await services.unregisterNotificationRoot({
629
655
  settings: ctx.settings,
630
656
  cwd: ctx.sessionManager.getCwd(),
631
657
  sessionId: ctx.sessionManager.getSessionId(),
658
+ registrationToken: registration.token,
632
659
  });
633
- if (unregistered.remainingRoots === 0) {
634
- const stopped = await new TelegramDaemonController(ctx.settings).stop();
635
- if (!stopped.ok) throw new Error(stopped.message);
660
+ if (unregistered.registryFingerprint !== undefined) {
661
+ await services.stopTelegramDaemonIfRootRegistryFenceMatches({
662
+ settings: ctx.settings,
663
+ registryFingerprint: unregistered.registryFingerprint,
664
+ stop: () => services.stopTelegramDaemon(ctx.settings),
665
+ });
636
666
  }
637
667
  },
638
668
  },
@@ -1 +1 @@
1
- - Exception: restricted `bash` is only for sanctioned GJC workflow persistence (`gjc ralplan --write ...`) and GJC workflow state commands (`gjc state ...`). Pass artifacts through `GJC_RALPLAN_ARTIFACT` with `--artifact-env GJC_RALPLAN_ARTIFACT`; never use bash for product-source writes or general shell work.
1
+ - Exception: restricted `bash` is only for sanctioned GJC workflow persistence (`gjc ralplan --write ...`), GJC workflow state commands (`gjc state ...`), and read-only git inspection (`git status`, `git log`, `git show`, `git diff`, `git blame`, `git rev-parse`, `git ls-files`). Pass artifacts through `GJC_RALPLAN_ARTIFACT` with `--artifact-env GJC_RALPLAN_ARTIFACT`; never use bash for product-source writes, commits, pushes, or other mutating/general shell work.
@@ -1,13 +1,20 @@
1
1
  ---
2
2
  name: architect
3
3
  description: Read-only architecture and code-review agent with severity-rated findings and status verdicts
4
- tools: read, search, find, lsp, ast_grep, web_search, bash, report_finding
4
+ tools: read, search, find, lsp, ast_grep, web_search, bash, report_finding, irc
5
5
  thinking-level: high
6
6
  blocking: true
7
7
  forkContext: allowed
8
8
  bashAllowedPrefixes:
9
9
  - gjc ralplan --write
10
10
  - gjc state
11
+ - git status
12
+ - git log
13
+ - git show
14
+ - git diff
15
+ - git blame
16
+ - git rev-parse
17
+ - git ls-files
11
18
  ---
12
19
  <identity>
13
20
  You are Architect. You combine system architecture review with code-review discipline. Diagnose, analyze, and recommend with file-backed evidence. You are read-only.
@@ -1,11 +1,18 @@
1
1
  ---
2
2
  name: critic
3
3
  description: Read-only plan critic that approves only actionable, verifiable execution plans
4
- tools: read, search, find, lsp, ast_grep, web_search, bash
4
+ tools: read, search, find, lsp, ast_grep, web_search, bash, irc
5
5
  thinking-level: high
6
6
  bashAllowedPrefixes:
7
7
  - gjc ralplan --write
8
8
  - gjc state
9
+ - git status
10
+ - git log
11
+ - git show
12
+ - git diff
13
+ - git blame
14
+ - git rev-parse
15
+ - git ls-files
9
16
  ---
10
17
  <identity>
11
18
  You are Critic. Decide whether a work plan is actionable before execution begins.
@@ -1,11 +1,18 @@
1
1
  ---
2
2
  name: planner
3
3
  description: Read-only planning agent for sequencing, acceptance criteria, risks, and handoff shape
4
- tools: read, search, find, lsp, ast_grep, web_search, bash
4
+ tools: read, search, find, lsp, ast_grep, web_search, bash, irc
5
5
  thinking-level: medium
6
6
  bashAllowedPrefixes:
7
7
  - gjc ralplan --write
8
8
  - gjc state
9
+ - git status
10
+ - git log
11
+ - git show
12
+ - git diff
13
+ - git blame
14
+ - git rev-parse
15
+ - git ls-files
9
16
  ---
10
17
  <identity>
11
18
  You are Planner. Turn requests into actionable work plans. You plan; you do not implement.
@@ -11,20 +11,30 @@ export function brokerIdentityPath(agentDir: string): string {
11
11
  export async function getBrokerIdentityKey(agentDir: string): Promise<string> {
12
12
  const file = brokerIdentityPath(agentDir);
13
13
  await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
14
- try {
15
- const key = (await fs.readFile(file, "utf8")).trim();
16
- if (/^[0-9a-f]{64}$/i.test(key)) return key;
17
- throw new Error(`Invalid broker identity key at ${file}`);
18
- } catch (error) {
19
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
14
+ while (true) {
15
+ try {
16
+ const key = (await fs.readFile(file, "utf8")).trim();
17
+ if (/^[0-9a-f]{64}$/i.test(key)) return key;
18
+ throw new Error(`Invalid broker identity key at ${file}`);
19
+ } catch (error) {
20
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
21
+ }
22
+
20
23
  const key = randomBytes(32).toString("hex");
24
+ const temporary = path.join(path.dirname(file), `.broker.identity-${randomBytes(16).toString("hex")}`);
21
25
  try {
22
- await fs.writeFile(file, `${key}\n`, { encoding: "utf8", mode: MODE, flag: "wx" });
23
- await fs.chmod(file, MODE);
24
- return key;
25
- } catch (writeError) {
26
- if ((writeError as NodeJS.ErrnoException).code !== "EEXIST") throw writeError;
27
- return getBrokerIdentityKey(agentDir);
26
+ await fs.writeFile(temporary, `${key}\n`, { encoding: "utf8", mode: MODE, flag: "wx" });
27
+ await fs.chmod(temporary, MODE);
28
+ try {
29
+ // Link publishes a fully written key without exposing a partial target.
30
+ // Same-directory hard-link support is required (standard on Linux, macOS, and Windows NTFS).
31
+ await fs.link(temporary, file);
32
+ return key;
33
+ } catch (linkError) {
34
+ if ((linkError as NodeJS.ErrnoException).code !== "EEXIST") throw linkError;
35
+ }
36
+ } finally {
37
+ await fs.rm(temporary, { force: true });
28
38
  }
29
39
  }
30
40
  }
@@ -12,6 +12,7 @@ export interface DaemonPaths {
12
12
  dir: string;
13
13
  lock: string;
14
14
  state: string;
15
+ heartbeat: string;
15
16
  ownership: string;
16
17
  roots: string;
17
18
  steal: string;
@@ -26,6 +27,7 @@ export function daemonPaths(agentDir: string): DaemonPaths {
26
27
  lock: path.join(dir, "telegram-daemon.lock"),
27
28
  state: path.join(dir, "telegram-daemon.state.json"),
28
29
  ownership: path.join(dir, "telegram-daemon.ownership"),
30
+ heartbeat: path.join(dir, "telegram-daemon.heartbeat.json"),
29
31
  roots: path.join(dir, "telegram-daemon.roots.json"),
30
32
  steal: path.join(dir, "telegram-daemon.steal"),
31
33
  aliases: path.join(dir, "telegram-callback-aliases.json"),
@@ -89,6 +89,8 @@ import {
89
89
  type EnsureDaemonResult,
90
90
  endpointAuthorityDigest,
91
91
  ensureTelegramDaemonRunningDetailed,
92
+ type RegisterNotificationRootResult,
93
+ unregisterNotificationRoot,
92
94
  } from "./telegram-daemon";
93
95
 
94
96
  // ===========================================================================
@@ -909,6 +911,8 @@ interface SessionRuntime {
909
911
  hostStopped: boolean;
910
912
  serverStopped: boolean;
911
913
  brokerRegistrationReleased: boolean;
914
+ /** Managed Telegram root registration released during terminal teardown. */
915
+ notificationRootRegistration?: { settings: Settings; cwd: string; registrationToken?: string };
912
916
  verbosity: "lean" | "verbose";
913
917
  sessionTag: string;
914
918
  /** Whether the agent loop is currently running (drives the typing indicator). */
@@ -2832,6 +2836,7 @@ export function createNotificationsExtension(
2832
2836
  settings: Settings;
2833
2837
  cwd: string;
2834
2838
  sessionId: string;
2839
+ onRegistered?: (registration: RegisterNotificationRootResult) => void;
2835
2840
  }) => Promise<EnsureDaemonResult>;
2836
2841
  ensureProviderDaemon?: (provider: "discord" | "slack", settings: Settings) => Promise<unknown>;
2837
2842
  /** Suppress auto-delivery for a GJC-spawned child under `sessionScope=primary`. */
@@ -2873,13 +2878,24 @@ export function createNotificationsExtension(
2873
2878
  settings: Settings,
2874
2879
  cwd: string,
2875
2880
  id: string,
2881
+ onRegistered?: (registrationToken: string) => void,
2876
2882
  ): Promise<"ready" | "blocked_identity"> {
2877
2883
  if (options.ensureTelegramDaemon) {
2878
- return (await options.ensureTelegramDaemon({ settings, cwd, sessionId: id })) === "blocked"
2884
+ return (await options.ensureTelegramDaemon({
2885
+ settings,
2886
+ cwd,
2887
+ sessionId: id,
2888
+ onRegistered: registration => onRegistered?.(registration.token),
2889
+ })) === "blocked"
2879
2890
  ? "blocked_identity"
2880
2891
  : "ready";
2881
2892
  }
2882
- return (await ensureTelegramDaemonRunningDetailed({ settings, cwd, sessionId: id })) === "blocked_identity"
2893
+ return (await ensureTelegramDaemonRunningDetailed({
2894
+ settings,
2895
+ cwd,
2896
+ sessionId: id,
2897
+ onRegistered: registration => onRegistered?.(registration.token),
2898
+ })) === "blocked_identity"
2883
2899
  ? "blocked_identity"
2884
2900
  : "ready";
2885
2901
  }
@@ -2888,9 +2904,10 @@ export function createNotificationsExtension(
2888
2904
  cfg: NotificationConfig,
2889
2905
  cwd: string,
2890
2906
  id: string,
2907
+ onRegistered?: (registrationToken: string) => void,
2891
2908
  ): Promise<boolean> {
2892
2909
  if (isTelegramConfigured(cfg)) {
2893
- if ((await ensureTelegramOwner(settings, cwd, id)) === "blocked_identity") return false;
2910
+ if ((await ensureTelegramOwner(settings, cwd, id, onRegistered)) === "blocked_identity") return false;
2894
2911
  }
2895
2912
  await ensureConfiguredProviderDaemons(settings, cfg, options.ensureProviderDaemon);
2896
2913
  return true;
@@ -3040,6 +3057,20 @@ export function createNotificationsExtension(
3040
3057
  hostStopped: rt.hostStopped && rt.serverStopped,
3041
3058
  brokerRegistrationReleased: rt.brokerRegistrationReleased,
3042
3059
  });
3060
+ if (rt.notificationRootRegistration) {
3061
+ try {
3062
+ await unregisterNotificationRoot({
3063
+ settings: rt.notificationRootRegistration.settings,
3064
+ cwd: rt.notificationRootRegistration.cwd,
3065
+ sessionId: id,
3066
+ registrationToken: rt.notificationRootRegistration.registrationToken,
3067
+ });
3068
+ rt.notificationRootRegistration = undefined;
3069
+ } catch (e) {
3070
+ ownerReleaseFailures.push(e);
3071
+ logger.warn(`notifications: Telegram root unregister failed: ${String(e)}`);
3072
+ }
3073
+ }
3043
3074
  if (ownerReleaseFailures.length > 0) {
3044
3075
  cleanupRetries.set(id, rt);
3045
3076
  throw new AggregateError(ownerReleaseFailures, `SDK notification runtime ${id} owner release failed.`);
@@ -3641,6 +3672,7 @@ export function createNotificationsExtension(
3641
3672
  inFlightTools: new Map<string, { toolName: string; args: unknown }>(),
3642
3673
  deferredGatePresentations: [],
3643
3674
  deferredInboundControls: [],
3675
+ notificationRootRegistration: undefined,
3644
3676
  };
3645
3677
  const initializedRuntime = runtime;
3646
3678
  runtimes.set(id, initializedRuntime);
@@ -4130,12 +4162,19 @@ export function createNotificationsExtension(
4130
4162
  }
4131
4163
  if (notificationsEnabledForSession && settingsAvailable && settings) {
4132
4164
  try {
4133
- if (!(await ensureConfiguredDaemonOwners(settings, cfg, ctx.cwd, id))) {
4165
+ let registrationToken: string | undefined;
4166
+ if (
4167
+ !(await ensureConfiguredDaemonOwners(settings, cfg, ctx.cwd, id, token => {
4168
+ registrationToken = token;
4169
+ }))
4170
+ ) {
4134
4171
  const result = failLifecycleStartup("failed", "Telegram daemon ownership is blocked.");
4135
4172
  finishStartup(result);
4136
4173
  await cleanupAbandonedStartup();
4137
4174
  return result;
4138
4175
  }
4176
+ if (isTelegramConfigured(cfg))
4177
+ runtime.notificationRootRegistration = { settings, cwd: ctx.cwd, registrationToken };
4139
4178
  } catch (error) {
4140
4179
  const result = failLifecycleStartup("failed", error);
4141
4180
  finishStartup(result);
@@ -4434,7 +4473,23 @@ export function createNotificationsExtension(
4434
4473
  const { settings, settingsAvailable } = resolveSettings(options.settings);
4435
4474
  if (!settingsAvailable || !settings) return "blocked_identity";
4436
4475
  try {
4437
- return await ensureTelegramOwner(settings, binding.cwd, binding.sessionId);
4476
+ let registrationToken: string | undefined;
4477
+ const result = await ensureTelegramOwner(settings, binding.cwd, binding.sessionId, token => {
4478
+ registrationToken = token;
4479
+ });
4480
+ const runtime = runtimes.get(binding.sessionId);
4481
+ const configured = isTelegramConfigured(resolveSettings(options.settings).cfg);
4482
+ if (result === "ready" && runtime && !runtime.stopping && configured) {
4483
+ runtime.notificationRootRegistration = { settings, cwd: binding.cwd, registrationToken };
4484
+ } else if (registrationToken !== undefined) {
4485
+ await unregisterNotificationRoot({
4486
+ settings,
4487
+ cwd: binding.cwd,
4488
+ sessionId: binding.sessionId,
4489
+ registrationToken,
4490
+ });
4491
+ }
4492
+ return result;
4438
4493
  } catch {
4439
4494
  return "failed";
4440
4495
  }
@@ -28,6 +28,7 @@ import {
28
28
  tokenFingerprint,
29
29
  } from "./config";
30
30
  import { type DaemonPaths, daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
31
+ import { type OwnerFreshnessSnapshot, readOwnerFreshnessSnapshot, type TelegramDaemonFs } from "./telegram-daemon";
31
32
  import { DAEMON_GENERATION } from "./telegram-daemon-contract";
32
33
 
33
34
  const DEFAULT_API_BASE = "https://api.telegram.org";
@@ -578,10 +579,21 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
578
579
  checks.push({ name: "config", level: "ok", detail: "enabled with at least one configured adapter" });
579
580
  }
580
581
 
581
- // Daemon ownership state (offline; read the persisted state file directly).
582
- const paths = daemonPaths(opts.settings.getAgentDir());
583
- const state = await readDaemonStateFile(fs, paths.state);
584
- const heartbeatAt = state?.heartbeatAt;
582
+ // Daemon ownership state (offline; corrupt or unreadable state degrades to a
583
+ // warning instead of making the health command itself fail).
584
+ let daemonStateUnreadable = false;
585
+ let snapshot: OwnerFreshnessSnapshot;
586
+ try {
587
+ snapshot = await readOwnerFreshnessSnapshot({
588
+ settings: opts.settings,
589
+ fs: fs as unknown as TelegramDaemonFs,
590
+ });
591
+ } catch {
592
+ daemonStateUnreadable = true;
593
+ snapshot = { ownerTag: null, effectiveHeartbeatAt: undefined, legacyEmbedded: false, state: undefined };
594
+ }
595
+ const state = snapshot.state ? parseDaemonState(JSON.stringify(snapshot.state)) : undefined;
596
+ const heartbeatAt = finiteNonNegativeNumber(snapshot.effectiveHeartbeatAt);
585
597
  const daemon: DaemonHealth = {
586
598
  present: Boolean(state),
587
599
  ownerId: state?.ownerId,
@@ -600,7 +612,9 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
600
612
  currentGeneration: DAEMON_GENERATION,
601
613
  generationRelation: daemonGenerationRelation(state),
602
614
  };
603
- if (!state) {
615
+ if (daemonStateUnreadable) {
616
+ checks.push({ name: "daemon", level: "warn", detail: "daemon ownership record is corrupt or unreadable" });
617
+ } else if (!state) {
604
618
  checks.push({ name: "daemon", level: "ok", detail: "no daemon ownership record (none running)" });
605
619
  } else if (!daemon.alive) {
606
620
  checks.push({
@@ -74,7 +74,7 @@ export class NotificationOperatorRuntime {
74
74
  #stopRequested = false;
75
75
  #activeAbort: AbortController | undefined;
76
76
  #intervals = new Map<string, OperatorIntervalHandle>();
77
- #exclusive = new Set<string>();
77
+ #exclusive = new Map<string, Promise<void>>();
78
78
 
79
79
  #deps: NotificationOperatorTimerDeps;
80
80
 
@@ -130,7 +130,7 @@ export class NotificationOperatorRuntime {
130
130
 
131
131
  stopInterval(name: string): void {
132
132
  const timer = this.#intervals.get(name);
133
- if (!timer) return;
133
+ if (timer === undefined) return;
134
134
  const clearIntervalImpl = this.#deps.clearIntervalImpl ?? clearInterval;
135
135
  clearIntervalImpl(timer);
136
136
  this.#intervals.delete(name);
@@ -142,14 +142,40 @@ export class NotificationOperatorRuntime {
142
142
 
143
143
  async runExclusive(name: string, fn: () => Promise<void>): Promise<void> {
144
144
  if (this.#exclusive.has(name)) return;
145
- this.#exclusive.add(name);
145
+ const completion = Promise.withResolvers<void>();
146
+ this.#exclusive.set(name, completion.promise);
146
147
  try {
147
- await fn();
148
+ try {
149
+ void fn().then(completion.resolve, completion.reject);
150
+ } catch (error) {
151
+ completion.reject(error);
152
+ }
153
+ await completion.promise;
148
154
  } finally {
149
- this.#exclusive.delete(name);
155
+ if (this.#exclusive.get(name) === completion.promise) this.#exclusive.delete(name);
150
156
  }
151
157
  }
152
158
 
159
+ async joinExclusive(name: string, timeoutMs: number): Promise<boolean> {
160
+ const completion = this.#exclusive.get(name);
161
+ if (!completion) return true;
162
+ const setTimeoutImpl = this.#deps.setTimeoutImpl ?? setTimeout;
163
+ const clearTimeoutImpl = this.#deps.clearTimeoutImpl ?? clearTimeout;
164
+ const { promise, resolve } = Promise.withResolvers<boolean>();
165
+ const timer = setTimeoutImpl(() => resolve(false), timeoutMs);
166
+ void completion.then(
167
+ () => {
168
+ clearTimeoutImpl(timer);
169
+ resolve(true);
170
+ },
171
+ () => {
172
+ clearTimeoutImpl(timer);
173
+ resolve(true);
174
+ },
175
+ );
176
+ return await promise;
177
+ }
178
+
153
179
  sleep(ms: number, signal?: AbortSignal): Promise<void> {
154
180
  return new Promise<void>(resolve => {
155
181
  if (signal?.aborted) return resolve();
@@ -21,7 +21,7 @@ import * as fs from "node:fs";
21
21
  import * as path from "node:path";
22
22
  import { getAgentDir } from "@gajae-code/utils";
23
23
  import { tokenFingerprint } from "./config";
24
- import { type DaemonState, daemonPaths, isFreshLiveOwner } from "./telegram-daemon";
24
+ import { isFreshLiveOwner, readOwnerFreshnessSnapshot } from "./telegram-daemon";
25
25
  import { runTelegramReferenceClient } from "./telegram-reference";
26
26
 
27
27
  interface CliArgs {
@@ -134,17 +134,9 @@ function pidAlive(pid: number): boolean {
134
134
  }
135
135
  }
136
136
 
137
- function readDaemonState(): DaemonState | undefined {
138
- try {
139
- const raw = fs.readFileSync(daemonPaths(getAgentDir()).state, "utf8");
140
- return JSON.parse(raw) as DaemonState;
141
- } catch {
142
- return undefined;
143
- }
144
- }
145
-
146
- function activeDaemonOwnsToken(input: { botToken: string; chatId: string }): boolean {
147
- const state = readDaemonState();
137
+ async function activeDaemonOwnsToken(input: { botToken: string; chatId: string }): Promise<boolean> {
138
+ const snapshot = await readOwnerFreshnessSnapshot({ settings: { getAgentDir } as never });
139
+ const state = snapshot.state;
148
140
  if (!state) return false;
149
141
  return isFreshLiveOwner({
150
142
  state,
@@ -152,6 +144,7 @@ function activeDaemonOwnsToken(input: { botToken: string; chatId: string }): boo
152
144
  tokenFingerprint: tokenFingerprint(input.botToken),
153
145
  chatId: input.chatId,
154
146
  pidAlive,
147
+ effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
155
148
  });
156
149
  }
157
150
 
@@ -176,7 +169,7 @@ async function main(): Promise<void> {
176
169
  }
177
170
 
178
171
  const chatId = args.chatId ?? process.env.GJC_TG_CHAT_ID ?? (await resolveChatId(botToken, apiBase));
179
- if (!args.force && activeDaemonOwnsToken({ botToken, chatId })) {
172
+ if (!args.force && (await activeDaemonOwnsToken({ botToken, chatId }))) {
180
173
  process.stderr.write(
181
174
  "an active gjc notifications daemon already owns this bot token; running a second poller will cause Telegram 409 conflicts. Re-run with --force to override.\n",
182
175
  );
@@ -14,6 +14,7 @@ import { daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths";
14
14
  import {
15
15
  type DaemonState,
16
16
  readDaemonState,
17
+ readOwnerFreshnessSnapshot,
17
18
  type TelegramDaemonOptions,
18
19
  TelegramNotificationDaemon,
19
20
  } from "./telegram-daemon";
@@ -254,15 +255,19 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
254
255
  if (!watchdogActive || watchdogTickInFlight || stopRequested) return;
255
256
  watchdogTickInFlight = true;
256
257
  try {
257
- const state = await readState(settings as Settings);
258
+ const snapshot = deps.readDaemonState
259
+ ? { state: await readState(settings as Settings), effectiveHeartbeatAt: undefined }
260
+ : await readOwnerFreshnessSnapshot({ settings: settings as Settings });
261
+ const state = snapshot.state;
262
+ const heartbeatAt = snapshot.effectiveHeartbeatAt ?? state?.heartbeatAt;
258
263
  if (!watchdogActive || !state) return;
259
264
  if (state.ownerId !== ownerId) {
260
265
  stopRequested = true;
261
266
  daemon.requestStop("stop");
262
267
  return;
263
268
  }
264
- if (lastHeartbeatAt === undefined || state.heartbeatAt !== lastHeartbeatAt) {
265
- lastHeartbeatAt = state.heartbeatAt;
269
+ if (lastHeartbeatAt === undefined || heartbeatAt !== lastHeartbeatAt) {
270
+ lastHeartbeatAt = heartbeatAt;
266
271
  stalledSince = now();
267
272
  return;
268
273
  }
@@ -7,12 +7,9 @@
7
7
  export const NOTIFICATION_PROTOCOL_VERSION = 3;
8
8
 
9
9
  /**
10
- * Operational generation the current daemon build speaks. Decoupled from
11
- * {@link NOTIFICATION_PROTOCOL_VERSION} (#2304): additive `tool_activity` /
12
- * `reasoning_summary` frames do not bump the wire protocol version, but a
13
- * freshly-upgraded host must still recognize an older, still-live daemon that
14
- * predates capability-gated frame enforcement and trigger a reload. Bump this
15
- * on every daemon-behavior change independent of the wire version.
10
+ * Guarded behavior-inventory version for the current daemon build. Bump this
11
+ * on every guarded daemon-behavior change independent of the wire version; it
12
+ * does not force a live daemon reload by itself.
16
13
  * The current development baseline already includes #2299's generation 4,
17
14
  * incarnation fencing in generation 5, owner-lock authority in generation 6,
18
15
  * identity-atomic transition markers in generation 7, stable signaling plus
@@ -40,5 +37,14 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
40
37
  * cooldown and lazy Telegram topic lifecycle safeguards (#2956, #2960, #2984).
41
38
  * Generation 27 refreshes retained native path and process authority semantics.
42
39
  * Generation 28 rejects special files before retained native authority opens.
40
+ * Generation 29 adds serving-epoch compatibility, sidecar heartbeat, root GC,
41
+ * and Bot API cooldown structural fixes (#2956, #2960, #3048).
43
42
  */
44
- export const DAEMON_GENERATION = 28;
43
+ export const DAEMON_GENERATION = 29;
44
+
45
+ /**
46
+ * Serving-compatibility boundary for daemon lifecycle requests. Epoch 1 covers
47
+ * all builds published before this field existed; bump this to force serving
48
+ * convergence and reload of compatible live predecessors.
49
+ */
50
+ export const SERVING_EPOCH = 2;
@@ -40,6 +40,7 @@ import {
40
40
  readAttestedLegacyDaemonOwner,
41
41
  readDaemonRoots,
42
42
  readDaemonState,
43
+ readOwnerFreshnessSnapshot,
43
44
  spawnTelegramDaemonOwner,
44
45
  type TelegramDaemonDeps,
45
46
  type TelegramDaemonFs,
@@ -224,7 +225,8 @@ export class TelegramDaemonController implements BuiltInDaemonController {
224
225
  if (!configured) {
225
226
  return { kind: this.kind, configured: false, health: "not_configured", runtime };
226
227
  }
227
- const state = await readDaemonState(this.settings, this.fsImpl);
228
+ const snapshot = await readOwnerFreshnessSnapshot({ settings: this.settings, fs: this.fsImpl });
229
+ const state = snapshot.state;
228
230
  const roots = await readDaemonRoots(this.settings, this.fsImpl);
229
231
  const health: DaemonHealth =
230
232
  !state || state.stoppedAt !== undefined || !this.pidAlive(state.pid)
@@ -236,6 +238,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
236
238
  chatId: cfg.chatId as string,
237
239
  pidAlive: this.pidAlive,
238
240
  pidIncarnation: this.deps.pidIncarnation,
241
+ effectiveHeartbeatAt: snapshot.effectiveHeartbeatAt,
239
242
  })
240
243
  ? "running"
241
244
  : "stale";
@@ -246,7 +249,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
246
249
  pid: state?.pid,
247
250
  ownerId: state?.ownerId,
248
251
  startedAt: state?.startedAt,
249
- heartbeatAt: state?.heartbeatAt,
252
+ heartbeatAt: snapshot.effectiveHeartbeatAt,
250
253
  roots,
251
254
  rootCount: roots.length,
252
255
  runtime,
@@ -480,7 +483,8 @@ export class TelegramDaemonController implements BuiltInDaemonController {
480
483
  const gracefulTimeoutMs = opts.gracefulTimeoutMs ?? DEFAULT_GRACEFUL_TIMEOUT_MS;
481
484
  const killTimeoutMs = opts.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
482
485
 
483
- const state = await readDaemonState(this.settings, this.fsImpl);
486
+ const ownerSnapshot = await readOwnerFreshnessSnapshot({ settings: this.settings, fs: this.fsImpl });
487
+ const state = ownerSnapshot.state;
484
488
  const attestedLegacyOwner = attestedLegacyUpgrade
485
489
  ? await readAttestedLegacyDaemonOwner({
486
490
  settings: this.settings,
@@ -501,6 +505,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
501
505
  chatId,
502
506
  pidAlive: this.pidAlive,
503
507
  pidIncarnation: this.deps.pidIncarnation,
508
+ effectiveHeartbeatAt: ownerSnapshot.effectiveHeartbeatAt,
504
509
  }) &&
505
510
  isSignalableMatchingOwner({
506
511
  state,
@@ -628,7 +633,8 @@ export class TelegramDaemonController implements BuiltInDaemonController {
628
633
  let dead = await this.waitForPidDeath(oldPid, gracefulTimeoutMs);
629
634
  if (!dead) {
630
635
  // Old pid still alive after the cooperative SIGTERM. Inspect current ownership.
631
- const current = await readDaemonState(this.settings, this.fsImpl);
636
+ const currentSnapshot = await readOwnerFreshnessSnapshot({ settings: this.settings, fs: this.fsImpl });
637
+ const current = currentSnapshot.state;
632
638
  const changedToLiveOwner =
633
639
  current !== undefined &&
634
640
  current.ownerId !== oldOwnerId &&
@@ -639,6 +645,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
639
645
  chatId,
640
646
  pidAlive: this.pidAlive,
641
647
  pidIncarnation: this.deps.pidIncarnation,
648
+ effectiveHeartbeatAt: currentSnapshot.effectiveHeartbeatAt,
642
649
  });
643
650
  if (changedToLiveOwner) {
644
651
  await this.clearOwnRequest(requestId);
@@ -701,7 +708,8 @@ export class TelegramDaemonController implements BuiltInDaemonController {
701
708
  warnings.push(...spawned.warnings);
702
709
  const after = await this.status();
703
710
  if (spawned.result === "attached") {
704
- const attachedState = await readDaemonState(this.settings, this.fsImpl);
711
+ const attachedSnapshot = await readOwnerFreshnessSnapshot({ settings: this.settings, fs: this.fsImpl });
712
+ const attachedState = attachedSnapshot.state;
705
713
  if (
706
714
  !isCurrentCompatibleOwner({
707
715
  state: attachedState,
@@ -710,6 +718,7 @@ export class TelegramDaemonController implements BuiltInDaemonController {
710
718
  chatId,
711
719
  pidAlive: this.pidAlive,
712
720
  pidIncarnation: this.deps.pidIncarnation,
721
+ effectiveHeartbeatAt: attachedSnapshot.effectiveHeartbeatAt,
713
722
  })
714
723
  ) {
715
724
  return this.result(