@gajae-code/coding-agent 0.12.21 → 0.13.0

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 (222) hide show
  1. package/CHANGELOG.md +67 -2
  2. package/dist/types/cli/args.d.ts +5 -2
  3. package/dist/types/cli/root-flags.d.ts +246 -0
  4. package/dist/types/cli.d.ts +11 -19
  5. package/dist/types/commands/daemon.d.ts +0 -30
  6. package/dist/types/commands/launch.d.ts +11 -19
  7. package/dist/types/commands/quick-lane.d.ts +32 -0
  8. package/dist/types/commands/sdk.d.ts +21 -15
  9. package/dist/types/config/file-lock-gc.d.ts +2 -0
  10. package/dist/types/config/model-equivalence.d.ts +20 -0
  11. package/dist/types/config/model-profile-activation.d.ts +62 -17
  12. package/dist/types/config/model-profiles.d.ts +10 -0
  13. package/dist/types/config/model-registry.d.ts +68 -1
  14. package/dist/types/config/model-resolver.d.ts +15 -4
  15. package/dist/types/config/models-config-schema.d.ts +7 -2
  16. package/dist/types/config/provider-selection-policy.d.ts +62 -0
  17. package/dist/types/config/settings-schema.d.ts +36 -78
  18. package/dist/types/config/settings.d.ts +2 -0
  19. package/dist/types/edit/renderer.d.ts +5 -2
  20. package/dist/types/edit/streaming.d.ts +13 -0
  21. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  22. package/dist/types/extensibility/extensions/compact-handler.d.ts +2 -1
  23. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +19 -5
  25. package/dist/types/internal-urls/local-protocol.d.ts +1 -0
  26. package/dist/types/modes/components/model-selector.d.ts +0 -2
  27. package/dist/types/modes/components/provider-order-context.d.ts +45 -0
  28. package/dist/types/modes/components/provider-order-editor.d.ts +24 -0
  29. package/dist/types/modes/components/settings-defs.d.ts +5 -1
  30. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  31. package/dist/types/quick-lane/classify.d.ts +40 -0
  32. package/dist/types/runtime/stt-settings-setup.d.ts +18 -0
  33. package/dist/types/sdk/broker/broker.d.ts +56 -2
  34. package/dist/types/sdk/broker/operation-receipt-ledger.d.ts +25 -0
  35. package/dist/types/sdk/broker/session-index.d.ts +83 -2
  36. package/dist/types/sdk/broker/state-version.d.ts +1 -1
  37. package/dist/types/sdk/bus/notification-service.d.ts +6 -0
  38. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +8 -1
  39. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -1
  40. package/dist/types/sdk/bus/telegram-daemon.d.ts +26 -0
  41. package/dist/types/sdk/cli/rows.d.ts +98 -0
  42. package/dist/types/sdk/cli/session-cli.d.ts +35 -3
  43. package/dist/types/sdk/elevation/allowlist.d.ts +20 -0
  44. package/dist/types/sdk/elevation/audit.d.ts +93 -0
  45. package/dist/types/sdk/elevation/capability.d.ts +3 -0
  46. package/dist/types/sdk/elevation/digest.d.ts +72 -0
  47. package/dist/types/sdk/elevation/dispatch-receipt.d.ts +85 -0
  48. package/dist/types/sdk/elevation/grant-ledger.d.ts +163 -0
  49. package/dist/types/sdk/elevation/owner.d.ts +48 -0
  50. package/dist/types/sdk/guides/cache.d.ts +98 -0
  51. package/dist/types/sdk/guides/catalog.d.ts +156 -0
  52. package/dist/types/sdk/guides/cli.d.ts +39 -0
  53. package/dist/types/sdk/guides/manifest.d.ts +66 -0
  54. package/dist/types/sdk/guides/verify.d.ts +66 -0
  55. package/dist/types/sdk/host/control/dispatch.d.ts +8 -0
  56. package/dist/types/sdk/host/control/index.d.ts +1 -1
  57. package/dist/types/sdk/host/control/operations.d.ts +3 -2
  58. package/dist/types/sdk/host/query/cursor.d.ts +10 -0
  59. package/dist/types/sdk/host/query/handlers.d.ts +21 -0
  60. package/dist/types/sdk/host/session-runtime.d.ts +9 -0
  61. package/dist/types/sdk/mcp/server.d.ts +2 -2
  62. package/dist/types/sdk/prompt-status.d.ts +11 -0
  63. package/dist/types/sdk/session.d.ts +4 -0
  64. package/dist/types/sdk/transport/index.d.ts +1 -0
  65. package/dist/types/sdk/transport/relay.d.ts +1 -0
  66. package/dist/types/sdk/transport/serve-cli.d.ts +20 -1
  67. package/dist/types/session/agent-session.d.ts +32 -18
  68. package/dist/types/session/blob-store.d.ts +13 -7
  69. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  70. package/dist/types/session/internal/managed-session-storage.d.ts +25 -1
  71. package/dist/types/session/internal/session-memory-sidecar.d.ts +5 -5
  72. package/dist/types/session/session-manager.d.ts +139 -10
  73. package/dist/types/session/session-storage.d.ts +48 -0
  74. package/dist/types/setup/provider-onboarding.d.ts +7 -1
  75. package/dist/types/slash-commands/builtin-registry.d.ts +14 -0
  76. package/dist/types/stt/setup.d.ts +2 -0
  77. package/dist/types/task/executor.d.ts +10 -2
  78. package/dist/types/tools/browser/runtime-diagnostics.d.ts +65 -0
  79. package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
  80. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  81. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  82. package/dist/types/tools/browser.d.ts +2 -0
  83. package/dist/types/tools/image-gen.d.ts +14 -18
  84. package/dist/types/tools/index.d.ts +2 -0
  85. package/dist/types/tools/resource-gc.d.ts +2 -0
  86. package/dist/types/utils/pasted-image-path.d.ts +6 -0
  87. package/package.json +9 -9
  88. package/scripts/generate-sdk-operation-inventory.ts +8 -0
  89. package/scripts/run-test-manifest.ts +61 -21
  90. package/scripts/verify-gjc-sdk-canonicalization.ts +9 -4
  91. package/src/cli/args.ts +62 -9
  92. package/src/cli/completion-cli.ts +1 -0
  93. package/src/cli/fast-help.ts +1 -0
  94. package/src/cli/root-flags.ts +100 -0
  95. package/src/cli/setup-cli.ts +6 -2
  96. package/src/cli.ts +42 -65
  97. package/src/commands/acp.ts +1 -1
  98. package/src/commands/daemon.ts +2 -31
  99. package/src/commands/gc.ts +1 -1
  100. package/src/commands/launch.ts +8 -129
  101. package/src/commands/quick-lane.ts +51 -0
  102. package/src/commands/sdk.ts +200 -26
  103. package/src/commands/setup.ts +4 -2
  104. package/src/config/file-lock-gc.ts +4 -1
  105. package/src/config/model-equivalence.ts +76 -7
  106. package/src/config/model-profile-activation.ts +1115 -265
  107. package/src/config/model-profiles.ts +134 -2
  108. package/src/config/model-registry.ts +352 -50
  109. package/src/config/model-resolver.ts +289 -38
  110. package/src/config/models-config-schema.ts +11 -4
  111. package/src/config/provider-selection-policy.ts +146 -0
  112. package/src/config/settings-schema.ts +50 -77
  113. package/src/config/settings.ts +40 -0
  114. package/src/coordinator-mcp/server.ts +32 -2
  115. package/src/edit/renderer.ts +175 -223
  116. package/src/edit/streaming.ts +108 -1
  117. package/src/export/html/index.ts +57 -7
  118. package/src/extensibility/custom-tools/types.ts +2 -0
  119. package/src/extensibility/extensions/compact-handler.ts +3 -2
  120. package/src/extensibility/extensions/runner.ts +6 -0
  121. package/src/extensibility/extensions/types.ts +3 -0
  122. package/src/gjc-runtime/gc-runtime.ts +379 -20
  123. package/src/gjc-runtime/launch-worktree.ts +5 -1
  124. package/src/gjc-runtime/team-runtime.ts +59 -2
  125. package/src/gjc-runtime/tmux-owner-isolation.ts +19 -0
  126. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +16 -1
  127. package/src/internal-urls/docs-index.generated.ts +17 -13
  128. package/src/internal-urls/local-protocol.ts +1 -0
  129. package/src/main.ts +40 -3
  130. package/src/memories/index.ts +4 -2
  131. package/src/modes/acp/acp-agent.ts +235 -91
  132. package/src/modes/components/model-selector.ts +206 -42
  133. package/src/modes/components/provider-order-context.ts +197 -0
  134. package/src/modes/components/provider-order-editor.ts +441 -0
  135. package/src/modes/components/settings-defs.ts +21 -1
  136. package/src/modes/components/settings-selector.ts +45 -2
  137. package/src/modes/components/tool-execution.ts +47 -15
  138. package/src/modes/controllers/command-controller.ts +6 -3
  139. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  140. package/src/modes/controllers/input-controller.ts +73 -2
  141. package/src/modes/controllers/selector-controller.ts +260 -153
  142. package/src/modes/prompt-suggestion-controller.ts +1 -1
  143. package/src/modes/runtime-init.ts +1 -0
  144. package/src/modes/utils/ui-helpers.ts +18 -0
  145. package/src/prompts/tools/browser.md +1 -0
  146. package/src/quick-lane/classify.ts +257 -0
  147. package/src/rlm/index.ts +1 -1
  148. package/src/runtime/stt-settings-setup.ts +57 -0
  149. package/src/sdk/acp/adapter.ts +2 -1
  150. package/src/sdk/broker/broker.ts +780 -12
  151. package/src/sdk/broker/lifecycle.ts +70 -29
  152. package/src/sdk/broker/operation-receipt-ledger.ts +144 -0
  153. package/src/sdk/broker/session-index.ts +463 -48
  154. package/src/sdk/broker/state-version.ts +13 -9
  155. package/src/sdk/broker/transport.ts +14 -1
  156. package/src/sdk/bus/index.ts +34 -13
  157. package/src/sdk/bus/kind-aware-reconciliation.ts +21 -2
  158. package/src/sdk/bus/notification-service.ts +47 -1
  159. package/src/sdk/bus/prompt-reconciliation.ts +22 -3
  160. package/src/sdk/bus/telegram-daemon-contract.ts +4 -1
  161. package/src/sdk/bus/telegram-daemon.ts +54 -3
  162. package/src/sdk/cli/rows.ts +247 -0
  163. package/src/sdk/cli/session-cli.ts +930 -79
  164. package/src/sdk/elevation/allowlist.ts +54 -0
  165. package/src/sdk/elevation/audit.ts +208 -0
  166. package/src/sdk/elevation/capability.ts +41 -0
  167. package/src/sdk/elevation/digest.ts +129 -0
  168. package/src/sdk/elevation/dispatch-receipt.ts +184 -0
  169. package/src/sdk/elevation/grant-ledger.ts +1142 -0
  170. package/src/sdk/elevation/owner.ts +109 -0
  171. package/src/sdk/guides/cache.ts +363 -0
  172. package/src/sdk/guides/catalog.ts +554 -0
  173. package/src/sdk/guides/cli.ts +235 -0
  174. package/src/sdk/guides/manifest.ts +152 -0
  175. package/src/sdk/guides/verify.ts +178 -0
  176. package/src/sdk/host/control/dispatch.ts +49 -1
  177. package/src/sdk/host/control/index.ts +1 -0
  178. package/src/sdk/host/control/operations.ts +8 -1
  179. package/src/sdk/host/query/cursor.ts +47 -3
  180. package/src/sdk/host/query/handlers.ts +155 -2
  181. package/src/sdk/host/sdk-surface-parity.test.ts +1 -0
  182. package/src/sdk/host/session-runtime.test.ts +391 -1
  183. package/src/sdk/host/session-runtime.ts +308 -73
  184. package/src/sdk/host/surface-policy.ts +3 -0
  185. package/src/sdk/host/websocket-transport.ts +7 -1
  186. package/src/sdk/mcp/server.ts +88 -15
  187. package/src/sdk/prompt-status.ts +8 -0
  188. package/src/sdk/protocol/operation-inventory.generated.json +62 -0
  189. package/src/sdk/protocol/operation-registry.ts +3 -2
  190. package/src/sdk/session.ts +87 -39
  191. package/src/sdk/transport/index.ts +1 -0
  192. package/src/sdk/transport/relay.ts +3 -0
  193. package/src/sdk/transport/serve-cli.ts +111 -24
  194. package/src/secrets/obfuscator.ts +92 -12
  195. package/src/session/agent-session.ts +322 -93
  196. package/src/session/blob-store.ts +60 -8
  197. package/src/session/fallback-chain-controller.ts +35 -0
  198. package/src/session/internal/managed-session-scope.ts +25 -3
  199. package/src/session/internal/managed-session-storage.ts +574 -73
  200. package/src/session/internal/session-memory-sidecar.ts +19 -29
  201. package/src/session/session-manager.ts +2166 -743
  202. package/src/session/session-storage.ts +715 -48
  203. package/src/setup/provider-onboarding.ts +19 -3
  204. package/src/setup/provider-presets.json +3 -1
  205. package/src/slash-commands/builtin-registry.ts +85 -3
  206. package/src/stt/downloader.ts +2 -3
  207. package/src/stt/setup.ts +29 -4
  208. package/src/task/executor.ts +22 -18
  209. package/src/task/index.ts +24 -1
  210. package/src/tools/archive-reader.ts +11 -2
  211. package/src/tools/browser/runtime-diagnostics.ts +249 -0
  212. package/src/tools/browser/tab-protocol.ts +4 -0
  213. package/src/tools/browser/tab-supervisor.ts +5 -0
  214. package/src/tools/browser/tab-worker.ts +36 -1
  215. package/src/tools/browser.ts +7 -0
  216. package/src/tools/image-gen.ts +110 -166
  217. package/src/tools/index.ts +2 -0
  218. package/src/tools/read.ts +1 -1
  219. package/src/tools/resource-gc.ts +6 -1
  220. package/src/tools/tool-catalog.generated.ts +5 -1
  221. package/src/utils/pasted-image-path.ts +93 -0
  222. package/src/web/search/index.ts +3 -3
@@ -1,11 +1,14 @@
1
1
  export const SDK_STATE_VERSION = 1;
2
2
 
3
3
  // The session-index snapshot carries its own format version, independent of the shared
4
- // SDK_STATE_VERSION used by discovery, the lifecycle ledger, and per-event records. Version 2
5
- // snapshots relax the on-disk schema to allow gaps in indexSeq (compaction drops terminal+dead
6
- // sessions and superseded heartbeats), so an older broker must hard-fail rather than silently
7
- // fall back to a truncated log. Bumping only this constant keeps the broker protocol untouched.
8
- export const SESSION_INDEX_SNAPSHOT_VERSION = 2;
4
+ // SDK_STATE_VERSION used by discovery, the lifecycle ledger, and per-event records.
5
+ // Version 2 snapshots relax the on-disk schema to allow gaps in indexSeq (compaction
6
+ // drops terminal+dead sessions and superseded heartbeats), so an older broker must
7
+ // hard-fail rather than silently fall back to a truncated log. Version 3 snapshots may
8
+ // additionally carry composite-identity events (hostIncarnation, activity checkpoints,
9
+ // session_deleted tombstones) and retention-compacted sessions; v2 (and v1) snapshots
10
+ // remain readable. Bumping only this constant keeps the broker protocol untouched.
11
+ export const SESSION_INDEX_SNAPSHOT_VERSION = 3;
9
12
 
10
13
  export class UnsupportedStateVersionError extends Error {
11
14
  readonly code = "unsupported_state_version";
@@ -32,10 +35,11 @@ export function assertSupportedStateVersion(file: string, value: unknown): void
32
35
  }
33
36
  }
34
37
 
35
- // Fences the session-index snapshot format independently of SDK_STATE_VERSION. An older broker
36
- // (whose SESSION_INDEX_SNAPSHOT_VERSION is 1) trips assertSupportedStateVersion on a version-2
37
- // snapshot and refuses to start; a current broker accepts both the legacy contiguous format
38
- // (version 1 or absent) and the gapped version-2 format.
38
+ // Fences the session-index snapshot format independently of SDK_STATE_VERSION. An older
39
+ // broker (whose SESSION_INDEX_SNAPSHOT_VERSION is below 3) trips assertSupportedStateVersion
40
+ // on a version-3 snapshot and refuses to start; a current broker accepts the legacy
41
+ // contiguous format (version 1), the gapped version-2 format, and the version-3 format
42
+ // with composite-identity events.
39
43
  export function assertSupportedSnapshotVersion(file: string, value: unknown): void {
40
44
  if (!value || typeof value !== "object") return;
41
45
  const record = value as { version?: unknown; stateVersion?: unknown };
@@ -15,6 +15,9 @@ const BROKER_OPERATIONS = new Set([
15
15
  "session.close",
16
16
  "session.delete",
17
17
  "broker.shutdown",
18
+ "elevation.issue",
19
+ "elevation.status",
20
+ "session.control",
18
21
  ]);
19
22
  type RequestInput = Record<string, unknown>;
20
23
  type BrokerRequest = {
@@ -23,6 +26,7 @@ type BrokerRequest = {
23
26
  operation?: string;
24
27
  input?: RequestInput;
25
28
  idempotencyKey?: string;
29
+ elevationRequestId?: string;
26
30
  };
27
31
  function digest(value: string): Buffer {
28
32
  return createHash("sha256").update(value).digest();
@@ -134,6 +138,10 @@ export class BrokerTransport {
134
138
  sendError(socket, frame.id, "invalid_input", "idempotencyKey must be a string");
135
139
  return;
136
140
  }
141
+ if (frame.elevationRequestId !== undefined && typeof frame.elevationRequestId !== "string") {
142
+ sendError(socket, frame.id, "invalid_input", "elevationRequestId must be a string");
143
+ return;
144
+ }
137
145
  if (frame.operation === "broker.shutdown") {
138
146
  const action = brokerShutdownSendAction(
139
147
  send(socket, { type: "broker_response", id: frame.id, ok: true, result: { accepted: true } }),
@@ -145,7 +153,12 @@ export class BrokerTransport {
145
153
  return;
146
154
  }
147
155
  try {
148
- const result = await this.#broker.handleRequest(frame.operation, frame.input, frame.idempotencyKey);
156
+ const result = await this.#broker.handleRequest(
157
+ frame.operation,
158
+ frame.input,
159
+ frame.idempotencyKey,
160
+ frame.elevationRequestId,
161
+ );
149
162
  send(socket, { type: "broker_response", id: frame.id, ...result });
150
163
  } catch {
151
164
  sendError(socket, frame.id, "unavailable", "broker request failed");
@@ -24,6 +24,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
24
24
  import { execFile } from "node:child_process";
25
25
  import * as crypto from "node:crypto";
26
26
  import * as fs from "node:fs";
27
+ import * as fsPromises from "node:fs/promises";
27
28
  import * as os from "node:os";
28
29
  import * as path from "node:path";
29
30
  import { promisify } from "node:util";
@@ -81,9 +82,11 @@ import {
81
82
  import { acpFinalTextFromMessage } from "../acp/final-text";
82
83
  import { ensureBroker } from "../broker/ensure";
83
84
  import { publishSessionHostRuntimeEvidence, type SessionHostRuntimePublication } from "../broker/lifecycle";
85
+ import { processIncarnation } from "../broker/process-incarnation";
84
86
  import { SessionIndex } from "../broker/session-index";
87
+ import { elevationAuthorityPath, verifyElevationCapability } from "../elevation/capability";
85
88
  import { createSdkSurfaceFactory, type SessionSdkHost, SessionSdkSessionRuntime, shouldHostSdk } from "../host";
86
- import { type ControlSurface, dispatchControl } from "../host/control";
89
+ import { type ControlSurface, controlRequestFromFrame, dispatchControl } from "../host/control";
87
90
  import { CursorRegistry, QueryHandlers, RevisionStore, type SessionSurface } from "../host/query";
88
91
  import type { SdkFrame } from "../host/types";
89
92
  import {
@@ -2384,6 +2387,7 @@ function sdkControlSurface(
2384
2387
  settings?: Settings,
2385
2388
  configOverrides: Map<string, unknown> = new Map(),
2386
2389
  configRevision: { current: number } = { current: 0 },
2390
+ elevationAuthorityToken?: string,
2387
2391
  abortOwnedPrompt: (
2388
2392
  connectionId: string | undefined,
2389
2393
  ) => Promise<{ aborted: true; disposition: "cancelled" | "already_terminal" | "idle" }> = async () => ({
@@ -2683,6 +2687,9 @@ function sdkControlSurface(
2683
2687
  cancelPendingPreflights(): void;
2684
2688
  cancelPendingPreflightsForConnection(connectionId: string): void;
2685
2689
  } = {
2690
+ authorizeElevationClaim: (sdkId, input, capability) =>
2691
+ elevationAuthorityToken !== undefined &&
2692
+ verifyElevationCapability(elevationAuthorityToken, capability, sdkId, input),
2686
2693
  prompt: (text, images, clientRef) =>
2687
2694
  submitPrompt(text, images, false, undefined, true, controlRequesterContext.getStore(), clientRef, true),
2688
2695
  steer: text => sendSteer(text),
@@ -2717,7 +2724,7 @@ function sdkControlSurface(
2717
2724
  pending.completeDirect();
2718
2725
  return { resolved: true };
2719
2726
  },
2720
- answerGate: async (id, response, expectedSessionId, idempotencyKey) => {
2727
+ answerGate: async (id, response, expectedSessionId, idempotencyKey, _elevationRequestId) => {
2721
2728
  if (!acceptGateResolution())
2722
2729
  throw Object.assign(new Error("Workflow gate is no longer answerable."), { code: "resource_gone" });
2723
2730
  if (expectedSessionId === undefined) auditMissingExpectedSessionId("workflow.gate_answer");
@@ -2792,7 +2799,7 @@ function sdkControlSurface(
2792
2799
  code: "terminal_uncertain",
2793
2800
  });
2794
2801
  },
2795
- approvePlan: async (id, choice, expectedSessionId) => {
2802
+ approvePlan: async (id, choice, expectedSessionId, _elevationRequestId) => {
2796
2803
  if (!acceptGateResolution())
2797
2804
  throw Object.assign(new Error("Workflow plan is no longer answerable."), { code: "resource_gone" });
2798
2805
  if (expectedSessionId === undefined) auditMissingExpectedSessionId("workflow.plan_approve");
@@ -3917,6 +3924,11 @@ export function createNotificationsExtension(
3917
3924
  // build information and required capability while lifecycle startup can settle
3918
3925
  // a structured failure instead of leaving the lifecycle caller pending.
3919
3926
  const token = resolveToken();
3927
+ const elevationAuthorityToken = crypto.randomBytes(32).toString("base64url");
3928
+ const elevationAuthorityFile = elevationAuthorityPath(endpointStateRoot, id);
3929
+ await fsPromises.mkdir(path.dirname(elevationAuthorityFile), { recursive: true, mode: 0o700 });
3930
+ await Bun.write(elevationAuthorityFile, `${JSON.stringify({ version: 1, token: elevationAuthorityToken })}\n`);
3931
+ await fsPromises.chmod(elevationAuthorityFile, 0o600);
3920
3932
  let server: NotificationServer;
3921
3933
  try {
3922
3934
  const { NotificationServer, nativeBuildInfo } = sdkBusNatives();
@@ -4599,6 +4611,7 @@ export function createNotificationsExtension(
4599
4611
  settings,
4600
4612
  configOverrides,
4601
4613
  configRevision,
4614
+ elevationAuthorityToken,
4602
4615
  async connectionId => {
4603
4616
  const active = [...promptSubmissions.entries()].find(
4604
4617
  ([, submission]) => submission.connectionId === connectionId && !submission.terminal,
@@ -4882,15 +4895,7 @@ export function createNotificationsExtension(
4882
4895
  dispatchControl(
4883
4896
  controlSurface,
4884
4897
  OPERATIONS.find(row => row.kind === "control" && row.sdkId === operation),
4885
- {
4886
- id: requestId,
4887
- operation,
4888
- input: request.input,
4889
- expectedRevision:
4890
- typeof request.expectedRevision === "string" ? request.expectedRevision : undefined,
4891
- idempotencyKey: typeof request.idempotencyKey === "string" ? request.idempotencyKey : undefined,
4892
- confirm: request.confirm === true,
4893
- },
4898
+ controlRequestFromFrame(request as Record<string, unknown>),
4894
4899
  ),
4895
4900
  );
4896
4901
 
@@ -4902,6 +4907,16 @@ export function createNotificationsExtension(
4902
4907
  },
4903
4908
  query: async (connectionId, frame) => {
4904
4909
  const request = frame as { id?: unknown; query?: unknown; input?: unknown; cursor?: unknown };
4910
+ // D1: a non-string top-level cursor is rejected, never silently
4911
+ // dropped into a fresh-snapshot query (parity with the loopback
4912
+ // session-runtime adapter).
4913
+ if (request.cursor !== undefined && typeof request.cursor !== "string")
4914
+ return {
4915
+ type: "query_response",
4916
+ id: typeof request.id === "string" ? request.id : undefined,
4917
+ ok: false,
4918
+ error: { code: "invalid_input", message: "cursor must be a non-empty string" },
4919
+ };
4905
4920
  const response = await queryHandlers.dispatch({
4906
4921
  id: typeof request.id === "string" ? request.id : undefined,
4907
4922
  query: typeof request.query === "string" ? request.query : "",
@@ -5608,6 +5623,7 @@ export function createNotificationsExtension(
5608
5623
  throwIfLifecycleStopped();
5609
5624
  const locator = { repo: path.resolve(ctx.cwd), stateRoot: endpointStateRoot };
5610
5625
  const endpointMtimeMs = fs.statSync(path.join(endpointStateRoot, "sdk", `${id}.json`)).mtimeMs;
5626
+ const hostProcessIncarnation = processIncarnation(process.pid);
5611
5627
  await host.registerWithBroker({
5612
5628
  // The endpoint is written before registration. Its exact mtime
5613
5629
  // binds this index generation to that discovery record.
@@ -5617,6 +5633,11 @@ export function createNotificationsExtension(
5617
5633
  ...input,
5618
5634
  locator,
5619
5635
  pid: process.pid,
5636
+ // A pid alone cannot survive its own reuse, and the marker that
5637
+ // binds it lives in the workspace state root this host can outlive.
5638
+ // Publishing the incarnation into broker-owned storage is what keeps
5639
+ // teardown identity provable after that workspace is gone.
5640
+ ...(hostProcessIncarnation ? { processIncarnation: hostProcessIncarnation } : {}),
5620
5641
  endpointMtimeMs,
5621
5642
  ...(lifecycleRequestId ? { lifecycleRequestId } : {}),
5622
5643
  });
@@ -5810,7 +5831,7 @@ export function createNotificationsExtension(
5810
5831
  logger.warn(`notifications: failed to start server: ${String(e)}`);
5811
5832
  const result = failLifecycleStartup("failed", e);
5812
5833
  finishStartup(result);
5813
- let suppressExtensionError = false;
5834
+ let suppressExtensionError = result.failure?.message === "Lifecycle SDK startup was cancelled.";
5814
5835
  let stopped = false;
5815
5836
  try {
5816
5837
  stopped = await stopSession(id, "session", runtime);
@@ -195,7 +195,21 @@ export function createKindAwareReconciliation(
195
195
  if (!correlation) return;
196
196
  await queueMutation(candidate => {
197
197
  const record = candidate.get(keyOf(kind, correlation));
198
- if (!record || record.terminalAt !== undefined) return { value: undefined, changed: false };
198
+ if (!record) return { value: undefined, changed: false };
199
+ if (record.terminalAt !== undefined) {
200
+ // The terminal is claimed by one delivery path while the reason arrives on
201
+ // another, so ordering is not the caller's to control. A late agent_failed
202
+ // enriches the settled record instead of being dropped; it must not resurrect
203
+ // it, so status, terminalAt, retention order, and the clientRef index stay
204
+ // as-is (cleanupRecords is intentionally not re-run). First reason wins: a
205
+ // late generic frame never overwrites a specific one. Persisted so the reason
206
+ // survives reconnect/restart reconciliation.
207
+ if (frame.type === "agent_failed" && record.error === undefined) {
208
+ record.error = sanitizePromptFailure(frame.error);
209
+ return { value: undefined, changed: true };
210
+ }
211
+ return { value: undefined, changed: false };
212
+ }
199
213
  if (frame.type === "agent_start") {
200
214
  if (record.status !== "accepted") return { value: undefined, changed: false };
201
215
  record.status = "in_flight";
@@ -278,7 +292,12 @@ export function createKindAwareReconciliation(
278
292
  terminalAt: record.terminalAt as number,
279
293
  ...(record.outcome !== undefined ? { outcome: record.outcome } : {}),
280
294
  };
281
- if (record.status === "terminal_ok") return { status: "terminal_ok", ...terminal };
295
+ if (record.status === "terminal_ok")
296
+ return {
297
+ status: "terminal_ok",
298
+ ...terminal,
299
+ ...(record.error !== undefined ? { error: record.error } : {}),
300
+ };
282
301
  return { status: "failed", ...terminal, error: record.error ?? sanitizePromptFailure(undefined) };
283
302
  };
284
303
 
@@ -602,6 +602,13 @@ export interface DaemonHealth {
602
602
  stopped: boolean;
603
603
  heartbeatAt: number | undefined;
604
604
  heartbeatAgeMs: number | undefined;
605
+
606
+ /**
607
+ * Session endpoints the live owner reported an OPEN socket to in its latest
608
+ * matching heartbeat sidecar. `undefined` means the owner never published the
609
+ * field (older daemon, no stable owner tag, no matching sidecar): unknown, not zero.
610
+ */
611
+ attachedEndpoints: number | undefined;
605
612
  generation: number | undefined;
606
613
  currentGeneration: number;
607
614
  generationRelation: DaemonGenerationRelation;
@@ -822,7 +829,13 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
822
829
  });
823
830
  } catch {
824
831
  daemonStateUnreadable = true;
825
- snapshot = { ownerTag: null, effectiveHeartbeatAt: undefined, legacyEmbedded: false, state: undefined };
832
+ snapshot = {
833
+ ownerTag: null,
834
+ effectiveHeartbeatAt: undefined,
835
+ legacyEmbedded: false,
836
+ attachedEndpoints: undefined,
837
+ state: undefined,
838
+ };
826
839
  }
827
840
  const state = snapshot.state ? parseDaemonState(JSON.stringify(snapshot.state)) : undefined;
828
841
  const heartbeatAt = finiteNonNegativeNumber(snapshot.effectiveHeartbeatAt);
@@ -840,6 +853,7 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
840
853
  stopped: state?.stoppedAt !== undefined,
841
854
  heartbeatAt,
842
855
  heartbeatAgeMs: heartbeatAt === undefined ? undefined : displayHeartbeatAgeMs(now, heartbeatAt),
856
+ attachedEndpoints: snapshot.attachedEndpoints,
843
857
  generation: state?.generation,
844
858
  currentGeneration: DAEMON_GENERATION,
845
859
  generationRelation: daemonGenerationRelation(state),
@@ -863,6 +877,19 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
863
877
  level: "warn",
864
878
  detail: "a live daemon owns a different bot token or chat id",
865
879
  });
880
+ } else if (telegramConfigured && !daemon.stopped && daemon.generationRelation !== "current") {
881
+ // Liveness is not serviceability. `isCurrentCompatibleOwner` refuses to attach
882
+ // a session to an owner whose generation is not this build's, and
883
+ // `acquireDaemonOwnership` then answers `provisional`, which `ensureTelegramDaemonRunning`
884
+ // reports as `blocked_identity`: no transport is ever attached and no
885
+ // notification is ever published, while the owner keeps heartbeating. Reporting
886
+ // that as OK is what makes the outage invisible; the generation is inventory
887
+ // metadata that never forces a reload on its own, so the operator has to.
888
+ checks.push({
889
+ name: "daemon",
890
+ level: "warn",
891
+ detail: `daemon pid ${daemon.pid} serves generation ${daemon.generation ?? "unknown"} but this build attaches only to generation ${daemon.currentGeneration}; it cannot attach a session transport — run \`gjc daemon reload\``,
892
+ });
866
893
  } else {
867
894
  checks.push({ name: "daemon", level: "ok", detail: `daemon pid ${daemon.pid} alive with a fresh heartbeat` });
868
895
  }
@@ -931,6 +958,25 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
931
958
  "No local notification endpoint for this working directory. In this GJC terminal run /notify on; if it does not report notifications enabled, start a new local GJC session. Do not re-pair Telegram.",
932
959
  });
933
960
  }
961
+ // Endpoint files are registrations, not deliveries: a live owner that
962
+ // reports zero OPEN sockets cannot deliver to any of them. An owner that
963
+ // never published a count is unknown, and unknown is never treated as zero.
964
+ if (
965
+ telegramConfigured &&
966
+ daemon.present &&
967
+ daemon.alive &&
968
+ daemon.heartbeatFresh &&
969
+ daemon.identityMatches &&
970
+ !daemon.stopped &&
971
+ endpoints.total > 0 &&
972
+ daemon.attachedEndpoints === 0
973
+ ) {
974
+ checks.push({
975
+ name: "endpoint_attachment",
976
+ level: "warn",
977
+ detail: `${endpoints.total} endpoint file(s) registered but daemon pid ${daemon.pid} reports 0 attached session(s); the daemon cannot deliver to any registered endpoint`,
978
+ });
979
+ }
934
980
  }
935
981
 
936
982
  // Optional selected REST-only reachability probe.
@@ -16,7 +16,9 @@
16
16
  * Session-scoped durable retention (kind-aware store) is provided by reconciliation-store.ts (#3032).
17
17
  * - The clientRef index is session-runtime scoped; a ref conflicts only while
18
18
  * retained and must never be reused as a retry mechanism.
19
- * - Terminal transitions settle once: the first terminal outcome wins.
19
+ * - Terminal transitions settle once: first terminal outcome wins. A late
20
+ * `agent_failed` may still attach its sanitized reason to an already-terminal
21
+ * record that has none, but never changes status, terminalAt, or retention.
20
22
  */
21
23
 
22
24
  import { PROMPT_FAILURE_CODE_MAX, sanitizePromptFailure } from "../prompt-failure";
@@ -67,6 +69,8 @@ export type TurnPromptReconciliation =
67
69
  acceptedAt: number;
68
70
  startedAt?: number;
69
71
  terminalAt: number;
72
+ /** Present when a late `agent_failed` supplied the only failure reason. */
73
+ error?: { code: string; message: string };
70
74
  }
71
75
  | {
72
76
  status: "failed";
@@ -184,7 +188,17 @@ export function createPromptReconciliation(options: { now?: () => number } = {})
184
188
  ) => {
185
189
  if (!correlation) return;
186
190
  const record = records.get(keyOf(correlation));
187
- if (!record || record.terminalAt !== undefined) return;
191
+ if (!record) return;
192
+ if (record.terminalAt !== undefined) {
193
+ // The terminal is claimed by one path while the reason arrives on another,
194
+ // so ordering is not the caller's to control. A late agent_failed enriches
195
+ // the settled record instead of being dropped; it must not resurrect it,
196
+ // so status, terminalAt, retention order, and clientRefIndex stay as-is.
197
+ // First reason wins: a late generic frame never overwrites a specific one.
198
+ if (frame.type === "agent_failed" && record.error === undefined)
199
+ record.error = sanitizePromptFailure(frame.error);
200
+ return;
201
+ }
188
202
  if (frame.type === "agent_start") {
189
203
  if (record.status === "accepted") {
190
204
  record.status = "in_flight";
@@ -227,7 +241,12 @@ export function createPromptReconciliation(options: { now?: () => number } = {})
227
241
  ...(record.startedAt !== undefined ? { startedAt: record.startedAt } : {}),
228
242
  terminalAt: record.terminalAt as number,
229
243
  };
230
- if (record.status === "terminal_ok") return { status: "terminal_ok", ...terminal };
244
+ if (record.status === "terminal_ok")
245
+ return {
246
+ status: "terminal_ok",
247
+ ...terminal,
248
+ ...(record.error !== undefined ? { error: record.error } : {}),
249
+ };
231
250
  return { status: "failed", ...terminal, error: record.error ?? sanitizePromptFailure(undefined) };
232
251
  };
233
252
 
@@ -93,8 +93,11 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
93
93
  * later session from creating a topic or replaying notifications. Generation
94
94
  * 58 preserves that invariant during durable-fence load promotion and restores
95
95
  * the exact grace deadline when a failed archive publication rolls back.
96
+ * Generation 59 publishes the owner's attached OPEN-socket count in the
97
+ * heartbeat sidecar so `gjc notify health` can report a live daemon that is
98
+ * attached to no registered endpoint (#4128).
96
99
  */
97
- export const DAEMON_GENERATION = 58;
100
+ export const DAEMON_GENERATION = 59;
98
101
 
99
102
  /**
100
103
  * Serving-compatibility boundary for daemon lifecycle requests. Epoch 5
@@ -1217,8 +1217,19 @@ type OwnerHeartbeatSidecar = {
1217
1217
  ownerId: string;
1218
1218
  acquisitionId: string;
1219
1219
  heartbeatAt: number;
1220
+ /**
1221
+ * Session endpoints this owner had an OPEN WebSocket to when the heartbeat was
1222
+ * published. Optional: a sidecar written by an older daemon omits it and must
1223
+ * read back as unknown, never as zero.
1224
+ */
1225
+ attachedEndpoints?: number;
1220
1226
  };
1221
1227
 
1228
+ /** Attachment counts are advisory observations, so only safe non-negative integers are published or trusted. */
1229
+ function validAttachedEndpointCount(value: unknown): value is number {
1230
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
1231
+ }
1232
+
1222
1233
  function ownerTagFromState(
1223
1234
  state: DaemonState | undefined,
1224
1235
  ): Pick<OwnerHeartbeatSidecar, "pid" | "incarnation" | "ownerId" | "acquisitionId"> | undefined {
@@ -1250,6 +1261,7 @@ function sidecarMatchesOwnerTag(
1250
1261
  typeof sidecar.ownerId === "string" &&
1251
1262
  typeof sidecar.acquisitionId === "string" &&
1252
1263
  Number.isSafeInteger(sidecar.heartbeatAt) &&
1264
+ (sidecar.attachedEndpoints === undefined || validAttachedEndpointCount(sidecar.attachedEndpoints)) &&
1253
1265
  sidecar.pid === tag.pid &&
1254
1266
  sidecar.incarnation === tag.incarnation &&
1255
1267
  sidecar.ownerId === tag.ownerId &&
@@ -1277,6 +1289,12 @@ export interface OwnerFreshnessSnapshot {
1277
1289
  ownerTag: Pick<OwnerHeartbeatSidecar, "pid" | "incarnation" | "ownerId" | "acquisitionId"> | null;
1278
1290
  effectiveHeartbeatAt: number | undefined;
1279
1291
  legacyEmbedded: boolean;
1292
+ /**
1293
+ * Session endpoints the current owner reported as attached in its latest matching
1294
+ * heartbeat sidecar. `undefined` means the owner never published the field (older
1295
+ * daemon, no stable owner tag, or no matching sidecar) — that is unknown, not zero.
1296
+ */
1297
+ attachedEndpoints: number | undefined;
1280
1298
  state: DaemonState | undefined;
1281
1299
  }
1282
1300
 
@@ -1293,7 +1311,14 @@ export async function readOwnerFreshnessSnapshot(input: {
1293
1311
  const tag = ownerTagFromState(state);
1294
1312
  const lock = await readOwnershipLock(fsImpl, paths.lock);
1295
1313
  const ownerTag = tag && ownershipLockMatchesState(lock, state) ? tag : null;
1296
- if (legacyEmbedded) return { ownerTag, effectiveHeartbeatAt: state?.heartbeatAt, legacyEmbedded, state };
1314
+ if (legacyEmbedded)
1315
+ return {
1316
+ ownerTag,
1317
+ effectiveHeartbeatAt: state?.heartbeatAt,
1318
+ legacyEmbedded,
1319
+ attachedEndpoints: undefined,
1320
+ state,
1321
+ };
1297
1322
  const sidecar = await readSidecarLenient(fsImpl, paths.heartbeat);
1298
1323
  const rereadState = await readJson<DaemonState>(fsImpl, paths.state);
1299
1324
  const rereadLock = await readOwnershipLock(fsImpl, paths.lock);
@@ -1301,15 +1326,20 @@ export async function readOwnerFreshnessSnapshot(input: {
1301
1326
  const stableTag = rereadTag && ownershipLockMatchesState(rereadLock, rereadState) ? rereadTag : null;
1302
1327
  const stable =
1303
1328
  ownerTag && stableTag && sidecarMatchesOwnerTag({ ...ownerTag, heartbeatAt: 0 }, stableTag) ? stableTag : null;
1329
+ const matchingSidecar = stable && sidecarMatchesOwnerTag(sidecar, stable) ? sidecar : undefined;
1304
1330
  const effectiveHeartbeatAt = stable
1305
- ? sidecarMatchesOwnerTag(sidecar, stable)
1306
- ? Math.max(rereadState?.heartbeatAt ?? 0, sidecar.heartbeatAt)
1331
+ ? matchingSidecar
1332
+ ? Math.max(rereadState?.heartbeatAt ?? 0, matchingSidecar.heartbeatAt)
1307
1333
  : rereadState?.heartbeatAt
1308
1334
  : undefined;
1335
+ const attachedEndpoints = validAttachedEndpointCount(matchingSidecar?.attachedEndpoints)
1336
+ ? matchingSidecar.attachedEndpoints
1337
+ : undefined;
1309
1338
  return {
1310
1339
  ownerTag: stable,
1311
1340
  effectiveHeartbeatAt,
1312
1341
  legacyEmbedded: false,
1342
+ attachedEndpoints,
1313
1343
  state: rereadState,
1314
1344
  };
1315
1345
  }
@@ -1323,6 +1353,12 @@ export async function renewOwnerHeartbeatSidecar(input: {
1323
1353
  now?: () => number;
1324
1354
  pid?: number;
1325
1355
  pidIncarnation?: (pid: number) => string | undefined;
1356
+ /**
1357
+ * Session endpoints the caller currently holds an OPEN WebSocket to. Omitted by
1358
+ * non-daemon callers (bootstrap/proof paths) so their sidecar reports unknown
1359
+ * rather than falsely claiming zero attachments.
1360
+ */
1361
+ attachedEndpoints?: number;
1326
1362
  }): Promise<boolean> {
1327
1363
  const fsImpl = input.fs ?? nodeFs;
1328
1364
  const paths = daemonPaths(input.settings.getAgentDir());
@@ -1347,6 +1383,7 @@ export async function renewOwnerHeartbeatSidecar(input: {
1347
1383
  ownerId: input.ownerId,
1348
1384
  acquisitionId,
1349
1385
  heartbeatAt: (input.now ?? Date.now)(),
1386
+ ...(validAttachedEndpointCount(input.attachedEndpoints) ? { attachedEndpoints: input.attachedEndpoints } : {}),
1350
1387
  };
1351
1388
  const tmp = `${paths.heartbeat}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
1352
1389
  await fsImpl.writeFile(tmp, `${JSON.stringify(sidecar, null, 2)}\n`, { mode: 0o600 });
@@ -9665,6 +9702,18 @@ export class TelegramNotificationDaemon {
9665
9702
  private stopFlushTimer(): void {
9666
9703
  this.runtime.stopInterval("telegram-flush");
9667
9704
  }
9705
+ /**
9706
+ * Sessions this owner can actually deliver to right now. Only `WebSocket.OPEN`
9707
+ * counts: a CONNECTING socket is constructed but cannot carry a frame, and
9708
+ * CLOSING/CLOSED ones are gone. Every delivery path in this daemon refuses to
9709
+ * send unless `readyState === WebSocket.OPEN`, so OPEN is exactly the state
9710
+ * that means a notification can leave this process for that session.
9711
+ */
9712
+ private attachedEndpointCount(): number {
9713
+ let attached = 0;
9714
+ for (const session of this.sessions.values()) if (session.ws.readyState === WebSocket.OPEN) attached += 1;
9715
+ return attached;
9716
+ }
9668
9717
  private async renewOwnershipHeartbeat(): Promise<boolean> {
9669
9718
  return renewOwnerHeartbeatSidecar({
9670
9719
  settings: this.opts.settings,
@@ -9674,6 +9723,7 @@ export class TelegramNotificationDaemon {
9674
9723
  now: this.opts.now,
9675
9724
  pid: this.opts.pid ?? process.pid,
9676
9725
  pidIncarnation: this.opts.pidIncarnation,
9726
+ attachedEndpoints: this.attachedEndpointCount(),
9677
9727
  });
9678
9728
  }
9679
9729
 
@@ -12347,6 +12397,7 @@ export class TelegramNotificationDaemon {
12347
12397
  now: this.opts.now,
12348
12398
  pid: this.opts.pid ?? process.pid,
12349
12399
  pidIncarnation: this.opts.pidIncarnation,
12400
+ attachedEndpoints: this.attachedEndpointCount(),
12350
12401
  }))
12351
12402
  )
12352
12403
  break;