@cursor/july 0.1.1 → 0.1.2

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 (199) hide show
  1. package/AGENTS.md +24 -2
  2. package/README.md +25 -15
  3. package/dist/bin/agent-serve.js +101 -12
  4. package/dist/channels/github/api.d.ts.map +1 -1
  5. package/dist/channels/github/api.js +31 -14
  6. package/dist/channels/github/cursor-account.d.ts +43 -0
  7. package/dist/channels/github/cursor-account.d.ts.map +1 -0
  8. package/dist/channels/github/cursor-account.js +95 -0
  9. package/dist/channels/github/github-channel.d.ts.map +1 -1
  10. package/dist/channels/github/github-channel.js +46 -9
  11. package/dist/channels/github/index.d.ts +2 -2
  12. package/dist/channels/github/index.js +2 -2
  13. package/dist/channels/github/types.d.ts +17 -0
  14. package/dist/channels/github/types.d.ts.map +1 -1
  15. package/dist/channels/slack/slack-channel.d.ts +8 -2
  16. package/dist/channels/slack/slack-channel.d.ts.map +1 -1
  17. package/dist/channels/slack/slack-channel.js +8 -0
  18. package/dist/channels/slack/types.d.ts +24 -3
  19. package/dist/channels/slack/types.d.ts.map +1 -1
  20. package/dist/channels/slack/types.js +15 -1
  21. package/dist/docs/404.html +2 -2
  22. package/dist/docs/ab.html +8 -8
  23. package/dist/docs/assets/{ab.md.COdXkces.js → ab.md.BMCZ6Hd7.js} +3 -3
  24. package/dist/docs/assets/{ab.md.COdXkces.lean.js → ab.md.BMCZ6Hd7.lean.js} +1 -1
  25. package/dist/docs/assets/{app.DqfFEmJd.js → app.Oje4vhlk.js} +1 -1
  26. package/dist/docs/assets/chunks/@localSearchIndexroot.zwQ9RCQ7.js +1 -0
  27. package/dist/docs/assets/chunks/{VPLocalSearchBox.BaLEdS15.js → VPLocalSearchBox.H5XZ2zCB.js} +1 -1
  28. package/dist/docs/assets/chunks/{theme.CZRvu_0q.js → theme.CTR_TuaE.js} +2 -2
  29. package/dist/docs/assets/{deployment.md.Dx1TYNk5.js → deployment.md.DTKwE15Z.js} +3 -3
  30. package/dist/docs/assets/{deployment.md.Dx1TYNk5.lean.js → deployment.md.DTKwE15Z.lean.js} +1 -1
  31. package/dist/docs/assets/{evals.md.DPZ_MAnI.js → evals.md.DAgEc_hL.js} +3 -3
  32. package/dist/docs/assets/{guides_agent-to-agent.md.CrtrsySy.js → guides_agent-to-agent.md.Bpzgq2Pq.js} +1 -1
  33. package/dist/docs/assets/{guides_cloud-runtime.md.CYlNMTNp.js → guides_cloud-runtime.md.gVzabdQL.js} +1 -1
  34. package/dist/docs/assets/{guides_github.md.DwbKhCeS.js → guides_github.md.DOOCpqsW.js} +11 -4
  35. package/dist/docs/assets/{guides_github.md.DwbKhCeS.lean.js → guides_github.md.DOOCpqsW.lean.js} +1 -1
  36. package/dist/docs/assets/{guides_human-in-the-loop.md.Dvuctx7s.js → guides_human-in-the-loop.md.DlUqsp1S.js} +2 -2
  37. package/dist/docs/assets/{guides_slack.md.bv41fHfW.js → guides_slack.md.CCwqHvSV.js} +4 -4
  38. package/dist/docs/assets/{guides_slack.md.bv41fHfW.lean.js → guides_slack.md.CCwqHvSV.lean.js} +1 -1
  39. package/dist/docs/assets/{guides_webhooks.md.hFTik3lf.js → guides_webhooks.md.B1EswtUu.js} +2 -2
  40. package/dist/docs/assets/index.md.m81y7TY7.js +20 -0
  41. package/dist/docs/assets/{index.md.BPKcj5AI.lean.js → index.md.m81y7TY7.lean.js} +1 -1
  42. package/dist/docs/assets/quickstart.md.CfU8_uTC.js +192 -0
  43. package/dist/docs/assets/quickstart.md.CfU8_uTC.lean.js +1 -0
  44. package/dist/docs/assets/{reference_agent-config.md.Bpd7HQwf.js → reference_agent-config.md.DrW2JUM8.js} +4 -4
  45. package/dist/docs/assets/{reference_agent-config.md.Bpd7HQwf.lean.js → reference_agent-config.md.DrW2JUM8.lean.js} +1 -1
  46. package/dist/docs/assets/{reference_channels.md.D7JTR03W.js → reference_channels.md.DdmiKgqf.js} +4 -4
  47. package/dist/docs/assets/{reference_channels.md.D7JTR03W.lean.js → reference_channels.md.DdmiKgqf.lean.js} +1 -1
  48. package/dist/docs/assets/{reference_connections.md.C3vNH_DE.js → reference_connections.md.zaEYCLHT.js} +1 -1
  49. package/dist/docs/assets/{reference_hooks.md.BCEc3MyM.js → reference_hooks.md.DyLVfE1O.js} +1 -1
  50. package/dist/docs/assets/{reference_hooks.md.BCEc3MyM.lean.js → reference_hooks.md.DyLVfE1O.lean.js} +1 -1
  51. package/dist/docs/assets/{reference_http-api.md.DBAahtdz.js → reference_http-api.md.Dx_nmDG6.js} +1 -1
  52. package/dist/docs/assets/{reference_instructions.md.BC05LEQ8.js → reference_instructions.md.CgoV-YEb.js} +9 -7
  53. package/dist/docs/assets/{reference_instructions.md.BC05LEQ8.lean.js → reference_instructions.md.CgoV-YEb.lean.js} +1 -1
  54. package/dist/docs/assets/{reference_schedules.md.D7qijxLk.js → reference_schedules.md.w_F2mXB6.js} +2 -2
  55. package/dist/docs/assets/{reference_skills.md.VQnlBT3Q.js → reference_skills.md.B_jHN7JL.js} +3 -3
  56. package/dist/docs/assets/{reference_skills.md.VQnlBT3Q.lean.js → reference_skills.md.B_jHN7JL.lean.js} +1 -1
  57. package/dist/docs/assets/{reference_subagents.md.CIRAVcPK.js → reference_subagents.md.zWAMNfi1.js} +1 -1
  58. package/dist/docs/assets/{reference_tools.md.DF5kwlt0.js → reference_tools.md.CqgJroI0.js} +2 -2
  59. package/dist/docs/assets/scaffolding-agents.md.C3pTrmoE.js +1 -0
  60. package/dist/docs/assets/scaffolding-agents.md.C3pTrmoE.lean.js +1 -0
  61. package/dist/docs/assets/storage.md.CVnInNiN.js +17 -0
  62. package/dist/docs/assets/storage.md.CVnInNiN.lean.js +1 -0
  63. package/dist/docs/building-with-agents.html +4 -4
  64. package/dist/docs/concepts.html +4 -4
  65. package/dist/docs/deployment.html +7 -7
  66. package/dist/docs/evals.html +7 -7
  67. package/dist/docs/guides/agent-to-agent.html +6 -6
  68. package/dist/docs/guides/cloud-runtime.html +5 -5
  69. package/dist/docs/guides/github.html +14 -7
  70. package/dist/docs/guides/human-in-the-loop.html +6 -6
  71. package/dist/docs/guides/slack.html +8 -8
  72. package/dist/docs/guides/webhooks.html +6 -6
  73. package/dist/docs/hashmap.json +1 -1
  74. package/dist/docs/hillclimbing.html +5 -5
  75. package/dist/docs/index.html +7 -7
  76. package/dist/docs/quickstart.html +180 -23
  77. package/dist/docs/reference/agent-config.html +7 -7
  78. package/dist/docs/reference/channels.html +8 -8
  79. package/dist/docs/reference/cli.html +4 -4
  80. package/dist/docs/reference/connections.html +5 -5
  81. package/dist/docs/reference/hooks.html +5 -5
  82. package/dist/docs/reference/http-api.html +6 -6
  83. package/dist/docs/reference/instructions.html +13 -11
  84. package/dist/docs/reference/playground.html +4 -4
  85. package/dist/docs/reference/project-layout.html +4 -4
  86. package/dist/docs/reference/schedules.html +7 -7
  87. package/dist/docs/reference/sessions.html +4 -4
  88. package/dist/docs/reference/skills.html +6 -6
  89. package/dist/docs/reference/subagents.html +6 -6
  90. package/dist/docs/reference/tools.html +7 -7
  91. package/dist/docs/scaffolding-agents.html +5 -5
  92. package/dist/docs/storage.html +41 -0
  93. package/dist/docs/troubleshooting.html +4 -4
  94. package/dist/index.d.ts +2 -0
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +1 -0
  97. package/dist/internal/cli-deploy.d.ts +52 -0
  98. package/dist/internal/cli-deploy.d.ts.map +1 -0
  99. package/dist/internal/cli-deploy.js +731 -0
  100. package/dist/internal/cursor/backend-client.d.ts +10 -1
  101. package/dist/internal/cursor/backend-client.d.ts.map +1 -1
  102. package/dist/internal/cursor/backend-client.js +79 -1
  103. package/dist/internal/cursor/github-credentials.d.ts +44 -0
  104. package/dist/internal/cursor/github-credentials.d.ts.map +1 -0
  105. package/dist/internal/cursor/github-credentials.js +195 -0
  106. package/dist/internal/deploy-client.d.ts +176 -0
  107. package/dist/internal/deploy-client.d.ts.map +1 -0
  108. package/dist/internal/deploy-client.js +375 -0
  109. package/dist/internal/discovery.d.ts.map +1 -1
  110. package/dist/internal/discovery.js +73 -5
  111. package/dist/internal/distribution.d.ts.map +1 -1
  112. package/dist/internal/distribution.js +1 -0
  113. package/dist/internal/eval-run-store.d.ts +22 -3
  114. package/dist/internal/eval-run-store.d.ts.map +1 -1
  115. package/dist/internal/eval-run-store.js +37 -19
  116. package/dist/internal/handleAgentServeTrigger.d.ts.map +1 -1
  117. package/dist/internal/handleAgentServeTrigger.js +10 -12
  118. package/dist/internal/host-platforms.d.ts +7 -2
  119. package/dist/internal/host-platforms.d.ts.map +1 -1
  120. package/dist/internal/host-platforms.js +15 -10
  121. package/dist/internal/hosting.d.ts +37 -0
  122. package/dist/internal/hosting.d.ts.map +1 -0
  123. package/dist/internal/hosting.js +67 -0
  124. package/dist/internal/reminder-runner.d.ts +7 -0
  125. package/dist/internal/reminder-runner.d.ts.map +1 -1
  126. package/dist/internal/reminder-runner.js +50 -6
  127. package/dist/internal/reminder-store.d.ts +2 -0
  128. package/dist/internal/reminder-store.d.ts.map +1 -1
  129. package/dist/internal/reminder-store.js +18 -0
  130. package/dist/internal/server.d.ts.map +1 -1
  131. package/dist/internal/server.js +173 -42
  132. package/dist/internal/session-engine.d.ts +49 -0
  133. package/dist/internal/session-engine.d.ts.map +1 -1
  134. package/dist/internal/session-engine.js +246 -17
  135. package/dist/internal/storage-coordinator.d.ts +139 -0
  136. package/dist/internal/storage-coordinator.d.ts.map +1 -0
  137. package/dist/internal/storage-coordinator.js +499 -0
  138. package/dist/playground/assets/cursor-icons-outline-BxTT_FVJ.woff2 +0 -0
  139. package/dist/playground/assets/index-B1Qc9h2u.css +1 -0
  140. package/dist/playground/assets/{index-FlWjhg3x.js → index-CpDYCj8W.js} +42 -42
  141. package/dist/playground/index.html +2 -2
  142. package/dist/storage.d.ts +204 -0
  143. package/dist/storage.d.ts.map +1 -0
  144. package/dist/storage.js +153 -0
  145. package/dist/types.d.ts +44 -3
  146. package/dist/types.d.ts.map +1 -1
  147. package/docs/README.md +3 -2
  148. package/docs/guides/github.md +43 -8
  149. package/docs/guides/slack.md +1 -1
  150. package/docs/quickstart.md +329 -51
  151. package/docs/reference/instructions.md +8 -6
  152. package/docs/scaffolding-agents.md +1 -1
  153. package/docs/storage.md +98 -0
  154. package/package.json +8 -1
  155. package/skills/github/SKILL.md +9 -1
  156. package/src/bin/agent-serve.ts +139 -0
  157. package/src/channels/github/api.ts +42 -23
  158. package/src/channels/github/cursor-account.ts +165 -0
  159. package/src/channels/github/github-channel.ts +66 -6
  160. package/src/channels/github/index.ts +2 -2
  161. package/src/channels/github/types.ts +19 -0
  162. package/src/channels/slack/slack-channel.ts +17 -3
  163. package/src/channels/slack/types.ts +44 -3
  164. package/src/index.ts +13 -0
  165. package/src/internal/cli-deploy.ts +940 -0
  166. package/src/internal/cursor/backend-client.ts +103 -1
  167. package/src/internal/cursor/github-credentials.ts +248 -0
  168. package/src/internal/deploy-client.ts +591 -0
  169. package/src/internal/discovery.ts +88 -1
  170. package/src/internal/distribution.ts +1 -0
  171. package/src/internal/eval-run-store.ts +48 -19
  172. package/src/internal/handleAgentServeTrigger.ts +10 -12
  173. package/src/internal/host-platforms.ts +28 -11
  174. package/src/internal/hosting.ts +77 -0
  175. package/src/internal/reminder-runner.ts +50 -6
  176. package/src/internal/reminder-store.ts +21 -0
  177. package/src/internal/server.ts +213 -27
  178. package/src/internal/session-engine.ts +285 -7
  179. package/src/internal/storage-coordinator.ts +615 -0
  180. package/src/storage.ts +325 -0
  181. package/src/types.ts +40 -2
  182. package/dist/docs/assets/chunks/@localSearchIndexroot.CcVk1uKq.js +0 -1
  183. package/dist/docs/assets/index.md.BPKcj5AI.js +0 -20
  184. package/dist/docs/assets/quickstart.md.tVPiGK_L.js +0 -35
  185. package/dist/docs/assets/quickstart.md.tVPiGK_L.lean.js +0 -1
  186. package/dist/docs/assets/scaffolding-agents.md.CyYfWGdc.js +0 -1
  187. package/dist/docs/assets/scaffolding-agents.md.CyYfWGdc.lean.js +0 -1
  188. package/dist/playground/assets/cursor-icons-outline-oY2V_mvK.woff2 +0 -0
  189. package/dist/playground/assets/index-1K-hG-7p.css +0 -1
  190. /package/dist/docs/assets/{evals.md.DPZ_MAnI.lean.js → evals.md.DAgEc_hL.lean.js} +0 -0
  191. /package/dist/docs/assets/{guides_agent-to-agent.md.CrtrsySy.lean.js → guides_agent-to-agent.md.Bpzgq2Pq.lean.js} +0 -0
  192. /package/dist/docs/assets/{guides_cloud-runtime.md.CYlNMTNp.lean.js → guides_cloud-runtime.md.gVzabdQL.lean.js} +0 -0
  193. /package/dist/docs/assets/{guides_human-in-the-loop.md.Dvuctx7s.lean.js → guides_human-in-the-loop.md.DlUqsp1S.lean.js} +0 -0
  194. /package/dist/docs/assets/{guides_webhooks.md.hFTik3lf.lean.js → guides_webhooks.md.B1EswtUu.lean.js} +0 -0
  195. /package/dist/docs/assets/{reference_connections.md.C3vNH_DE.lean.js → reference_connections.md.zaEYCLHT.lean.js} +0 -0
  196. /package/dist/docs/assets/{reference_http-api.md.DBAahtdz.lean.js → reference_http-api.md.Dx_nmDG6.lean.js} +0 -0
  197. /package/dist/docs/assets/{reference_schedules.md.D7qijxLk.lean.js → reference_schedules.md.w_F2mXB6.lean.js} +0 -0
  198. /package/dist/docs/assets/{reference_subagents.md.CIRAVcPK.lean.js → reference_subagents.md.zWAMNfi1.lean.js} +0 -0
  199. /package/dist/docs/assets/{reference_tools.md.DF5kwlt0.lean.js → reference_tools.md.CqgJroI0.lean.js} +0 -0
@@ -7,8 +7,8 @@
7
7
  * event handlers and hooks.
8
8
  */
9
9
 
10
- import { mkdir, rm } from "node:fs/promises";
11
- import { join } from "node:path";
10
+ import { mkdir, rm, writeFile } from "node:fs/promises";
11
+ import { dirname, join } from "node:path";
12
12
  import type { SDKCustomTool } from "@cursor/sdk";
13
13
  import {
14
14
  type ABDefinition,
@@ -68,6 +68,7 @@ import { McpHost } from "./mcp-host.js";
68
68
  import { assertCloudCanHonorAccountServersFilters } from "./resolved-connections.js";
69
69
  import type { AgentRunner } from "./sdk-runner.js";
70
70
  import { SessionStore } from "./session-store.js";
71
+ import { StorageCoordinator } from "./storage-coordinator.js";
71
72
  import { normalizeToolResult, toolCallErrorMessage } from "./tool-result.js";
72
73
  import {
73
74
  buildAgentToolsCatalog,
@@ -157,6 +158,7 @@ export interface SessionEngineOptions {
157
158
  stateRoot: string;
158
159
  runner: AgentRunner;
159
160
  logger?: (line: string) => void;
161
+ platforms?: HostPlatforms;
160
162
  }
161
163
 
162
164
  /**
@@ -192,6 +194,21 @@ export class SessionEngine {
192
194
  readonly project: AgentProject;
193
195
  readonly stateRoot: string;
194
196
  readonly sessions: SessionStore;
197
+ /**
198
+ * Runtime for `agent/storage.ts` (`defineStorage`), when
199
+ * authored. All durable state changes funnel through it (session
200
+ * records, event chunks, A/B samples/snapshots); the eval run store
201
+ * attaches via {@link StorageCoordinator.asEvalRunPersistence} at
202
+ * serve start.
203
+ */
204
+ readonly storage: StorageCoordinator | undefined;
205
+ /** In-flight lazy restores, deduped per channel + continuation key. */
206
+ private readonly restoreInFlight = new Map<
207
+ string,
208
+ Promise<SessionRecord | undefined>
209
+ >();
210
+ /** Once-per-process cache for the A/B snapshot backfill (see abSnapshot). */
211
+ private restoredAbSnapshot: Promise<ABSnapshot | undefined> | undefined;
195
212
 
196
213
  private readonly runner: AgentRunner;
197
214
  private readonly logger: (line: string) => void;
@@ -229,15 +246,26 @@ export class SessionEngine {
229
246
  `[agent-serve] failed to persist events for ${sessionId}: ${describeError(error)}`
230
247
  )
231
248
  );
249
+ this.storage =
250
+ options.project.storage === undefined
251
+ ? undefined
252
+ : new StorageCoordinator({
253
+ definition: options.project.storage,
254
+ agentName: options.project.name,
255
+ projectRoot: options.project.rootDir,
256
+ logger: this.logger,
257
+ });
258
+ // Domain-specific hooks win; the defineStorage sink is the fallback.
259
+ const persistSamples =
260
+ options.project.abConfig?.persistSamples ??
261
+ this.storage?.asABSamplePersistence();
232
262
  this.abCollector = new ABCollector(
233
263
  options.project.abs,
234
264
  this.logger,
235
265
  async (sessionId) => (await this.logs.get(sessionId)).snapshot(0),
236
266
  {
237
267
  projectRoot: options.project.rootDir,
238
- ...(options.project.abConfig?.persistSamples === undefined
239
- ? {}
240
- : { persistSamples: options.project.abConfig.persistSamples }),
268
+ persistSamples,
241
269
  }
242
270
  );
243
271
  // Peer and Cursor-account transports are symbolic until the serve host
@@ -248,7 +276,7 @@ export class SessionEngine {
248
276
  (connection) => !isSymbolicConnectionTransport(connection.transport)
249
277
  )
250
278
  );
251
- this.platforms = createHostPlatforms();
279
+ this.platforms = options.platforms ?? createHostPlatforms();
252
280
  for (const channel of options.project.channels) {
253
281
  this.channelsById.set(channel.id, channel.definition);
254
282
  }
@@ -305,13 +333,21 @@ export class SessionEngine {
305
333
  options: EngineSendOptions = {}
306
334
  ): Promise<ChannelSession> {
307
335
  const auth = options.auth ?? null;
308
- const existing =
336
+ let existing =
309
337
  options.continuationToken === undefined
310
338
  ? undefined
311
339
  : await this.sessions.findByContinuation(
312
340
  channelId,
313
341
  options.continuationToken
314
342
  );
343
+ if (existing === undefined && options.continuationToken !== undefined) {
344
+ // Local miss: the session may live only in the storage sink (it
345
+ // fell outside the startup restore window, or restore is lazy-only).
346
+ existing = await this.restoreSessionByContinuation(
347
+ channelId,
348
+ options.continuationToken
349
+ );
350
+ }
315
351
 
316
352
  if (existing !== undefined) {
317
353
  if (!samePrincipal(existing.auth, auth)) {
@@ -361,6 +397,7 @@ export class SessionEngine {
361
397
  updatedAt: now,
362
398
  };
363
399
  await this.sessions.save(record);
400
+ this.storage?.sessionRecord(record);
364
401
  await this.appendEvent(record.sessionId, {
365
402
  type: "session.started",
366
403
  data: { channelId },
@@ -1337,6 +1374,9 @@ export class SessionEngine {
1337
1374
  }
1338
1375
 
1339
1376
  private queueDispatch(sessionId: string, event: SessionEvent): void {
1377
+ // Single funnel for both append paths (appendEvent + the held-log emit),
1378
+ // so the storage sink sees every durable event exactly once.
1379
+ this.storage?.event(event);
1340
1380
  const chain = this.dispatchChains.get(sessionId) ?? Promise.resolve();
1341
1381
  const next = chain
1342
1382
  .then(() => this.dispatchEvent(sessionId, event))
@@ -1344,6 +1384,12 @@ export class SessionEngine {
1344
1384
  this.logger(
1345
1385
  `[agent-serve] event dispatch failed for ${sessionId}: ${describeError(error)}`
1346
1386
  );
1387
+ })
1388
+ .then(() => {
1389
+ // Turn-end flush happens here — after the boundary event's handlers
1390
+ // and their record updates settled — so the persisted record carries
1391
+ // the turn's final channel state and continuation token.
1392
+ this.storage?.eventDispatched(event);
1347
1393
  });
1348
1394
  this.dispatchChains.set(sessionId, next);
1349
1395
  }
@@ -1508,6 +1554,25 @@ export class SessionEngine {
1508
1554
  abConfig?.maxPlaygroundSessions
1509
1555
  );
1510
1556
  const foldedSessions = sessions.slice(0, maxPlaygroundSessions);
1557
+ // Backfill for a replacement host with nothing local to fold (e.g.
1558
+ // lazy-only restore before any session resumed): serve the persisted
1559
+ // aggregate instead of an empty surface. Gated on the host having no
1560
+ // sessions AT ALL — the persisted snapshot spans every principal, so a
1561
+ // caller who merely owns no sessions must not see it. Loaded once per
1562
+ // process (playground polls this route ~every 4s); skip the persist
1563
+ // side effect below — re-saving a restored snapshot would echo stale
1564
+ // data back into the store.
1565
+ if (
1566
+ foldedSessions.length === 0 &&
1567
+ this.storage !== undefined &&
1568
+ (await this.sessions.list()).length === 0
1569
+ ) {
1570
+ this.restoredAbSnapshot ??= this.storage.getLatestAbSnapshot();
1571
+ const restoredSnapshot = await this.restoredAbSnapshot;
1572
+ if (restoredSnapshot !== undefined) {
1573
+ return restoredSnapshot;
1574
+ }
1575
+ }
1511
1576
  const snapshot = await buildABSnapshot({
1512
1577
  experiments: this.project.abs,
1513
1578
  agentName: this.project.name,
@@ -1535,6 +1600,9 @@ export class SessionEngine {
1535
1600
  `[agent-serve] ab persistSnapshots.save threw: ${describeError(error)}`
1536
1601
  );
1537
1602
  }
1603
+ } else {
1604
+ // defineStorage fallback (throttled inside the coordinator).
1605
+ this.storage?.abSnapshot(snapshot);
1538
1606
  }
1539
1607
  return snapshot;
1540
1608
  }
@@ -1700,6 +1768,7 @@ export class SessionEngine {
1700
1768
  updatedAt: new Date().toISOString(),
1701
1769
  };
1702
1770
  await this.sessions.save(updated);
1771
+ this.storage?.sessionRecord(updated);
1703
1772
  return updated;
1704
1773
  });
1705
1774
  this.updateChains.set(
@@ -1713,6 +1782,203 @@ export class SessionEngine {
1713
1782
  // Lifecycle
1714
1783
  // ==========================================================================
1715
1784
 
1785
+ /**
1786
+ * Restore sessions from the authored storage sink. Called once at
1787
+ * serve start, before channels accept traffic: any persisted session
1788
+ * missing from the local `--state-root` is materialized there — events
1789
+ * as `events.ndjson`, record as `session.json` — after which every
1790
+ * existing read path (continuation lookup, event replay/streams, A/B
1791
+ * fold hydration) works unchanged. Local state always wins over the
1792
+ * sink, so restarts on the same host are no-ops.
1793
+ *
1794
+ * Failures are logged and skipped; a broken store (including rows that
1795
+ * violate the SessionRecord shape) never blocks serve start.
1796
+ */
1797
+ async restoreStoredSessions(): Promise<void> {
1798
+ const storage = this.storage;
1799
+ if (storage === undefined || !storage.canBulkRestore) {
1800
+ return;
1801
+ }
1802
+ const policy = storage.policy.restore;
1803
+ if (policy === "off") {
1804
+ return;
1805
+ }
1806
+ try {
1807
+ await this.runBulkRestore(storage, policy);
1808
+ } catch (error) {
1809
+ this.logger(
1810
+ `[agent-serve] session restore failed: ${describeError(error)}`
1811
+ );
1812
+ }
1813
+ }
1814
+
1815
+ private async runBulkRestore(
1816
+ storage: StorageCoordinator,
1817
+ policy: { maxSessions: number; maxAgeMs: number; maxTotalBytes: number }
1818
+ ): Promise<void> {
1819
+ const loaded = await storage.listSessions();
1820
+ // Guardrails on whatever the sink returned: drop invalid rows and
1821
+ // sessions older than the age window, then restore newest-first under
1822
+ // the count cap. Anything filtered out here stays reachable through
1823
+ // the lazy continuation-lookup path.
1824
+ const cutoff = Date.now() - policy.maxAgeMs;
1825
+ const candidates = loaded
1826
+ .map((record) => ({
1827
+ record,
1828
+ // Numeric timestamp for both the age filter and the sort; a
1829
+ // non-string updatedAt (e.g. a raw DB Date from an untyped sink)
1830
+ // parses to NaN and is dropped instead of crashing serve start.
1831
+ updatedAt:
1832
+ typeof record?.updatedAt === "string"
1833
+ ? Date.parse(record.updatedAt)
1834
+ : Number.NaN,
1835
+ }))
1836
+ .filter(
1837
+ ({ record, updatedAt }) =>
1838
+ typeof record?.sessionId === "string" &&
1839
+ record.sessionId !== "" &&
1840
+ updatedAt >= cutoff
1841
+ )
1842
+ .sort((a, b) => b.updatedAt - a.updatedAt)
1843
+ .slice(0, policy.maxSessions)
1844
+ .map(({ record }) => record);
1845
+
1846
+ let restored = 0;
1847
+ let budget = policy.maxTotalBytes;
1848
+ // Sink round trips dominate restore latency, so event streams are
1849
+ // fetched with bounded parallelism; writes stay sequential, newest
1850
+ // first, so the byte budget is enforced deterministically.
1851
+ outer: for (
1852
+ let i = 0;
1853
+ i < candidates.length;
1854
+ i += RESTORE_FETCH_CONCURRENCY
1855
+ ) {
1856
+ const chunk = candidates.slice(i, i + RESTORE_FETCH_CONCURRENCY);
1857
+ const fetched = await Promise.all(
1858
+ chunk.map(async (record) => {
1859
+ try {
1860
+ // Local state always wins over the sink.
1861
+ if ((await this.sessions.get(record.sessionId)) !== undefined) {
1862
+ return undefined;
1863
+ }
1864
+ const events = await storage.listSessionEvents(record.sessionId);
1865
+ return { record, events };
1866
+ } catch (error) {
1867
+ this.logger(
1868
+ `[agent-serve] failed to restore session ${record.sessionId}: ${describeError(error)}`
1869
+ );
1870
+ return undefined;
1871
+ }
1872
+ })
1873
+ );
1874
+ for (const item of fetched) {
1875
+ if (item === undefined) {
1876
+ continue;
1877
+ }
1878
+ const ndjson = eventsNdjson(item.events);
1879
+ // Budget is checked BEFORE anything is written: one oversized
1880
+ // session cannot blow past maxTotalBytes on disk. Skipped sessions
1881
+ // stay reachable through the lazy path.
1882
+ const bytes =
1883
+ Buffer.byteLength(JSON.stringify(item.record), "utf8") +
1884
+ (ndjson === undefined ? 0 : Buffer.byteLength(ndjson, "utf8"));
1885
+ if (bytes > budget) {
1886
+ this.logger(
1887
+ `[agent-serve] session restore stopped at the ${policy.maxTotalBytes}-byte budget after ${restored} session(s); remaining sessions restore lazily on resume`
1888
+ );
1889
+ break outer;
1890
+ }
1891
+ try {
1892
+ await this.materializeSession(item.record, ndjson);
1893
+ budget -= bytes;
1894
+ restored += 1;
1895
+ } catch (error) {
1896
+ this.logger(
1897
+ `[agent-serve] failed to restore session ${item.record.sessionId}: ${describeError(error)}`
1898
+ );
1899
+ }
1900
+ }
1901
+ }
1902
+ if (restored > 0) {
1903
+ this.logger(`[agent-serve] restored ${restored} session(s) from storage`);
1904
+ }
1905
+ }
1906
+
1907
+ /**
1908
+ * Lazy restore: a continuation lookup missed locally, so ask the sink
1909
+ * for that one session and materialize it before resuming. Disk grows
1910
+ * with sessions that are actually resumed, not with history.
1911
+ *
1912
+ * Concurrent misses for the same key (webhook redeliveries, retries)
1913
+ * share one in-flight restore — a second materialization racing the
1914
+ * first resumed turn could truncate events the turn already appended.
1915
+ * Sink errors propagate and fail the send (see
1916
+ * {@link StorageCoordinator.getSessionByContinuation}).
1917
+ */
1918
+ private restoreSessionByContinuation(
1919
+ channelId: string,
1920
+ continuationKey: string
1921
+ ): Promise<SessionRecord | undefined> {
1922
+ const storage = this.storage;
1923
+ if (storage === undefined) {
1924
+ return Promise.resolve(undefined);
1925
+ }
1926
+ const key = `${channelId}\u0000${continuationKey}`;
1927
+ let pending = this.restoreInFlight.get(key);
1928
+ if (pending === undefined) {
1929
+ pending = this.loadAndMaterializeByContinuation(
1930
+ storage,
1931
+ channelId,
1932
+ continuationKey
1933
+ ).finally(() => this.restoreInFlight.delete(key));
1934
+ this.restoreInFlight.set(key, pending);
1935
+ }
1936
+ return pending;
1937
+ }
1938
+
1939
+ private async loadAndMaterializeByContinuation(
1940
+ storage: StorageCoordinator,
1941
+ channelId: string,
1942
+ continuationKey: string
1943
+ ): Promise<SessionRecord | undefined> {
1944
+ const record = await storage.getSessionByContinuation(
1945
+ channelId,
1946
+ continuationKey
1947
+ );
1948
+ if (record === undefined) {
1949
+ // Definitive miss: the caller starts a fresh session under this token.
1950
+ return undefined;
1951
+ }
1952
+ const local = await this.sessions.get(record.sessionId);
1953
+ if (local !== undefined) {
1954
+ return local;
1955
+ }
1956
+ const events = await storage.listSessionEvents(record.sessionId);
1957
+ await this.materializeSession(record, eventsNdjson(events));
1958
+ this.logger(
1959
+ `[agent-serve] lazily restored session ${record.sessionId} from storage`
1960
+ );
1961
+ return record;
1962
+ }
1963
+
1964
+ /**
1965
+ * Write one restored session to the local state root. Events land on
1966
+ * disk before the record: a session becomes visible (and addressable via
1967
+ * its continuation key) only with its full stream in place, and the
1968
+ * lazily opened event log hydrates from this file.
1969
+ */
1970
+ private async materializeSession(
1971
+ record: SessionRecord,
1972
+ eventsNdjson: string | undefined
1973
+ ): Promise<void> {
1974
+ if (eventsNdjson !== undefined) {
1975
+ const filePath = this.sessions.eventFilePath(record.sessionId);
1976
+ await mkdir(dirname(filePath), { recursive: true });
1977
+ await writeFile(filePath, eventsNdjson, "utf8");
1978
+ }
1979
+ await this.sessions.save(record);
1980
+ }
1981
+
1716
1982
  trackBackground(promise: Promise<unknown>): void {
1717
1983
  this.backgroundWork.add(promise);
1718
1984
  void promise
@@ -1730,11 +1996,23 @@ export class SessionEngine {
1730
1996
  await Promise.allSettled([...this.dispatchChains.values()]);
1731
1997
  await Promise.allSettled([...this.updateChains.values()]);
1732
1998
  await this.logs.flushAll();
1999
+ await this.storage?.close();
1733
2000
  await this.mcpHost.close();
1734
2001
  await this.runner.dispose?.();
1735
2002
  }
1736
2003
  }
1737
2004
 
2005
+ /** Parallel event-stream loads during the startup bulk restore. */
2006
+ const RESTORE_FETCH_CONCURRENCY = 8;
2007
+
2008
+ /** NDJSON payload for a restored event stream; undefined when empty. */
2009
+ function eventsNdjson(events: SessionEvent[]): string | undefined {
2010
+ if (events.length === 0) {
2011
+ return undefined;
2012
+ }
2013
+ return `${events.map((event) => JSON.stringify(event)).join("\n")}\n`;
2014
+ }
2015
+
1738
2016
  function subagentPrompt(subagent: ResolvedAgent): string {
1739
2017
  if (subagent.instructions !== undefined && subagent.instructions !== "") {
1740
2018
  return subagent.instructions;