@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
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { withFileLock } from "../../config/file-lock";
5
+ import { processIncarnation } from "./process-incarnation";
5
6
  import {
6
7
  assertSupportedSnapshotVersion,
7
8
  assertSupportedStateVersion,
@@ -17,7 +18,29 @@ export type SessionIndexEventType =
17
18
  | "lifecycle_started"
18
19
  | "lifecycle_terminal"
19
20
  | "session_closed"
21
+ | "session_deleted"
20
22
  | "record_reconciled";
23
+
24
+ export type SessionActivityState = "active" | "idle";
25
+ /** Coalesced broker-owned heartbeat checkpoint (C2): state plus the observation time. */
26
+ export interface SessionActivity {
27
+ state: SessionActivityState;
28
+ at: number;
29
+ }
30
+ /** Events persisted without an OS process incarnation (v1/v2 era) are legacy provenance. */
31
+ export type SessionIdentityProvenance = "composite" | "legacy";
32
+ export type SessionTombstoneRule = "retain" | "expire";
33
+ /**
34
+ * Injected retention policy (C3). The broker schedules compaction independently of
35
+ * rotation; settings apply at the next scheduled compaction. `clock` drives both
36
+ * retention expiry and heartbeat-freshness liveness reads.
37
+ */
38
+ export interface RetentionPolicy {
39
+ clock?: () => number;
40
+ maxAgeMs?: number;
41
+ maxRows?: number;
42
+ tombstoneRule?: SessionTombstoneRule;
43
+ }
21
44
  export interface SessionIndexEvent {
22
45
  version: typeof SDK_STATE_VERSION;
23
46
  indexSeq: number;
@@ -26,9 +49,20 @@ export interface SessionIndexEvent {
26
49
  locator: { repo: string; stateRoot: string };
27
50
  endpointGeneration: number;
28
51
  pid: number;
52
+ /**
53
+ * OS start incarnation of `pid`, published by the host that owns that pid. A
54
+ * pid is reusable, so teardown needs this binding to prove the pid is still
55
+ * the same process; keeping it here, in broker-owned storage, is what lets
56
+ * that proof outlive the session's own workspace.
57
+ */
58
+ processIncarnation?: string;
29
59
  endpointMtimeMs?: number;
30
60
  lifecycleRequestId?: string;
31
61
  terminalUncertain?: boolean;
62
+ /** OS process incarnation (C1); absent on legacy v1/v2 events. */
63
+ hostIncarnation?: string;
64
+ /** Present on host_heartbeat checkpoints (C2). */
65
+ activity?: SessionActivity;
32
66
  ts: number;
33
67
  checksum: string;
34
68
  }
@@ -37,11 +71,22 @@ export interface IndexedSession {
37
71
  locator: { repo: string; stateRoot: string };
38
72
  endpointGeneration: number;
39
73
  pid: number;
74
+ /** OS start incarnation of `pid` as published by its own host at registration. */
75
+ processIncarnation?: string;
40
76
  endpointMtimeMs?: number;
41
77
  live: boolean;
42
78
  indexSeq: number;
43
79
  lifecycleRequestId?: string;
44
80
  terminalUncertain?: boolean;
81
+ hostIncarnation?: string;
82
+ identityProvenance: SessionIdentityProvenance;
83
+ activity?: SessionActivity;
84
+ /** Wall-clock timestamp of the latest admitted heartbeat, when one exists. */
85
+ lastHeartbeatAt?: number;
86
+ /** True when the same sessionId maps to more than one stateRoot (cross-repo duplicate). */
87
+ ambiguous: boolean;
88
+ /** True when the identity's latest event is host_unregistered or session_closed (DR-1: stopped rows are retained). */
89
+ terminal: boolean;
45
90
  }
46
91
  export interface SessionList {
47
92
  indexSeq: number;
@@ -69,13 +114,243 @@ interface SessionIndexScan {
69
114
  logContents: Buffer | undefined;
70
115
  unsupportedError?: UnsupportedStateVersionError;
71
116
  }
117
+
118
+ /** Admission-fence rejection codes recorded in the durable index audit (C5/C4). */
119
+ export type SessionIndexAuditCode = "rejected_superseded_incarnation" | "rejected_after_tombstone";
120
+ export interface SessionIndexAuditRecord {
121
+ version: typeof SDK_STATE_VERSION;
122
+ code: SessionIndexAuditCode;
123
+ /** indexSeq of the rejected event (unique per record; used for idempotent dedupe). */
124
+ indexSeq: number;
125
+ sessionId: string;
126
+ endpointGeneration: number;
127
+ stateRoot: string;
128
+ hostIncarnation?: string;
129
+ supersededByIncarnation?: string;
130
+ /** indexSeq of the superseding registration, or of the tombstone for post-delete rejections. */
131
+ supersededByIndexSeq: number;
132
+ ts: number;
133
+ }
134
+
72
135
  const canonical = (event: Omit<SessionIndexEvent, "checksum">) => JSON.stringify(event);
73
136
  export const sessionIndexChecksum = (event: Omit<SessionIndexEvent, "checksum">) =>
74
137
  createHash("sha256").update(canonical(event)).digest("hex");
75
138
  const dirFor = (agentDir: string) => path.join(agentDir, "sdk", "sessions");
76
139
  const logFor = (agentDir: string) => path.join(dirFor(agentDir), "index.jsonl");
77
140
  const snapshotFor = (agentDir: string) => path.join(dirFor(agentDir), "index.snapshot.json");
141
+ const auditFor = (agentDir: string) => path.join(dirFor(agentDir), "index-audit.jsonl");
78
142
  const ROTATE_BYTES = 4 * 1024 * 1024;
143
+ /** Coalesced heartbeat checkpoint rate cap (C2): at most one per session per minute. */
144
+ export const SESSION_HEARTBEAT_INTERVAL_MS = 60_000;
145
+ export const DEFAULT_SESSION_RETENTION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
146
+ export const DEFAULT_SESSION_RETENTION_MAX_ROWS = 25_000;
147
+
148
+ /** Identity tuple: (sessionId, generation, stateRoot). Registration authority is per tuple. */
149
+ const tupleKey = (event: SessionIndexEvent) =>
150
+ `${event.sessionId}\u0000${event.endpointGeneration}\u0000${event.locator.stateRoot}`;
151
+ /** Composite identity (C1): (sessionId, generation, process incarnation, stateRoot). */
152
+ const effectiveIncarnation = (event: SessionIndexEvent) => event.hostIncarnation ?? event.processIncarnation;
153
+ const identityKey = (event: SessionIndexEvent) => `${tupleKey(event)}\u0000${effectiveIncarnation(event) ?? ""}`;
154
+
155
+ interface ResolvedRetentionPolicy {
156
+ clock: () => number;
157
+ maxAgeMs: number;
158
+ maxRows: number;
159
+ tombstoneRule: SessionTombstoneRule;
160
+ }
161
+ const resolvePolicy = (policy: RetentionPolicy): ResolvedRetentionPolicy => ({
162
+ clock: policy.clock ?? Date.now,
163
+ maxAgeMs: policy.maxAgeMs ?? DEFAULT_SESSION_RETENTION_MAX_AGE_MS,
164
+ maxRows: policy.maxRows ?? DEFAULT_SESSION_RETENTION_MAX_ROWS,
165
+ tombstoneRule: policy.tombstoneRule ?? "retain",
166
+ });
167
+
168
+ interface RejectedEvent {
169
+ code: SessionIndexAuditCode;
170
+ event: SessionIndexEvent;
171
+ supersededByIncarnation: string | undefined;
172
+ supersededByIndexSeq: number;
173
+ }
174
+ interface Admission {
175
+ admitted: SessionIndexEvent[];
176
+ rejected: RejectedEvent[];
177
+ }
178
+
179
+ /**
180
+ * Broker admission fence (C5), applied before reduction over the total order of
181
+ * checksum-chained indexSeq. Rule A: per (sessionId, generation, stateRoot) tuple the
182
+ * latest host_registered event is the incarnation authority; every event of a different
183
+ * incarnation is rejected (`rejected_superseded_incarnation`). This is a whole-log
184
+ * function so replay and snapshot replay re-derive the same admission order
185
+ * (supersession survives compaction). Rule B: after an admitted `session_deleted`
186
+ * tombstone, non-registration events of identities anchored to pre-delete registrations
187
+ * are rejected (`rejected_after_tombstone`), so a deleted session cannot be resurrected
188
+ * by stale old-host events, while registrations after the tombstone lift it.
189
+ */
190
+ function admitEvents(events: SessionIndexEvent[]): Admission {
191
+ const authoritative = new Map<string, { incarnation: string | undefined; indexSeq: number }>();
192
+ for (const event of events) {
193
+ if (event.type !== "host_registered") continue;
194
+ const key = tupleKey(event);
195
+ const current = authoritative.get(key);
196
+ if (current === undefined || event.indexSeq > current.indexSeq) {
197
+ authoritative.set(key, { incarnation: event.hostIncarnation, indexSeq: event.indexSeq });
198
+ }
199
+ }
200
+ const admitted: SessionIndexEvent[] = [];
201
+ const rejected: RejectedEvent[] = [];
202
+ for (const event of events) {
203
+ const authority = authoritative.get(tupleKey(event));
204
+ if (
205
+ authority !== undefined &&
206
+ authority.incarnation !== undefined &&
207
+ event.hostIncarnation !== undefined &&
208
+ event.hostIncarnation !== authority.incarnation
209
+ ) {
210
+ rejected.push({
211
+ code: "rejected_superseded_incarnation",
212
+ event,
213
+ supersededByIncarnation: authority.incarnation,
214
+ supersededByIndexSeq: authority.indexSeq,
215
+ });
216
+ continue;
217
+ }
218
+ admitted.push(event);
219
+ }
220
+ const tombstoneSeq = new Map<string, number>();
221
+ for (const event of admitted) {
222
+ if (event.type !== "session_deleted") continue;
223
+ const previous = tombstoneSeq.get(event.sessionId);
224
+ if (previous === undefined || event.indexSeq > previous) tombstoneSeq.set(event.sessionId, event.indexSeq);
225
+ }
226
+ if (tombstoneSeq.size === 0) return { admitted, rejected };
227
+ const anchorSeqByIdentity = new Map<string, number>();
228
+ const postTombstone: SessionIndexEvent[] = [];
229
+ for (const event of admitted) {
230
+ const key = identityKey(event);
231
+ const tombstone = tombstoneSeq.get(event.sessionId);
232
+ if (event.type === "host_registered") anchorSeqByIdentity.set(key, event.indexSeq);
233
+ if (tombstone === undefined || event.indexSeq <= tombstone) {
234
+ postTombstone.push(event);
235
+ continue;
236
+ }
237
+ if (event.type === "host_registered") {
238
+ postTombstone.push(event);
239
+ continue;
240
+ }
241
+ const anchor = anchorSeqByIdentity.get(key);
242
+ if (anchor === undefined || anchor <= tombstone) {
243
+ rejected.push({
244
+ code: "rejected_after_tombstone",
245
+ event,
246
+ supersededByIncarnation: undefined,
247
+ supersededByIndexSeq: tombstone,
248
+ });
249
+ continue;
250
+ }
251
+ postTombstone.push(event);
252
+ }
253
+ return { admitted: postTombstone, rejected };
254
+ }
255
+
256
+ /** Pure rejection ledger derived from the event stream (C5 audit, idempotent by indexSeq). */
257
+ function auditRecords(events: SessionIndexEvent[], ts: number): SessionIndexAuditRecord[] {
258
+ const { rejected } = admitEvents(events);
259
+ return rejected.map(rejection => ({
260
+ version: SDK_STATE_VERSION,
261
+ code: rejection.code,
262
+ indexSeq: rejection.event.indexSeq,
263
+ sessionId: rejection.event.sessionId,
264
+ endpointGeneration: rejection.event.endpointGeneration,
265
+ stateRoot: rejection.event.locator.stateRoot,
266
+ ...(rejection.event.hostIncarnation !== undefined ? { hostIncarnation: rejection.event.hostIncarnation } : {}),
267
+ ...(rejection.supersededByIncarnation !== undefined
268
+ ? { supersededByIncarnation: rejection.supersededByIncarnation }
269
+ : {}),
270
+ supersededByIndexSeq: rejection.supersededByIndexSeq,
271
+ ts,
272
+ }));
273
+ }
274
+
275
+ /**
276
+ * Total-order projection (C5/C6): operate only on admitted events keyed by composite
277
+ * identity; the cross-identity winner per sessionId is the admitted event with the
278
+ * highest (generation, indexSeq); only `session_deleted` hides a row (DR-1 retains
279
+ * stopped/terminal credential-free rows so inspect/offline tail can work). Heartbeats
280
+ * inherit locator/endpoint metadata from their identity's prior event. Liveness (C2)
281
+ * requires a heartbeat observed within 2x the checkpoint interval AND a live host:
282
+ * a missing heartbeat (e.g. after a broker restart) is unknown, never fresh forever.
283
+ */
284
+ function reduceEvents(events: SessionIndexEvent[], now: number): IndexedSession[] {
285
+ const { admitted } = admitEvents(events);
286
+ const latestByIdentity = new Map<string, SessionIndexEvent>();
287
+ const latestHeartbeatByIdentity = new Map<string, SessionIndexEvent>();
288
+ for (const event of admitted) {
289
+ const key = identityKey(event);
290
+ if (event.type === "host_heartbeat") {
291
+ latestHeartbeatByIdentity.set(key, event);
292
+ continue;
293
+ }
294
+ const previous = latestByIdentity.get(key);
295
+ if (previous === undefined || event.indexSeq > previous.indexSeq) latestByIdentity.set(key, event);
296
+ }
297
+ const winner = new Map<string, { gen: number; seq: number; identity: string }>();
298
+ const roots = new Map<string, Set<string>>();
299
+ for (const event of admitted) {
300
+ let group = roots.get(event.sessionId);
301
+ if (group === undefined) {
302
+ group = new Set();
303
+ roots.set(event.sessionId, group);
304
+ }
305
+ group.add(event.locator.stateRoot);
306
+ if (event.type === "host_heartbeat") continue;
307
+ const current = winner.get(event.sessionId);
308
+ if (
309
+ current === undefined ||
310
+ event.endpointGeneration > current.gen ||
311
+ (event.endpointGeneration === current.gen && event.indexSeq > current.seq)
312
+ ) {
313
+ winner.set(event.sessionId, {
314
+ gen: event.endpointGeneration,
315
+ seq: event.indexSeq,
316
+ identity: identityKey(event),
317
+ });
318
+ }
319
+ }
320
+ const sessions: IndexedSession[] = [];
321
+ for (const [sessionId, chosen] of winner) {
322
+ const latest = latestByIdentity.get(chosen.identity);
323
+ if (latest === undefined) continue;
324
+ const terminal = latest.type === "host_unregistered" || latest.type === "session_closed";
325
+ if (latest.type === "session_deleted") continue;
326
+ const heartbeat = latestHeartbeatByIdentity.get(chosen.identity);
327
+ const pidAlive = alive(latest.pid);
328
+ const heartbeatFresh = heartbeat !== undefined && now - heartbeat.ts < 2 * SESSION_HEARTBEAT_INTERVAL_MS;
329
+ const recordedIncarnation = effectiveIncarnation(latest);
330
+ const currentIncarnation = recordedIncarnation === undefined ? undefined : processIncarnation(latest.pid);
331
+ const incarnationMatches = currentIncarnation !== undefined && currentIncarnation === recordedIncarnation;
332
+ sessions.push({
333
+ sessionId,
334
+ locator: latest.locator,
335
+ endpointGeneration: latest.endpointGeneration,
336
+ pid: latest.pid,
337
+ processIncarnation: latest.processIncarnation,
338
+ endpointMtimeMs: latest.endpointMtimeMs,
339
+ lifecycleRequestId: latest.lifecycleRequestId,
340
+ terminalUncertain: latest.type === "lifecycle_terminal" || latest.terminalUncertain === true,
341
+ indexSeq: latest.indexSeq,
342
+ hostIncarnation: latest.hostIncarnation,
343
+ identityProvenance: recordedIncarnation === undefined ? "legacy" : "composite",
344
+ activity: heartbeat?.activity,
345
+ lastHeartbeatAt: heartbeat?.ts,
346
+ ambiguous: (roots.get(sessionId)?.size ?? 0) > 1,
347
+ terminal,
348
+ live: !terminal && pidAlive && heartbeatFresh && incarnationMatches,
349
+ });
350
+ }
351
+ return sessions;
352
+ }
353
+
79
354
  function isValidSnapshot(snapshot: unknown): snapshot is { indexSeq: number; events: SessionIndexEvent[] } {
80
355
  if (!snapshot || typeof snapshot !== "object") return false;
81
356
  const { indexSeq, events } = snapshot as { indexSeq?: unknown; events?: unknown };
@@ -96,36 +371,74 @@ function isValidSnapshot(snapshot: unknown): snapshot is { indexSeq: number; eve
96
371
  return previous === indexSeq;
97
372
  }
98
373
 
99
- // Compact the event history for a snapshot without renumbering: clients hold indexSeq
100
- // across calls, so retained events keep their original indexSeq and checksum. Drops
101
- // terminal+dead sessions entirely, collapses superseded heartbeats to the latest per
102
- // surviving session, and always retains the global-max indexSeq as the chain anchor.
103
- function compactEvents(events: SessionIndexEvent[]): SessionIndexEvent[] {
374
+ /**
375
+ * Compact the event history for a snapshot without renumbering: clients hold indexSeq
376
+ * across calls, so retained events keep their original indexSeq and checksum. Stopped
377
+ * and terminal identities are retained (DR-1: only `session_deleted` hides a row), so
378
+ * inspect/offline tail keep working across compaction; superseded heartbeats collapse
379
+ * to the latest per surviving composite identity, the global-max indexSeq always stays
380
+ * as the chain anchor, then the injected retention policy applies per session
381
+ * (whole-session eviction keeps the projection deterministic: a dropped session
382
+ * contributes no events to re-derive). Tombstone rule "retain" exempts deleted sessions
383
+ * from age/row eviction (C4 audit evidence retained); "expire" evicts them like any
384
+ * other session.
385
+ */
386
+ function compactEvents(events: SessionIndexEvent[], policy: ResolvedRetentionPolicy): SessionIndexEvent[] {
104
387
  if (events.length === 0) return events;
105
388
  const maxIndexSeq = events[events.length - 1]!.indexSeq;
106
- const latestBySession = new Map<string, SessionIndexEvent>();
107
- for (const event of events) latestBySession.set(event.sessionId, event);
108
- const deadTerminal = new Set<string>();
109
- for (const [sessionId, latest] of latestBySession) {
110
- const terminal = latest.type === "host_unregistered" || latest.type === "session_closed";
111
- if (terminal && !alive(latest.pid)) deadTerminal.add(sessionId);
112
- }
113
- const latestHeartbeatSeq = new Map<string, number>();
389
+ const latestByIdentity = new Map<string, SessionIndexEvent>();
390
+ const latestHeartbeatByIdentity = new Map<string, number>();
114
391
  for (const event of events) {
115
- if (event.type === "host_heartbeat" && !deadTerminal.has(event.sessionId)) {
116
- latestHeartbeatSeq.set(event.sessionId, event.indexSeq);
392
+ const key = identityKey(event);
393
+ const previous = latestByIdentity.get(key);
394
+ if (previous === undefined || event.indexSeq > previous.indexSeq) latestByIdentity.set(key, event);
395
+ if (event.type === "host_heartbeat") {
396
+ const current = latestHeartbeatByIdentity.get(key);
397
+ if (current === undefined || event.indexSeq > current) latestHeartbeatByIdentity.set(key, event.indexSeq);
117
398
  }
118
399
  }
400
+ const now = policy.clock();
401
+ const sessionLatest = new Map<string, SessionIndexEvent>();
402
+ for (const event of events) {
403
+ const previous = sessionLatest.get(event.sessionId);
404
+ if (previous === undefined || event.indexSeq > previous.indexSeq) sessionLatest.set(event.sessionId, event);
405
+ }
406
+ const expiredSessions = new Set<string>();
407
+ for (const [sessionId, latest] of sessionLatest) {
408
+ if (latest.indexSeq === maxIndexSeq) continue;
409
+ const deleted = latest.type === "session_deleted";
410
+ if (policy.tombstoneRule === "retain" && deleted) continue;
411
+ if (latest.ts < now - policy.maxAgeMs) expiredSessions.add(sessionId);
412
+ }
119
413
  const kept: SessionIndexEvent[] = [];
120
414
  for (const event of events) {
121
415
  if (event.indexSeq === maxIndexSeq) {
122
416
  kept.push(event);
123
417
  continue;
124
418
  }
125
- if (deadTerminal.has(event.sessionId)) continue;
126
- if (event.type === "host_heartbeat" && latestHeartbeatSeq.get(event.sessionId) !== event.indexSeq) continue;
419
+ if (event.type === "host_heartbeat" && latestHeartbeatByIdentity.get(identityKey(event)) !== event.indexSeq)
420
+ continue;
421
+ if (expiredSessions.has(event.sessionId)) continue;
127
422
  kept.push(event);
128
423
  }
424
+ if (policy.maxRows >= 1 && kept.length > policy.maxRows) {
425
+ const keptLatest = new Map<string, SessionIndexEvent>();
426
+ for (const event of kept) {
427
+ const previous = keptLatest.get(event.sessionId);
428
+ if (previous === undefined || event.indexSeq > previous.indexSeq) keptLatest.set(event.sessionId, event);
429
+ }
430
+ const anchorSession = kept.find(event => event.indexSeq === maxIndexSeq)?.sessionId;
431
+ const candidates = [...keptLatest.entries()]
432
+ .filter(([sessionId]) => sessionId !== anchorSession)
433
+ .filter(([, latest]) => !(policy.tombstoneRule === "retain" && latest.type === "session_deleted"))
434
+ .sort((a, b) => a[1].indexSeq - b[1].indexSeq);
435
+ let result = kept;
436
+ for (const [sessionId] of candidates) {
437
+ if (result.length <= policy.maxRows) break;
438
+ result = result.filter(event => event.sessionId !== sessionId);
439
+ }
440
+ return result;
441
+ }
129
442
  return kept;
130
443
  }
131
444
 
@@ -211,12 +524,16 @@ export class SessionIndex {
211
524
  static #operations = new Map<string, Promise<void>>();
212
525
  static #openGroups = new Map<string, SessionIndexOpenGroup>();
213
526
  #agentDir: string;
527
+ #policy: ResolvedRetentionPolicy;
214
528
  #events: SessionIndexEvent[] = [];
215
529
  #warnings: string[] = [];
216
530
  #logOffset = 0;
217
531
  #corruptSuffix = false;
218
- constructor(agentDir: string) {
532
+ /** indexSeqs already recorded in the durable audit; null until lazily seeded. */
533
+ #auditedSeq: Set<number> | null = null;
534
+ constructor(agentDir: string, policy: RetentionPolicy = {}) {
219
535
  this.#agentDir = agentDir;
536
+ this.#policy = resolvePolicy(policy);
220
537
  }
221
538
  static #enqueue<T>(indexPath: string, operation: () => Promise<T>): Promise<T> {
222
539
  const previous = SessionIndex.#operations.get(indexPath) ?? Promise.resolve();
@@ -265,6 +582,33 @@ export class SessionIndex {
265
582
  this.#corruptSuffix = scan.diagnosis.status === "corrupt";
266
583
  if (scan.diagnosis.reason === "invalid snapshot") this.#warnings.push("Invalid session index snapshot");
267
584
  if (this.#corruptSuffix) this.#warnings.push("Corrupt session index entry; replay truncated");
585
+ await this.#writeAuditUnderLock();
586
+ }
587
+ /** Seed the audit dedupe set once, then append records for newly-rejected events. */
588
+ async #writeAuditUnderLock(): Promise<void> {
589
+ if (this.#auditedSeq === null) {
590
+ this.#auditedSeq = new Set();
591
+ try {
592
+ const contents = await fs.readFile(auditFor(this.#agentDir), "utf8");
593
+ for (const line of contents.split("\n")) {
594
+ if (!line) continue;
595
+ try {
596
+ const record = JSON.parse(line) as Partial<SessionIndexAuditRecord>;
597
+ if (typeof record.indexSeq === "number") this.#auditedSeq.add(record.indexSeq);
598
+ } catch {
599
+ // Best-effort dedupe seed; a corrupt audit row never blocks the index.
600
+ }
601
+ }
602
+ } catch (error) {
603
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
604
+ }
605
+ }
606
+ const pending = auditRecords(this.#events, this.#policy.clock()).filter(
607
+ record => !this.#auditedSeq!.has(record.indexSeq),
608
+ );
609
+ if (pending.length === 0) return;
610
+ await appendSync(auditFor(this.#agentDir), pending.map(record => JSON.stringify(record)).join("\n"));
611
+ for (const record of pending) this.#auditedSeq.add(record.indexSeq);
268
612
  }
269
613
  async #scan(): Promise<SessionIndexScan> {
270
614
  let snapshotContents: Buffer | undefined;
@@ -513,6 +857,7 @@ export class SessionIndex {
513
857
  }
514
858
  async #refreshUnderLock(): Promise<void> {
515
859
  await this.#tailUnderLock();
860
+ await this.#writeAuditUnderLock();
516
861
  }
517
862
  get indexSeq(): number {
518
863
  return this.#events.at(-1)?.indexSeq ?? 0;
@@ -536,6 +881,11 @@ export class SessionIndex {
536
881
  indexSeq: this.indexSeq + 1,
537
882
  ts: input.ts ?? Date.now(),
538
883
  };
884
+ // Use the registration's durable process identity when supplied; otherwise
885
+ // derive one from the OS. Legacy records remain explicitly unbound.
886
+ if (unsigned.hostIncarnation === undefined && Number.isSafeInteger(unsigned.pid) && unsigned.pid > 0) {
887
+ unsigned.hostIncarnation = unsigned.processIncarnation ?? processIncarnation(unsigned.pid);
888
+ }
539
889
  const event: SessionIndexEvent = { ...unsigned, checksum: sessionIndexChecksum(unsigned) };
540
890
  await appendSync(logFor(this.#agentDir), JSON.stringify(event));
541
891
  await this.#refreshUnderLock();
@@ -544,6 +894,24 @@ export class SessionIndex {
544
894
  });
545
895
  });
546
896
  }
897
+
898
+ /** Hold the canonical index lock across an authority-sensitive operation. */
899
+ async withLocked<T>(callback: () => Promise<T>): Promise<T> {
900
+ const indexPath = path.resolve(logFor(this.#agentDir));
901
+ return await SessionIndex.#enqueue(indexPath, async () => {
902
+ try {
903
+ await fs.stat(dirFor(this.#agentDir));
904
+ } catch (error) {
905
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return await callback();
906
+ throw error;
907
+ }
908
+ return await withFileLock(logFor(this.#agentDir), async () => {
909
+ await this.#replayUnderLock();
910
+ if (this.#corruptSuffix) throw new Error("Cannot use corrupt session index for artifact reclamation");
911
+ return await callback();
912
+ });
913
+ });
914
+ }
547
915
  async snapshot(): Promise<void> {
548
916
  const indexPath = path.resolve(logFor(this.#agentDir));
549
917
  await SessionIndex.#enqueue(indexPath, () =>
@@ -566,7 +934,7 @@ export class SessionIndex {
566
934
  JSON.stringify({
567
935
  version: SESSION_INDEX_SNAPSHOT_VERSION,
568
936
  indexSeq: this.indexSeq,
569
- events: compactEvents(this.#events),
937
+ events: compactEvents(this.#events, this.#policy),
570
938
  }),
571
939
  {
572
940
  mode: 0o600,
@@ -581,6 +949,19 @@ export class SessionIndex {
581
949
  await fs.rename(tmp, file);
582
950
  await syncDirectory(file);
583
951
  }
952
+ /**
953
+ * Broker-scheduled compaction (C3), independent of rotation size: applies the
954
+ * injected retention policy to a fresh snapshot and truncates the log.
955
+ */
956
+ async compact(): Promise<void> {
957
+ const indexPath = path.resolve(logFor(this.#agentDir));
958
+ await SessionIndex.#enqueue(indexPath, () =>
959
+ withFileLock(logFor(this.#agentDir), async () => {
960
+ await this.#replayUnderLock();
961
+ await this.#rotate();
962
+ }),
963
+ );
964
+ }
584
965
  async #rotate(): Promise<void> {
585
966
  await this.#snapshotUnderLock();
586
967
  const file = logFor(this.#agentDir);
@@ -592,35 +973,64 @@ export class SessionIndex {
592
973
  }
593
974
 
594
975
  listSessions(): SessionList {
595
- const latest = new Map<string, SessionIndexEvent>();
596
- for (const event of this.#events) {
597
- const previous = latest.get(event.sessionId);
598
- latest.set(
599
- event.sessionId,
600
- event.type === "host_heartbeat" && previous
601
- ? {
602
- ...event,
603
- locator: previous.locator,
604
- endpointMtimeMs: previous.endpointMtimeMs,
605
- lifecycleRequestId: previous.lifecycleRequestId,
606
- }
607
- : event,
608
- );
609
- }
610
- const sessions = [...latest.values()]
611
- .filter(event => !["host_unregistered", "session_closed"].includes(event.type))
612
- .map(event => ({
613
- sessionId: event.sessionId,
614
- locator: event.locator,
615
- endpointGeneration: event.endpointGeneration,
616
- pid: event.pid,
617
- endpointMtimeMs: event.endpointMtimeMs,
618
- lifecycleRequestId: event.lifecycleRequestId,
619
- terminalUncertain: event.type === "lifecycle_terminal" || event.terminalUncertain === true,
620
- indexSeq: event.indexSeq,
621
- live: alive(event.pid),
622
- }));
623
- return { indexSeq: this.indexSeq, sessions, warnings: this.#warnings };
976
+ return {
977
+ indexSeq: this.indexSeq,
978
+ sessions: reduceEvents(this.#events, this.#policy.clock()),
979
+ warnings: this.#warnings,
980
+ };
981
+ }
982
+
983
+ /**
984
+ * Production coalesced heartbeat checkpoint pass (C2): appends one
985
+ * `host_heartbeat` per session at most once per {@link SESSION_HEARTBEAT_INTERVAL_MS}.
986
+ * The pass observes liveness the same way the projection does — the host process
987
+ * must be alive and, for composite identities, still carry the recorded OS process
988
+ * incarnation (a reused PID is never checkpointed). Stopped/terminal rows and rows
989
+ * whose heartbeat is still fresh are skipped. After a broker restart, sessions whose
990
+ * host survived are re-observed as live on the first pass; sessions whose host died
991
+ * while the broker was down keep their stale or missing heartbeat and read as
992
+ * unknown/not-live (never fresh forever). Returns the number of checkpoints written.
993
+ */
994
+ async checkpointLiveHeartbeats(now = Date.now()): Promise<number> {
995
+ const indexPath = path.resolve(logFor(this.#agentDir));
996
+ return await SessionIndex.#enqueue(indexPath, async () => {
997
+ await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
998
+ return await withFileLock(logFor(this.#agentDir), async () => {
999
+ await this.#replayUnderLock();
1000
+ if (this.#corruptSuffix) return 0;
1001
+ const events: SessionIndexEvent[] = [];
1002
+ const rows = reduceEvents(this.#events, now);
1003
+ for (const row of rows) {
1004
+ if (row.terminal) continue;
1005
+ if (row.lastHeartbeatAt !== undefined && now - row.lastHeartbeatAt < SESSION_HEARTBEAT_INTERVAL_MS)
1006
+ continue;
1007
+ if (!alive(row.pid)) continue;
1008
+ const recordedIncarnation = row.hostIncarnation ?? row.processIncarnation;
1009
+ if (recordedIncarnation === undefined) continue;
1010
+ const current = processIncarnation(row.pid);
1011
+ if (current === undefined || current !== recordedIncarnation) continue;
1012
+ const unsigned: Omit<SessionIndexEvent, "checksum"> = {
1013
+ version: SDK_STATE_VERSION,
1014
+ indexSeq: this.indexSeq + events.length + 1,
1015
+ type: "host_heartbeat",
1016
+ sessionId: row.sessionId,
1017
+ locator: row.locator,
1018
+ endpointGeneration: row.endpointGeneration,
1019
+ pid: row.pid,
1020
+ ...(row.processIncarnation === undefined ? {} : { processIncarnation: row.processIncarnation }),
1021
+ ...(row.hostIncarnation === undefined ? {} : { hostIncarnation: row.hostIncarnation }),
1022
+ activity: { state: "active", at: now },
1023
+ ts: now,
1024
+ };
1025
+ events.push({ ...unsigned, checksum: sessionIndexChecksum(unsigned) });
1026
+ }
1027
+ if (events.length === 0) return 0;
1028
+ for (const event of events) await appendSync(logFor(this.#agentDir), JSON.stringify(event));
1029
+ await this.#refreshUnderLock();
1030
+ if ((await fs.stat(logFor(this.#agentDir))).size >= ROTATE_BYTES) await this.#rotate();
1031
+ return events.length;
1032
+ });
1033
+ });
624
1034
  }
625
1035
 
626
1036
  hostUnregisteredAfter(
@@ -667,11 +1077,16 @@ export class SessionIndex {
667
1077
  locator: event.locator,
668
1078
  endpointGeneration: event.endpointGeneration,
669
1079
  pid: event.pid,
1080
+ processIncarnation: event.processIncarnation,
670
1081
  endpointMtimeMs: event.endpointMtimeMs,
671
1082
  lifecycleRequestId: event.lifecycleRequestId,
672
1083
  terminalUncertain: false,
673
1084
  indexSeq: event.indexSeq,
1085
+ hostIncarnation: event.hostIncarnation,
1086
+ identityProvenance: event.hostIncarnation === undefined ? "legacy" : "composite",
1087
+ ambiguous: false,
674
1088
  live: alive(event.pid),
1089
+ terminal: false,
675
1090
  }
676
1091
  : undefined;
677
1092
  }