@cursor/july 0.1.45 → 0.1.47

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 (149) hide show
  1. package/AGENTS.md +4 -2
  2. package/README.md +3 -1
  3. package/dist/channels/github/api.d.ts +25 -0
  4. package/dist/channels/github/api.d.ts.map +1 -1
  5. package/dist/channels/github/api.js +48 -0
  6. package/dist/channels/github/cursor-account.d.ts +7 -0
  7. package/dist/channels/github/cursor-account.d.ts.map +1 -1
  8. package/dist/channels/github/cursor-account.js +18 -0
  9. package/dist/channels/github/defaults.d.ts +1 -1
  10. package/dist/channels/github/defaults.js +1 -1
  11. package/dist/channels/github/github-channel.d.ts.map +1 -1
  12. package/dist/channels/github/github-channel.js +2 -2
  13. package/dist/channels/github/progress.d.ts +6 -5
  14. package/dist/channels/github/progress.d.ts.map +1 -1
  15. package/dist/channels/github/progress.js +58 -16
  16. package/dist/channels/github/state.d.ts +11 -0
  17. package/dist/channels/github/state.d.ts.map +1 -1
  18. package/dist/channels/github/state.js +1 -1
  19. package/dist/channels/github/types.d.ts +3 -3
  20. package/dist/docs/404.html +2 -2
  21. package/dist/docs/ab.html +3 -3
  22. package/dist/docs/assets/{app.fHnNXEJ6.js → app.IEXEim5m.js} +1 -1
  23. package/dist/docs/assets/chunks/@localSearchIndexroot.D7w61u6Y.js +1 -0
  24. package/dist/docs/assets/chunks/{VPLocalSearchBox.Cf6ghfFY.js → VPLocalSearchBox.78s6nqsH.js} +1 -1
  25. package/dist/docs/assets/chunks/{theme.B_5-VLe8.js → theme.DroGdz_Q.js} +2 -2
  26. package/dist/docs/assets/{guides_github.md.DSg0j9qj.js → guides_github.md.B8QKtIpw.js} +7 -6
  27. package/dist/docs/assets/{guides_github.md.DSg0j9qj.lean.js → guides_github.md.B8QKtIpw.lean.js} +1 -1
  28. package/dist/docs/assets/{reference_agent-config.md.XRpxctsA.js → reference_agent-config.md.DHkkvcEZ.js} +4 -4
  29. package/dist/docs/assets/{reference_agent-config.md.XRpxctsA.lean.js → reference_agent-config.md.DHkkvcEZ.lean.js} +1 -1
  30. package/dist/docs/assets/{reference_hooks.md.4cbRRIIu.js → reference_hooks.md.DJE5DXcT.js} +1 -1
  31. package/dist/docs/assets/{storage.md.DcGSnIxX.js → storage.md.CJYd5YmN.js} +2 -2
  32. package/dist/docs/assets/{storage.md.DcGSnIxX.lean.js → storage.md.CJYd5YmN.lean.js} +1 -1
  33. package/dist/docs/building-with-agents.html +3 -3
  34. package/dist/docs/concepts.html +3 -3
  35. package/dist/docs/deployment.html +3 -3
  36. package/dist/docs/evals.html +3 -3
  37. package/dist/docs/example-agents/approval-buddy.html +3 -3
  38. package/dist/docs/example-agents/benny.html +3 -3
  39. package/dist/docs/example-agents/bugbot.html +3 -3
  40. package/dist/docs/example-agents/codebase-wiki.html +3 -3
  41. package/dist/docs/example-agents/codeowners-review.html +3 -3
  42. package/dist/docs/example-agents/concierge.html +3 -3
  43. package/dist/docs/example-agents/fsd.html +3 -3
  44. package/dist/docs/example-agents/index.html +3 -3
  45. package/dist/docs/example-agents/knowledge-base.html +3 -3
  46. package/dist/docs/example-agents/oncall.html +3 -3
  47. package/dist/docs/example-agents/security-reviewer.html +3 -3
  48. package/dist/docs/example-agents/slack-agent.html +3 -3
  49. package/dist/docs/example-agents/weather-agent.html +3 -3
  50. package/dist/docs/guides/agent-to-agent.html +3 -3
  51. package/dist/docs/guides/cloud-runtime.html +3 -3
  52. package/dist/docs/guides/github.html +10 -9
  53. package/dist/docs/guides/human-in-the-loop.html +3 -3
  54. package/dist/docs/guides/mcp-oauth.html +3 -3
  55. package/dist/docs/guides/opentelemetry.html +3 -3
  56. package/dist/docs/guides/slack.html +3 -3
  57. package/dist/docs/guides/webhooks.html +3 -3
  58. package/dist/docs/hashmap.json +1 -1
  59. package/dist/docs/hillclimbing.html +3 -3
  60. package/dist/docs/index.html +3 -3
  61. package/dist/docs/quickstart.html +3 -3
  62. package/dist/docs/reference/agent-config.html +6 -6
  63. package/dist/docs/reference/artifacts.html +3 -3
  64. package/dist/docs/reference/channels.html +3 -3
  65. package/dist/docs/reference/cli.html +3 -3
  66. package/dist/docs/reference/connections.html +3 -3
  67. package/dist/docs/reference/hooks.html +5 -5
  68. package/dist/docs/reference/http-api.html +3 -3
  69. package/dist/docs/reference/instructions.html +3 -3
  70. package/dist/docs/reference/playground.html +3 -3
  71. package/dist/docs/reference/project-layout.html +3 -3
  72. package/dist/docs/reference/prompt.html +3 -3
  73. package/dist/docs/reference/schedules.html +3 -3
  74. package/dist/docs/reference/sessions.html +3 -3
  75. package/dist/docs/reference/skills.html +3 -3
  76. package/dist/docs/reference/subagents.html +3 -3
  77. package/dist/docs/reference/tools.html +3 -3
  78. package/dist/docs/scaffolding-agents.html +3 -3
  79. package/dist/docs/storage.html +5 -5
  80. package/dist/docs/templates/triage.html +3 -3
  81. package/dist/docs/troubleshooting.html +3 -3
  82. package/dist/index.d.ts +1 -1
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +1 -1
  85. package/dist/internal/auth.d.ts +4 -0
  86. package/dist/internal/auth.d.ts.map +1 -1
  87. package/dist/internal/auth.js +7 -0
  88. package/dist/internal/durable-session-store.d.ts +35 -23
  89. package/dist/internal/durable-session-store.d.ts.map +1 -1
  90. package/dist/internal/durable-session-store.js +71 -26
  91. package/dist/internal/ledger-serving.d.ts +135 -67
  92. package/dist/internal/ledger-serving.d.ts.map +1 -1
  93. package/dist/internal/ledger-serving.js +326 -107
  94. package/dist/internal/sdk-runner.d.ts +21 -0
  95. package/dist/internal/sdk-runner.d.ts.map +1 -1
  96. package/dist/internal/sdk-runner.js +34 -1
  97. package/dist/internal/server.d.ts.map +1 -1
  98. package/dist/internal/server.js +9 -4
  99. package/dist/internal/session-engine.d.ts +18 -17
  100. package/dist/internal/session-engine.d.ts.map +1 -1
  101. package/dist/internal/session-engine.js +60 -27
  102. package/dist/kv/memory-kv.d.ts +8 -0
  103. package/dist/kv/memory-kv.d.ts.map +1 -1
  104. package/dist/kv/memory-kv.js +8 -0
  105. package/dist/ledger/janitor.d.ts +60 -0
  106. package/dist/ledger/janitor.d.ts.map +1 -0
  107. package/dist/ledger/janitor.js +137 -0
  108. package/dist/ledger/session-ledger.d.ts +132 -43
  109. package/dist/ledger/session-ledger.d.ts.map +1 -1
  110. package/dist/ledger/session-ledger.js +282 -50
  111. package/dist/ledger.d.ts +9 -8
  112. package/dist/ledger.d.ts.map +1 -1
  113. package/dist/ledger.js +7 -7
  114. package/dist/playground/assets/{index-Dces8BDp.js → index-BgUHfe3L.js} +32 -32
  115. package/dist/playground/assets/index-uFaeqVZS.css +1 -0
  116. package/dist/playground/index.html +2 -2
  117. package/dist/storage-backends/cursor-hosted.d.ts +5 -0
  118. package/dist/storage-backends/cursor-hosted.d.ts.map +1 -1
  119. package/dist/storage-backends/cursor-hosted.js +6 -0
  120. package/dist/types.d.ts +19 -8
  121. package/dist/types.d.ts.map +1 -1
  122. package/docs/guides/github.md +23 -18
  123. package/docs/reference/agent-config.md +31 -36
  124. package/docs/reference/hooks.md +1 -1
  125. package/docs/storage.md +26 -9
  126. package/package.json +1 -1
  127. package/src/channels/github/api.ts +122 -0
  128. package/src/channels/github/cursor-account.ts +23 -0
  129. package/src/channels/github/defaults.ts +1 -1
  130. package/src/channels/github/github-channel.ts +5 -1
  131. package/src/channels/github/progress.ts +84 -19
  132. package/src/channels/github/state.ts +14 -0
  133. package/src/channels/github/types.ts +3 -3
  134. package/src/index.ts +1 -0
  135. package/src/internal/auth.ts +10 -0
  136. package/src/internal/durable-session-store.ts +66 -27
  137. package/src/internal/ledger-serving.ts +439 -148
  138. package/src/internal/sdk-runner.ts +46 -1
  139. package/src/internal/server.ts +17 -3
  140. package/src/internal/session-engine.ts +95 -37
  141. package/src/kv/memory-kv.ts +8 -0
  142. package/src/ledger/janitor.ts +193 -0
  143. package/src/ledger/session-ledger.ts +372 -68
  144. package/src/ledger.ts +21 -6
  145. package/src/storage-backends/cursor-hosted.ts +17 -2
  146. package/src/types.ts +19 -8
  147. package/dist/docs/assets/chunks/@localSearchIndexroot.BMT0xM4I.js +0 -1
  148. package/dist/playground/assets/index-CZH9EFvO.css +0 -1
  149. /package/dist/docs/assets/{reference_hooks.md.4cbRRIIu.lean.js → reference_hooks.md.DJE5DXcT.lean.js} +0 -0
@@ -12,6 +12,7 @@ import {
12
12
  type AgentDefinition,
13
13
  type AgentOptions,
14
14
  type CloudAgentOptions,
15
+ Cursor,
15
16
  createAgentPlatform,
16
17
  JsonlLocalAgentStore,
17
18
  type LocalAgentStore,
@@ -75,6 +76,36 @@ type MaybePrewarmingPlatform = {
75
76
  ) => Promise<() => Promise<void>>;
76
77
  };
77
78
 
79
+ /**
80
+ * How long a non-`--dev` serve holds workspace scans (rules, skills,
81
+ * `AGENTS.md`, ignore files). The SDK default is 20s for an editor whose
82
+ * files move under it; agent-serve checkouts only change on deploy, so a
83
+ * missed mid-process edit is the right trade. `CURSOR_RIPWALK_CACHE_TTL_MS`
84
+ * still wins when set. `--dev` leaves the 20s default so skill edits show up.
85
+ */
86
+ export const SERVE_WORKSPACE_SCAN_CACHE_TTL_MS: number = 24 * 60 * 60 * 1000;
87
+
88
+ /**
89
+ * Resolve the SDK workspace-scan TTL for this serve. `undefined` means "do
90
+ * not call `Cursor.configure`" and leave the SDK at its 20s editor default.
91
+ */
92
+ export function resolveServeWorkspaceScanCacheTtlMs(args: {
93
+ dev: boolean;
94
+ env?: NodeJS.ProcessEnv;
95
+ }): number | undefined {
96
+ const raw = (args.env ?? process.env).CURSOR_RIPWALK_CACHE_TTL_MS?.trim();
97
+ if (raw !== undefined && raw !== "") {
98
+ const parsed = Number(raw);
99
+ if (Number.isFinite(parsed) && parsed > 0) {
100
+ return parsed;
101
+ }
102
+ }
103
+ if (args.dev) {
104
+ return undefined;
105
+ }
106
+ return SERVE_WORKSPACE_SCAN_CACHE_TTL_MS;
107
+ }
108
+
78
109
  export interface CursorSdkRunnerOptions {
79
110
  /** Cursor API key. Defaults to the `CURSOR_API_KEY` environment variable. */
80
111
  apiKey?: string;
@@ -86,6 +117,11 @@ export interface CursorSdkRunnerOptions {
86
117
  store?: LocalAgentStore;
87
118
  /** Diagnostics for degraded paths (resume-miss fallback). */
88
119
  logger?: (line: string) => void;
120
+ /**
121
+ * Hold workspace scans this long (ms). Applied via `Cursor.configure` so
122
+ * each new local executor reuses the walk. Omit to keep the SDK 20s default.
123
+ */
124
+ workspaceScanCacheTtlMs?: number;
89
125
  }
90
126
 
91
127
  /**
@@ -128,7 +164,16 @@ export class CursorSdkRunner implements AgentRunner {
128
164
  private readonly stores = new Map<string, Promise<LocalAgentStore>>();
129
165
  private readonly prewarmReleases: Array<() => Promise<void>> = [];
130
166
 
131
- constructor(private readonly options: CursorSdkRunnerOptions = {}) {}
167
+ constructor(private readonly options: CursorSdkRunnerOptions = {}) {
168
+ if (options.workspaceScanCacheTtlMs !== undefined) {
169
+ Cursor.configure({
170
+ local: { workspaceScanCacheTtlMs: options.workspaceScanCacheTtlMs },
171
+ });
172
+ this.log(
173
+ `[agent-sdk] workspace scan cache ttl=${options.workspaceScanCacheTtlMs}ms`
174
+ );
175
+ }
176
+ }
132
177
 
133
178
  async runTurn(request: RunnerTurnRequest): Promise<TurnOutcome> {
134
179
  let sawUpdate = false;
@@ -17,6 +17,7 @@ import { join } from "node:path";
17
17
  import {
18
18
  type CursorGithubRepoScope,
19
19
  createCursorAccountGithubTokenResolver,
20
+ cursorGithubPermissionsForChannel,
20
21
  type GithubCursorAccountRuntime,
21
22
  getGithubCursorAccountRuntime,
22
23
  parseCursorGithubRepoScope,
@@ -146,7 +147,11 @@ import {
146
147
  } from "./resolved-connections.js";
147
148
  import { Router } from "./router.js";
148
149
  import { ScheduleRunner, UnknownScheduleError } from "./schedule-runner.js";
149
- import { type AgentRunner, CursorSdkRunner } from "./sdk-runner.js";
150
+ import {
151
+ type AgentRunner,
152
+ CursorSdkRunner,
153
+ resolveServeWorkspaceScanCacheTtlMs,
154
+ } from "./sdk-runner.js";
150
155
  import {
151
156
  SessionAuthError,
152
157
  SessionBusyError,
@@ -363,11 +368,17 @@ export async function startServer(
363
368
  "[agent-sdk] warning: --allow-anonymous admits every HTTP caller as the same principal; use only on a trusted network (e.g. Tailscale) and prefer --bearer-token for shared hosts"
364
369
  );
365
370
  }
371
+ const workspaceScanCacheTtlMs = resolveServeWorkspaceScanCacheTtlMs({
372
+ dev,
373
+ });
366
374
  const runner =
367
375
  options.runner ??
368
376
  new CursorSdkRunner({
369
377
  ...(accountKey === undefined ? {} : { apiKey: accountKey.apiKey }),
370
378
  logger,
379
+ ...(workspaceScanCacheTtlMs === undefined
380
+ ? {}
381
+ : { workspaceScanCacheTtlMs }),
371
382
  });
372
383
 
373
384
  const router = new Router();
@@ -1869,8 +1880,11 @@ function resolveCursorGithubEventConfig(input: {
1869
1880
  ]),
1870
1881
  ];
1871
1882
  const permissions = resolveCursorGithubPermissions(
1872
- input.targets.map(
1873
- (target) => target.accountRuntime.scope?.permissions ?? "pr-write"
1883
+ input.targets.map((target) =>
1884
+ cursorGithubPermissionsForChannel(
1885
+ target.accountRuntime.options,
1886
+ target.accountRuntime.scope?.permissions
1887
+ )
1874
1888
  )
1875
1889
  );
1876
1890
  const scope = parseCursorGithubRepoScope(repos, permissions);
@@ -76,7 +76,7 @@ import { foldABStream } from "./ab-fold.js";
76
76
  import { type ABSnapshot, buildABSnapshot } from "./ab-snapshot.js";
77
77
  import { ApprovalGate } from "./approval-gate.js";
78
78
  import { ArtifactsStore } from "./artifacts-store.js";
79
- import { samePrincipal } from "./auth.js";
79
+ import { SessionAuthError, samePrincipal } from "./auth.js";
80
80
  import { mergeCloudOptions, resolveSessionRuntime } from "./cloud-merge.js";
81
81
  import {
82
82
  applyCloudUsageToEvents,
@@ -149,14 +149,9 @@ export class SessionBusyError extends Error {
149
149
  }
150
150
  }
151
151
 
152
- export class SessionAuthError extends Error {
153
- constructor(sessionId: string) {
154
- super(
155
- `Session ${sessionId} belongs to a different principal; pass the original caller auth on follow-up`
156
- );
157
- this.name = "SessionAuthError";
158
- }
159
- }
152
+ // Defined in auth.js (the ledger pump needs it without importing this
153
+ // module); re-exported here because this is its historical public home.
154
+ export { SessionAuthError } from "./auth.js";
160
155
 
161
156
  export class UnknownChannelError extends Error {
162
157
  constructor(channelId: string) {
@@ -234,10 +229,11 @@ export interface SessionEngineOptions {
234
229
  /**
235
230
  * Explicit durable-session store for `serving: "ledger"` agents — a KV
236
231
  * dedicated to the ledger (session identity, leases, commit heads),
237
- * separate from agent state. Must implement the ledger's CAS methods
238
- * (`getWithVersion` / `putIfAbsent` / `putIfVersion`); construction
239
- * fails fast otherwise. Supply the **raw** store: the engine owns key
240
- * scoping and namespaces every ledger key under
232
+ * separate from agent state. Must implement the full CAS-capable KV
233
+ * surface (`HostKvCasApi`: the CAS trio plus `get`/`put`/`delete` for
234
+ * mailbox payload blobs and `listKeys` for the janitor scan);
235
+ * construction fails fast otherwise. Supply the **raw** store: the
236
+ * engine owns key scoping and namespaces every ledger key under
241
237
  * `agentkit/v1/<agent>/durable-session/` itself — a self-hoster never
242
238
  * writes prefixes. Default when omitted: the authored storage backend
243
239
  * (`agent/storage.ts`) when it implements the `cas` group, else — with
@@ -286,12 +282,13 @@ interface ActiveToolContext {
286
282
  export interface EngineSendOptions extends SendMessageOptions {
287
283
  mode?: "chat" | "task";
288
284
  /**
289
- * Internal (ledger serving): this send *is* a lease-holding turn
290
- * delivery, so it must run the resident path. Without the flag, every
291
- * send on a `serving: "ledger"` agent routes through the ledger
292
- * (claim turn commit) and the pump would recurse into itself.
285
+ * Internal (ledger serving): set only by the ledger pump when it
286
+ * executes an already-parked event under its lease, so the call must
287
+ * run the resident path. Without the flag, every send on a
288
+ * `serving: "ledger"` agent routes through the ledger (append claim
289
+ * → drain → turn → commit) and the pump would recurse into itself.
293
290
  */
294
- ledgerDelivery?: boolean;
291
+ fromLedgerPump?: boolean;
295
292
  }
296
293
 
297
294
  interface CoalescedQueuedTurn {
@@ -482,11 +479,12 @@ export class SessionEngine {
482
479
  this.channelsById.set(channel.id, channel.definition);
483
480
  }
484
481
  this.ledgerServing = this.buildLedgerServing(options);
482
+ this.ledgerServing?.start();
485
483
  }
486
484
 
487
485
  /**
488
486
  * Ledger-mode wiring (`serving: "ledger"`): resolve a CAS-capable
489
- * durable-session store and build the delivery seam. Resident agents
487
+ * durable-session store and build the event pump. Resident agents
490
488
  * (the default) return undefined and never touch ledger keys.
491
489
  */
492
490
  private buildLedgerServing(
@@ -495,15 +493,46 @@ export class SessionEngine {
495
493
  if (options.project.agent.serving !== "ledger") {
496
494
  return undefined;
497
495
  }
496
+ // The handle is already agent-scoped (resolveDurableSessionStore):
497
+ // two ledger-mode agents sharing one backend never resolve to each
498
+ // other's sessions or janitor-claim each other's parked envelopes.
498
499
  const ledgerKv = this.resolveDurableSessionStore(options);
499
500
  return new LedgerServing({
500
501
  ledger: new SessionLedger({ kv: ledgerKv, podId: newLedgerPodId() }),
501
- deliverTurn: (request) =>
502
- this.send(request.channelId, request.message, {
503
- ...request.options,
504
- continuationToken: request.continuationToken,
505
- ledgerDelivery: true,
502
+ deliverTurn: (envelope) =>
503
+ this.send(envelope.channelId, envelope.message, {
504
+ continuationToken: envelope.continuationToken,
505
+ auth: envelope.auth,
506
+ ...(envelope.title === undefined ? {} : { title: envelope.title }),
507
+ ...(envelope.state === undefined ? {} : { state: envelope.state }),
508
+ ...(envelope.mode === undefined ? {} : { mode: envelope.mode }),
509
+ ...(envelope.purpose === undefined
510
+ ? {}
511
+ : { purpose: envelope.purpose }),
512
+ // Isolation overrides ride the envelope: without them the
513
+ // drained turn would run local with the default harness cwd
514
+ // instead of the cloud VM / channel worktree the sender bound.
515
+ ...(envelope.workspaceDir === undefined
516
+ ? {}
517
+ : { workspaceDir: envelope.workspaceDir }),
518
+ ...(envelope.cloud === undefined ? {} : { cloud: envelope.cloud }),
519
+ fromLedgerPump: true,
506
520
  }),
521
+ findSession: async (channelId, continuationToken) => {
522
+ let record = await this.sessions.findByContinuation(
523
+ channelId,
524
+ continuationToken
525
+ );
526
+ if (record === undefined) {
527
+ record = await this.restoreSessionByContinuation(
528
+ channelId,
529
+ continuationToken
530
+ );
531
+ }
532
+ return record === undefined
533
+ ? undefined
534
+ : this.channelSession(record.sessionId, record, false);
535
+ },
507
536
  logger: this.logger,
508
537
  });
509
538
  }
@@ -521,13 +550,14 @@ export class SessionEngine {
521
550
  *
522
551
  * An authored backend without `cas` fails fast: silently falling back
523
552
  * to local files would fork coordination away from the deployment's
524
- * durable store. The check demands exactly the methods the
525
- * coordination-only ledger uses `listKeys` is not one of them.
553
+ * durable store. The check demands the full surface the inbox uses —
554
+ * the CAS trio plus `get`/`put`/`delete` for mailbox payload blobs and
555
+ * `listKeys` for the janitor scan and the orphan sweep.
526
556
  *
527
557
  * Whatever store wins, the returned handle is scoped here — once —
528
558
  * under `agentkit/v1/<agent>/durable-session/`. The ledger speaks bare
529
- * `session/…` / `ledger/…` keys; namespacing is this boundary's job,
530
- * exactly like `host.kv`'s agent scoping.
559
+ * `session/…` / `ledger/…` / `mailbox/…` keys; namespacing is this
560
+ * boundary's job, exactly like `host.kv`'s agent scoping.
531
561
  */
532
562
  private resolveDurableSessionStore(
533
563
  options: SessionEngineOptions
@@ -537,7 +567,7 @@ export class SessionEngine {
537
567
  if (explicit !== undefined) {
538
568
  if (!supportsSessionLedgerKv(explicit)) {
539
569
  throw new Error(
540
- `Agent "${options.project.name}" declares serving: "ledger", but the configured durableSessionStore is not CAS-capable (getWithVersion / putIfAbsent / putIfVersion). Provide a store implementing those methods (see @cursor/july/kv).`
570
+ `Agent "${options.project.name}" declares serving: "ledger", but the configured durableSessionStore is not CAS-capable (getWithVersion / putIfAbsent / putIfVersion / listKeys). Provide a store implementing HostKvCasApi (see @cursor/july/kv).`
541
571
  );
542
572
  }
543
573
  return namespacedKv(explicit, namespace);
@@ -729,10 +759,10 @@ export class SessionEngine {
729
759
  message: string,
730
760
  options: EngineSendOptions = {}
731
761
  ): Promise<ChannelSession> {
732
- // Ledger-mode agents acquire the session lease before any turn runs;
733
- // only the seam's own lease-holding deliveries fall through to the
734
- // resident path.
735
- if (this.ledgerServing !== undefined && options.ledgerDelivery !== true) {
762
+ // Ledger-mode agents park the event durably and coordinate through
763
+ // the session lease before any turn runs; only the pump's own
764
+ // lease-holding deliveries fall through to the resident path.
765
+ if (this.ledgerServing !== undefined && options.fromLedgerPump !== true) {
736
766
  return this.ledgerServing.deliver(channelId, message, options);
737
767
  }
738
768
  const auth = options.auth ?? null;
@@ -806,13 +836,20 @@ export class SessionEngine {
806
836
  ),
807
837
  }));
808
838
  }
839
+ const since = (await this.logs.get(record.sessionId)).count;
809
840
  const coalesced = await this.startTurn(record.sessionId, message, {
810
841
  admission: options.admission ?? "preempt",
811
842
  ...(options.coalesceSourceTs === undefined
812
843
  ? {}
813
844
  : { sourceTs: options.coalesceSourceTs }),
814
845
  });
815
- return this.channelSession(record.sessionId, record, false, coalesced);
846
+ return this.channelSession(
847
+ record.sessionId,
848
+ record,
849
+ false,
850
+ coalesced,
851
+ since
852
+ );
816
853
  }
817
854
 
818
855
  const now = new Date().toISOString();
@@ -838,18 +875,26 @@ export class SessionEngine {
838
875
  };
839
876
  await this.sessions.save(record);
840
877
  this.storage?.sessionRecord(record);
878
+ // Ledger serving: another pod can follow up mid-turn, so the session
879
+ // identity (record + continuation index) must hit the shared store
880
+ // before we return a session id. Default turnEnd flush is too late.
881
+ if (this.ledgerServing !== undefined && this.storage !== undefined) {
882
+ this.storage.flushSession(record.sessionId);
883
+ await this.storage.whenIdle();
884
+ }
841
885
  await this.appendEvent(record.sessionId, {
842
886
  type: "session.started",
843
887
  data: { channelId },
844
888
  });
845
889
  record = await this.enrollABs(record);
890
+ const since = (await this.logs.get(record.sessionId)).count;
846
891
  await this.startTurn(record.sessionId, message, {
847
892
  admission: options.admission ?? "preempt",
848
893
  ...(options.coalesceSourceTs === undefined
849
894
  ? {}
850
895
  : { sourceTs: options.coalesceSourceTs }),
851
896
  });
852
- return this.channelSession(record.sessionId, record, true, false);
897
+ return this.channelSession(record.sessionId, record, true, false, since);
853
898
  }
854
899
 
855
900
  /** Whether a continuation token already maps to a session on this channel. */
@@ -2026,6 +2071,7 @@ export class SessionEngine {
2026
2071
  }),
2027
2072
  getSession: (channelId, sessionId) =>
2028
2073
  this.getChannelSession(channelId, sessionId),
2074
+ emit: args.emit,
2029
2075
  };
2030
2076
  try {
2031
2077
  const needsGate = await evaluateNeedsApproval(
@@ -2314,6 +2360,16 @@ export class SessionEngine {
2314
2360
  this.logger(`[session] tool ${toolName} start (direct ${callId})`);
2315
2361
  let result: unknown;
2316
2362
  let isError: boolean;
2363
+ const live = record;
2364
+ const emit =
2365
+ live === undefined
2366
+ ? undefined
2367
+ : (payload: SessionEventPayload) => {
2368
+ void this.appendEvent(live.sessionId, {
2369
+ ...payload,
2370
+ turnId: callId,
2371
+ }).catch(() => {});
2372
+ };
2317
2373
  try {
2318
2374
  result = normalizeToolResult(
2319
2375
  await execute(validation.value as never, {
@@ -2340,6 +2396,7 @@ export class SessionEngine {
2340
2396
  }),
2341
2397
  getSession: (channelId, sessionId) =>
2342
2398
  this.getChannelSession(channelId, sessionId),
2399
+ emit,
2343
2400
  })
2344
2401
  );
2345
2402
  isError = isErrorEnvelope(result);
@@ -3039,7 +3096,8 @@ export class SessionEngine {
3039
3096
  sessionId: string,
3040
3097
  record: SessionRecord,
3041
3098
  isNew: boolean,
3042
- coalesced = false
3099
+ coalesced = false,
3100
+ since = 0
3043
3101
  ): ChannelSession {
3044
3102
  return {
3045
3103
  id: sessionId,
@@ -3048,7 +3106,7 @@ export class SessionEngine {
3048
3106
  ...(coalesced ? { coalesced: true } : {}),
3049
3107
  info: this.sessionInfo(record),
3050
3108
  getEventStream: (options?: { startIndex?: number }) =>
3051
- this.eventStream(sessionId, options?.startIndex ?? 0),
3109
+ this.eventStream(sessionId, options?.startIndex ?? since),
3052
3110
  waitForCompletion: () => this.waitForCompletion(sessionId),
3053
3111
  setContinuationToken: (token: string | null) =>
3054
3112
  this.setContinuationToken(sessionId, token),
@@ -86,6 +86,14 @@ export function createMemoryKv(): HostKvCasApi {
86
86
  * fixtures, an off-hosting no-op sink). Non-durable and process-local by
87
87
  * construction; a real deployment backend should implement CAS against
88
88
  * its own store instead.
89
+ *
90
+ * **Never pair this with `serving: "ledger"` on a real backend.** The
91
+ * ledger derives its coordination store from the whole storage config,
92
+ * so a durable plain backend with a memory `cas` group silently voids
93
+ * ack-after-durable: leases, heads, and pending indexes live only in
94
+ * this process and vanish on restart while payload blobs go through the
95
+ * plain methods. Ledger state must live in the same durable store as
96
+ * the blobs.
89
97
  */
90
98
  export function memoryCasTable(): StorageCasTable {
91
99
  const kv = createMemoryKv();
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The ledger janitor: lazy crash recovery has a hole — a session with
3
+ * pending events and no future events would wait forever — so serving runs
4
+ * this loop. It scans `ledger/` by prefix, finds pending-with-expired-lease
5
+ * sessions, claims them, and hands each claim to the execution callback.
6
+ * Self-host runs the same loop; it is part of the library, not the control
7
+ * plane.
8
+ *
9
+ * Nothing starts on construction: call `start()` for the interval loop or
10
+ * `runOnce()` for an explicit pass (what unit tests use).
11
+ */
12
+
13
+ import type {
14
+ ClaimResult,
15
+ LedgerLease,
16
+ SessionLedger,
17
+ } from "./session-ledger.js";
18
+
19
+ export const DEFAULT_JANITOR_INTERVAL_MS = 15_000;
20
+
21
+ export interface JanitorClaimedSession {
22
+ sessionId: string;
23
+ lease: LedgerLease;
24
+ head: number;
25
+ pending: string[];
26
+ }
27
+
28
+ export interface JanitorRunResult {
29
+ /** Ledgers scanned this pass. */
30
+ scanned: number;
31
+ /** Sessions this pass claimed and handed to the callback. */
32
+ claimed: JanitorClaimedSession[];
33
+ /** Orphan blob ids deleted (only when the sweep is enabled). */
34
+ orphansDeleted: string[];
35
+ }
36
+
37
+ export interface LedgerJanitorOptions {
38
+ ledger: SessionLedger;
39
+ /**
40
+ * Executes a recovered session: drain, run the turn, commit. Awaited so
41
+ * one pass never claims more sessions than it can serve.
42
+ */
43
+ onSession: (session: JanitorClaimedSession) => Promise<void> | void;
44
+ /** Scan cadence for `start()` (default {@link DEFAULT_JANITOR_INTERVAL_MS}). */
45
+ intervalMs?: number;
46
+ /**
47
+ * Also sweep unreferenced mailbox blobs each pass. Off by default —
48
+ * enable with `{ olderThanMs }` (see `SessionLedger.sweepOrphans`).
49
+ */
50
+ orphanSweep?: { olderThanMs?: number };
51
+ /** Called with per-session or sweep failures; the pass continues. */
52
+ onError?: (error: unknown, sessionId?: string) => void;
53
+ /** Time source for lease-expiry checks (default `Date.now`). */
54
+ clock?: () => number;
55
+ }
56
+
57
+ export interface LedgerJanitor {
58
+ start(): void;
59
+ /**
60
+ * Clear the interval AND await any in-flight pass: a running pass can
61
+ * claim a session and spawn a turn, so shutdown must not proceed while
62
+ * one is mid-scan.
63
+ */
64
+ stop(): Promise<void>;
65
+ runOnce(): Promise<JanitorRunResult>;
66
+ }
67
+
68
+ export function createLedgerJanitor(
69
+ options: LedgerJanitorOptions
70
+ ): LedgerJanitor {
71
+ const intervalMs = options.intervalMs ?? DEFAULT_JANITOR_INTERVAL_MS;
72
+ const clock = options.clock ?? Date.now;
73
+ const reportError =
74
+ options.onError ??
75
+ ((): void => {
76
+ // Recovery is best-effort per pass; an unobserved failure is retried
77
+ // on the next scan.
78
+ });
79
+
80
+ let timer: ReturnType<typeof setInterval> | undefined;
81
+ let running = false;
82
+ /** The settled-shape promise of the pass currently scanning, if any. */
83
+ let inFlight: Promise<void> | undefined;
84
+
85
+ const runPass = async (): Promise<JanitorRunResult> => {
86
+ // An unparseable ledger record is reported and skipped: it degrades
87
+ // that one session's recovery, never the whole agent's pass.
88
+ const ledgers = await options.ledger.listLedgers((key, error) => {
89
+ reportError(
90
+ new Error(
91
+ `janitor scan: skipping unparseable ledger record at ${key}: ${String(
92
+ error instanceof Error ? error.message : error
93
+ )}`
94
+ )
95
+ );
96
+ });
97
+ const claimed: JanitorClaimedSession[] = [];
98
+ for (const snapshot of ledgers) {
99
+ if (snapshot.pending.length === 0) {
100
+ continue;
101
+ }
102
+ if (snapshot.lease !== null && snapshot.lease.expiresAt > clock()) {
103
+ continue;
104
+ }
105
+ let claim: ClaimResult;
106
+ try {
107
+ claim = await options.ledger.claim(snapshot.sessionId);
108
+ } catch (error) {
109
+ reportError(error, snapshot.sessionId);
110
+ continue;
111
+ }
112
+ if (!claim.claimed) {
113
+ continue;
114
+ }
115
+ const session: JanitorClaimedSession = {
116
+ sessionId: snapshot.sessionId,
117
+ lease: claim.lease,
118
+ head: claim.head,
119
+ pending: claim.pending,
120
+ };
121
+ claimed.push(session);
122
+ try {
123
+ await options.onSession(session);
124
+ } catch (error) {
125
+ reportError(error, snapshot.sessionId);
126
+ }
127
+ }
128
+
129
+ let orphansDeleted: string[] = [];
130
+ if (options.orphanSweep !== undefined) {
131
+ try {
132
+ const swept = await options.ledger.sweepOrphans({
133
+ olderThanMs: options.orphanSweep.olderThanMs,
134
+ });
135
+ orphansDeleted = swept.deleted;
136
+ } catch (error) {
137
+ reportError(error);
138
+ }
139
+ }
140
+ return { scanned: ledgers.length, claimed, orphansDeleted };
141
+ };
142
+
143
+ const runOnce = (): Promise<JanitorRunResult> => {
144
+ const pass = runPass();
145
+ const settled = pass.then(
146
+ () => undefined,
147
+ () => undefined
148
+ );
149
+ inFlight = settled;
150
+ void settled.finally(() => {
151
+ if (inFlight === settled) {
152
+ inFlight = undefined;
153
+ }
154
+ });
155
+ return pass;
156
+ };
157
+
158
+ const tick = async (): Promise<void> => {
159
+ // Skip a tick rather than overlap passes when a scan outlasts the
160
+ // cadence — overlapping claims would just fence each other.
161
+ if (running) {
162
+ return;
163
+ }
164
+ running = true;
165
+ try {
166
+ await runOnce();
167
+ } catch (error) {
168
+ reportError(error);
169
+ } finally {
170
+ running = false;
171
+ }
172
+ };
173
+
174
+ return {
175
+ start(): void {
176
+ if (timer !== undefined) {
177
+ return;
178
+ }
179
+ timer = setInterval(() => {
180
+ void tick();
181
+ }, intervalMs);
182
+ timer.unref?.();
183
+ },
184
+ async stop(): Promise<void> {
185
+ if (timer !== undefined) {
186
+ clearInterval(timer);
187
+ timer = undefined;
188
+ }
189
+ await inFlight;
190
+ },
191
+ runOnce,
192
+ };
193
+ }