@gajae-code/coding-agent 0.11.8 → 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 (101) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/dist/types/cli.d.ts +1 -0
  3. package/dist/types/config/model-profiles.d.ts +2 -3
  4. package/dist/types/config/model-registry.d.ts +2 -0
  5. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  6. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  7. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  8. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  9. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  10. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  11. package/dist/types/gjc-runtime/state-writer.d.ts +45 -0
  12. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  13. package/dist/types/modes/components/welcome.d.ts +2 -0
  14. package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
  15. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  16. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -1
  17. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  18. package/dist/types/sdk/bus/index.d.ts +8 -1
  19. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  20. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +14 -7
  21. package/dist/types/sdk/bus/telegram-daemon.d.ts +74 -5
  22. package/dist/types/sdk/session.d.ts +7 -0
  23. package/dist/types/session/agent-session.d.ts +2 -0
  24. package/dist/types/session/internal/managed-session-scope.d.ts +13 -3
  25. package/dist/types/session/internal/managed-session-storage.d.ts +8 -3
  26. package/dist/types/session/session-manager.d.ts +5 -1
  27. package/dist/types/session/session-storage.d.ts +13 -0
  28. package/dist/types/skill-state/active-state.d.ts +4 -0
  29. package/dist/types/task/provider-retry-status.d.ts +13 -0
  30. package/dist/types/task/types.d.ts +3 -0
  31. package/dist/types/tools/index.d.ts +3 -1
  32. package/package.json +8 -8
  33. package/scripts/generate-sdk-operation-inventory.ts +1 -0
  34. package/src/cli/mcp-cli.ts +4 -21
  35. package/src/cli/notify-cli.ts +1 -0
  36. package/src/cli.ts +32 -0
  37. package/src/commands/deep-interview.ts +9 -4
  38. package/src/commit/agentic/index.ts +3 -3
  39. package/src/commit/map-reduce/index.ts +2 -2
  40. package/src/config/model-profiles.ts +9 -10
  41. package/src/config/model-registry.ts +4 -2
  42. package/src/defaults/gjc/skills/deep-interview/SKILL.md +78 -163
  43. package/src/eval/js/tool-bridge.ts +1 -1
  44. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  45. package/src/gjc-runtime/deep-interview-draft.ts +843 -0
  46. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  47. package/src/gjc-runtime/deep-interview-recorder.ts +310 -227
  48. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  49. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  50. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  51. package/src/gjc-runtime/ralplan-runtime.ts +3 -1
  52. package/src/gjc-runtime/state-writer.ts +166 -2
  53. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  54. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  55. package/src/hooks/skill-state.ts +23 -1
  56. package/src/internal-urls/docs-index.generated.ts +6 -5
  57. package/src/modes/components/welcome.ts +10 -2
  58. package/src/modes/controllers/selector-controller.ts +38 -8
  59. package/src/modes/interactive-mode.ts +29 -9
  60. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  61. package/src/prompts/agents/architect.md +8 -1
  62. package/src/prompts/agents/critic.md +8 -1
  63. package/src/prompts/agents/planner.md +8 -1
  64. package/src/prompts/system/system-prompt.md +1 -0
  65. package/src/runtime-mcp/json-rpc.ts +7 -4
  66. package/src/runtime-mcp/redaction.ts +30 -0
  67. package/src/sdk/broker/identity.ts +22 -12
  68. package/src/sdk/broker/session-index.ts +11 -1
  69. package/src/sdk/bus/chat-daemon-control.ts +5 -3
  70. package/src/sdk/bus/daemon-paths.ts +2 -0
  71. package/src/sdk/bus/index.ts +60 -5
  72. package/src/sdk/bus/notification-service.ts +19 -5
  73. package/src/sdk/bus/operator-runtime.ts +31 -5
  74. package/src/sdk/bus/telegram-cli.ts +6 -13
  75. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  76. package/src/sdk/bus/telegram-daemon-contract.ts +15 -7
  77. package/src/sdk/bus/telegram-daemon-control.ts +14 -5
  78. package/src/sdk/bus/telegram-daemon.ts +685 -120
  79. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  80. package/src/sdk/session.ts +12 -0
  81. package/src/session/agent-session.ts +140 -10
  82. package/src/session/internal/managed-session-scope.ts +339 -228
  83. package/src/session/internal/managed-session-storage.ts +17 -4
  84. package/src/session/session-manager.ts +96 -33
  85. package/src/session/session-storage.ts +23 -0
  86. package/src/skill-state/active-state.ts +10 -0
  87. package/src/skill-state/workflow-mutation-guard.ts +41 -7
  88. package/src/task/executor.ts +154 -44
  89. package/src/task/provider-retry-status.ts +88 -0
  90. package/src/task/render.ts +45 -26
  91. package/src/task/types.ts +3 -0
  92. package/src/tools/ask.ts +1 -0
  93. package/src/tools/bash-allowed-prefixes.ts +45 -1
  94. package/src/tools/index.ts +3 -1
  95. package/src/tools/subagent-render.ts +74 -16
  96. package/src/tools/subagent.ts +2 -0
  97. package/vendor/insane-search/MANIFEST.json +4 -1
  98. package/vendor/insane-search/engine/bias_check.py +5 -0
  99. package/vendor/insane-search/engine/fetch_chain.py +67 -1
  100. package/vendor/insane-search/engine/transport.py +13 -2
  101. package/vendor/insane-search/engine/validators.py +4 -1
@@ -665,8 +665,16 @@ function gradientLogo(lines: readonly string[], phase = 0, shine?: ShineConfig):
665
665
 
666
666
  /** Total length of the intro animation. */
667
667
  const INTRO_MS = 3000;
668
- /** Render cadence during the intro (~30fps). */
669
- const INTRO_TICK_MS = 33;
668
+ /** Resolve the intro cadence without making tests mutate global process state. */
669
+ export function resolveWelcomeIntroTickMs(
670
+ platform: NodeJS.Platform = process.platform,
671
+ tmux = process.env.TMUX,
672
+ ): number {
673
+ return platform === "win32" && tmux ? 100 : 33;
674
+ }
675
+
676
+ /** Render at 30fps directly, but cap native Windows multiplexers at 10fps to avoid ConPTY output backpressure. */
677
+ const INTRO_TICK_MS = resolveWelcomeIntroTickMs();
670
678
  /** Number of full gradient rotations the sweep performs before settling. */
671
679
  const INTRO_SWEEPS = 2.5;
672
680
  /** Number of times the shine highlight crosses the diagonal across the intro. */
@@ -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
  },
@@ -70,6 +70,7 @@ import type { ToolExecutionHandle } from "./components/tool-execution";
70
70
  import { StatusLineComponent } from "./components/tool-status-header";
71
71
  import { composeToolText } from "./components/tool-transcript-format";
72
72
  import {
73
+ type RecentSession,
73
74
  WelcomeComponent,
74
75
  type WelcomeLogoMode,
75
76
  type LspServerInfo as WelcomeLspServerInfo,
@@ -631,15 +632,9 @@ export class InteractiveMode implements InteractiveModeContext {
631
632
  const modelName = this.session.model?.name ?? "Unknown";
632
633
  const providerName = this.session.model?.provider ?? "Unknown";
633
634
 
634
- // Get recent sessions
635
- const recentSessions = await logger.time("InteractiveMode.init:recentSessions", () =>
636
- getRecentSessions(this.sessionManager.getSessionDir()).then(sessions =>
637
- sessions.map(s => ({
638
- name: s.name,
639
- timeAgo: s.timeAgo,
640
- })),
641
- ),
642
- );
635
+ // Session history is display-only. Never hold the editor and keyboard behind
636
+ // transcript I/O; populate the welcome trail after the interactive surface exists.
637
+ const recentSessions: RecentSession[] = [];
643
638
 
644
639
  const startupQuiet = settings.get("startup.quiet");
645
640
  const welcomeLogoMode = resolveWelcomeLogoMode(settings.get("startup.welcomeBannerMode"));
@@ -763,6 +758,31 @@ export class InteractiveMode implements InteractiveModeContext {
763
758
  if (this.settings.get("tasksPane.defaultVisible")) this.showTasksPane();
764
759
  this.#syncIrcSidebarAvailabilityFromSettings();
765
760
  this.ui.requestRender(true);
761
+ if (this.#welcomeComponent) {
762
+ const welcomeComponent = this.#welcomeComponent;
763
+ const recentSessionsTimer = setTimeout(() => {
764
+ void logger
765
+ .time("InteractiveMode.init:recentSessions", () =>
766
+ getRecentSessions(this.sessionManager.getSessionDir()),
767
+ )
768
+ .then(sessions => {
769
+ if (this.#welcomeComponent !== welcomeComponent) return;
770
+ welcomeComponent.setRecentSessions(
771
+ sessions.map(session => ({
772
+ name: session.name,
773
+ timeAgo: session.timeAgo,
774
+ })),
775
+ );
776
+ this.ui.requestRender();
777
+ })
778
+ .catch(error => {
779
+ logger.debug("Failed to load recent sessions for welcome screen", {
780
+ error: error instanceof Error ? error.message : String(error),
781
+ });
782
+ });
783
+ }, 0);
784
+ recentSessionsTimer.unref?.();
785
+ }
766
786
 
767
787
  // GitHub star reminder (interactive-only). Register the decline-driven
768
788
  // injection contributor and schedule the launch nudge after the first
@@ -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.
@@ -24,6 +24,7 @@ Optimize for correctness first, maintainability second, and brevity third. Prefe
24
24
  - Informational questions are answer-only/read-only unless the user explicitly requests a change, command, or execution.
25
25
  - Vague requirements use `/skill:deep-interview`: a requirements-only workflow that must not mutate product code. Its spec hands off as deep-interview → ralplan consensus → pending approval → separately approved execution.
26
26
  - Clear work with non-trivial architecture or sequencing risk uses `/skill:ralplan --deliberate` and stops pending approval.
27
+ - Deep-interview state is runtime-owned: diagnose with `gjc deep-interview sanity-check`, inspect only the affected bounded selector, and repair through typed operations using CLI-generated/edited drafts (`draft create|edit|show|check|rebase|discard`) consumed with `--draft-id`. Inline JSON request flags are compatibility-only; normal flow must never reconstruct a payload or full envelope.
27
28
  - Use `/skill:ultragoal` for durable goal ledgers and `/skill:team` for approved coordinated persistent work.
28
29
  - Delegate large implementation slices to `executor`; use `planner`, `architect`, or `critic` for bounded planning and review.
29
30
  - Active skills are authoritative: never ignore an invoked skill; read the full skill text and follow it exactly.
@@ -4,9 +4,9 @@
4
4
  * Lightweight utilities for calling MCP servers directly via HTTP
5
5
  * without maintaining persistent connections.
6
6
  */
7
- // biome-ignore assist/source/organizeImports: Keep independent MCP security imports on separate merge anchors.
8
- import { cancelMCPStream, MCP_HTTP_TIMEOUT_MS, MCP_MAX_CONTENT_BYTES, readMCPResponseText } from "./content-limits";
9
7
  import { logger } from "@gajae-code/utils";
8
+ import { cancelMCPStream, MCP_HTTP_TIMEOUT_MS, MCP_MAX_CONTENT_BYTES, readMCPResponseText } from "./content-limits";
9
+ import { redactMCPDiagnosticValue, redactMCPEndpoint } from "./redaction";
10
10
 
11
11
  /** Parse SSE response format (lines starting with "data: ") */
12
12
  export function parseSSE(text: string): unknown {
@@ -82,7 +82,7 @@ export async function callMCP<T = unknown>(
82
82
  if (!response.ok) {
83
83
  cancelMCPStream(response.body);
84
84
  const errorMsg = `MCP request failed: ${response.status} ${response.statusText}`;
85
- logger.error(errorMsg, { url, method, params });
85
+ logger.error(errorMsg, { url: redactMCPEndpoint(url), method, params: redactMCPDiagnosticValue(params) });
86
86
  throw new Error(errorMsg);
87
87
  }
88
88
 
@@ -90,7 +90,10 @@ export async function callMCP<T = unknown>(
90
90
  const result = parseSSE(text) as JsonRpcResponse<T> | null;
91
91
 
92
92
  if (!result) {
93
- logger.error("Failed to parse MCP response", { url, method, responseText: text.slice(0, 500) });
93
+ logger.error("Failed to parse MCP response", {
94
+ url: redactMCPEndpoint(url),
95
+ method,
96
+ });
94
97
  throw new Error("Failed to parse MCP response");
95
98
  }
96
99
 
@@ -0,0 +1,30 @@
1
+ const REDACTED = "<redacted>";
2
+ const SENSITIVE_KEY_PATTERN = /(?:token|secret|key|credential|password|authorization|auth|bearer|cookie|session)/i;
3
+
4
+ export function redactMCPEndpoint(value: string | undefined): string | undefined {
5
+ if (!value) return value;
6
+ try {
7
+ const url = new URL(value);
8
+ if (url.username) url.username = REDACTED;
9
+ if (url.password) url.password = REDACTED;
10
+ if (url.pathname !== "/") url.pathname = `/${REDACTED}`;
11
+ for (const key of Array.from(url.searchParams.keys())) {
12
+ url.searchParams.set(key, REDACTED);
13
+ }
14
+ url.hash = "";
15
+ return url.toString();
16
+ } catch {
17
+ return REDACTED;
18
+ }
19
+ }
20
+
21
+ export function redactMCPDiagnosticValue(value: unknown): unknown {
22
+ if (Array.isArray(value)) return value.map(redactMCPDiagnosticValue);
23
+ if (!value || typeof value !== "object") return value;
24
+ return Object.fromEntries(
25
+ Object.entries(value).map(([key, entry]) => [
26
+ key,
27
+ SENSITIVE_KEY_PATTERN.test(key) ? REDACTED : redactMCPDiagnosticValue(entry),
28
+ ]),
29
+ );
30
+ }
@@ -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
  }
@@ -131,6 +131,7 @@ function compactEvents(events: SessionIndexEvent[]): SessionIndexEvent[] {
131
131
 
132
132
  async function appendSync(file: string, value: string): Promise<void> {
133
133
  const h = await fs.open(file, "a", 0o600);
134
+ let failure: { error: unknown } | undefined;
134
135
  try {
135
136
  const data = Buffer.from(`${value}\n`);
136
137
  for (let offset = 0; offset < data.length; ) {
@@ -139,9 +140,18 @@ async function appendSync(file: string, value: string): Promise<void> {
139
140
  offset += bytesWritten;
140
141
  }
141
142
  await h.sync();
142
- } finally {
143
+ } catch (error) {
144
+ failure = { error };
145
+ }
146
+ try {
143
147
  await h.close();
148
+ } catch (error) {
149
+ // Bun may report EBADF when concurrent child-pipe teardown has already
150
+ // released a fully written and fsynced append handle. The descriptor is
151
+ // closed in that case; every other close failure remains fatal.
152
+ if ((error as NodeJS.ErrnoException).code !== "EBADF" && failure === undefined) failure = { error };
144
153
  }
154
+ if (failure !== undefined) throw failure.error;
145
155
  }
146
156
 
147
157
  async function syncDirectory(file: string): Promise<void> {
@@ -32,11 +32,13 @@ export type ChatDaemonAction = "stop" | "reload";
32
32
  * authority declaration contract with bounded frame-delivery acknowledgement.
33
33
  * Generation 11 accepts typed retained exact-unlink cleanup authority — a
34
34
  * concrete detached quarantine plus a proven-absent canonical lock pathname —
35
- * when deleting an observed owner-lock lease.
35
+ * when deleting an observed owner-lock lease. Generation 12 refreshes retained
36
+ * native path and process authority semantics. Generation 13 rejects special
37
+ * files before retained native authority opens.
36
38
  */
37
39
  export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
38
- discord: 11,
39
- slack: 11,
40
+ discord: 13,
41
+ slack: 13,
40
42
  };
41
43
 
42
44
  export function chatDaemonGeneration(kind: ChatDaemonKind): number {
@@ -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();