stitchkit 0.60.1 → 0.62.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 (67) hide show
  1. package/dist/agent-runtime/compaction.d.ts.map +1 -1
  2. package/dist/agent-runtime/coordinator.d.ts +17 -2
  3. package/dist/agent-runtime/coordinator.d.ts.map +1 -1
  4. package/dist/agent-runtime/events.d.ts +16 -0
  5. package/dist/agent-runtime/events.d.ts.map +1 -1
  6. package/dist/agent-runtime/history.d.ts +39 -1
  7. package/dist/agent-runtime/history.d.ts.map +1 -1
  8. package/dist/agent-runtime/observability.d.ts +2 -0
  9. package/dist/agent-runtime/observability.d.ts.map +1 -1
  10. package/dist/agent-runtime/prompt.d.ts +1 -1
  11. package/dist/agent-runtime/prompt.d.ts.map +1 -1
  12. package/dist/agent-runtime/run-execution.d.ts.map +1 -1
  13. package/dist/agent-runtime/runtime-internals.d.ts +47 -0
  14. package/dist/agent-runtime/runtime-internals.d.ts.map +1 -1
  15. package/dist/agent-runtime/runtime.d.ts +1 -0
  16. package/dist/agent-runtime/runtime.d.ts.map +1 -1
  17. package/dist/agent-runtime/schemas.d.ts +9 -0
  18. package/dist/agent-runtime/schemas.d.ts.map +1 -1
  19. package/dist/agent-runtime/store-driver.d.ts +11 -0
  20. package/dist/agent-runtime/store-driver.d.ts.map +1 -1
  21. package/dist/agent-runtime/store.d.ts +27 -0
  22. package/dist/agent-runtime/store.d.ts.map +1 -1
  23. package/dist/agent-runtime/terminal-commit.d.ts +3 -3
  24. package/dist/agent-runtime/terminal-commit.d.ts.map +1 -1
  25. package/dist/agent-runtime/terminal-status.d.ts +13 -0
  26. package/dist/agent-runtime/terminal-status.d.ts.map +1 -1
  27. package/dist/agent-runtime.js +251 -82
  28. package/dist/application/kernel.d.ts +23 -0
  29. package/dist/application/kernel.d.ts.map +1 -1
  30. package/dist/application/server-resource.d.ts.map +1 -1
  31. package/dist/application.d.ts +1 -1
  32. package/dist/application.d.ts.map +1 -1
  33. package/dist/application.js +11 -6
  34. package/dist/browser/socket-io.d.ts +47 -0
  35. package/dist/browser/socket-io.d.ts.map +1 -1
  36. package/dist/browser/stream.d.ts +23 -0
  37. package/dist/browser/stream.d.ts.map +1 -1
  38. package/dist/contract/index.js +1 -1
  39. package/dist/{index-t8xrqc9g.js → index-413xk7ga.js} +1 -1
  40. package/dist/{index-vtjgx3vv.js → index-58vkx74h.js} +3 -0
  41. package/dist/{index-82e74yfx.js → index-eabpd4tb.js} +56 -7
  42. package/dist/{index-2cgbdckv.js → index-s1tywej8.js} +88 -19
  43. package/dist/index.d.ts +2 -2
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +213 -17
  46. package/dist/internal/optional-peer.d.ts +14 -0
  47. package/dist/internal/optional-peer.d.ts.map +1 -0
  48. package/dist/node.js +1 -1
  49. package/dist/realtime/contract.d.ts +7 -1
  50. package/dist/realtime/contract.d.ts.map +1 -1
  51. package/dist/realtime/index.d.ts +2 -1
  52. package/dist/realtime/index.d.ts.map +1 -1
  53. package/dist/realtime/rejected-frame.d.ts +86 -0
  54. package/dist/realtime/rejected-frame.d.ts.map +1 -0
  55. package/dist/realtime/request.d.ts +24 -0
  56. package/dist/realtime/request.d.ts.map +1 -1
  57. package/dist/realtime/socket.d.ts.map +1 -1
  58. package/dist/server/index.d.ts +1 -0
  59. package/dist/server/index.d.ts.map +1 -1
  60. package/dist/server/index.js +196 -3
  61. package/dist/server/socket-io.d.ts.map +1 -1
  62. package/dist/server/stream.d.ts.map +1 -1
  63. package/dist/server/streaming-route.d.ts +130 -0
  64. package/dist/server/streaming-route.d.ts.map +1 -0
  65. package/dist/testing.js +2 -2
  66. package/llms-full.txt +630 -31
  67. package/package.json +2 -2
package/llms-full.txt CHANGED
@@ -1515,6 +1515,7 @@ focused helper — not a sub-framework.
1515
1515
  |--------|------|
1516
1516
  | `serveFile()` | serve a file with `Range` / `304` / `HEAD` (media seeking) |
1517
1517
  | `streamSSE()` | turn an `AsyncGenerator` into a Server-Sent-Events `Response` |
1518
+ | `ndjsonRoute()` / `sseRoute()` | a **long-lived** subscription route, with the whole checklist |
1518
1519
  | `parseMultipart()` | parse a typed buffered/streaming multipart descriptor |
1519
1520
  | `createRateLimiter()` | per-key token-bucket rate limiting |
1520
1521
  | `createCache()` + `cacheHeaders()` | in-memory TTL cache; `Cache-Control` builder |
@@ -1540,6 +1541,64 @@ stream: () => streamSSE(tokens()), // → a text/event-stream Response
1540
1541
 
1541
1542
  The client side is [`parseSSE`](./client.md#sse).
1542
1543
 
1544
+ `streamSSE` is for a stream that **finishes** — a completion, a job's output.
1545
+ For one that stays open, see below.
1546
+
1547
+ ### Long-lived subscriptions
1548
+
1549
+ `streamSSE` assumes the generator keeps producing. A **subscription** is the
1550
+ opposite: silence is its normal state, and three unrelated things have to be
1551
+ right or it breaks without saying anything. (Measured: with a heartbeat under
1552
+ the threshold, either of the first two alone keeps an in-process connection
1553
+ alive — the first earns its place against what a heartbeat cannot reach, an
1554
+ intermediary applying its own idle rule.)
1555
+
1556
+ 1. **The generic idle timeout has to go.** Without `server.timeout(req, 0)` Bun
1557
+ resets the connection after ten seconds — a healthy connection severed on a
1558
+ schedule, precisely because the subscriber had nothing to be told.
1559
+ 2. **Something has to be on the wire.** Even with the timeout gone,
1560
+ intermediate proxies are under no obligation to hold a connection carrying no
1561
+ bytes.
1562
+ 3. **The headers have to leave at open.** A runtime sends nothing until the body
1563
+ produces a byte, so the consumer's `fetch` does not return. "Subscribed and
1564
+ silent" then looks exactly like "not answering", and there is nothing to
1565
+ inspect because there is no response yet.
1566
+
1567
+ `ndjsonRoute` / `sseRoute` do all three, and close the source when the consumer
1568
+ goes away:
1569
+
1570
+ ```ts
1571
+ import { ndjsonRoute } from 'stitchkit/server'
1572
+
1573
+ const events = ndjsonRoute({
1574
+ path: '/events/subscribe',
1575
+ heartbeatMs: 5_000, // default; keep it well under 10s
1576
+ source: async function* (request, { signal }) {
1577
+ for await (const event of subscribe({ signal })) yield event
1578
+ },
1579
+ })
1580
+
1581
+ createServer({ port: 3000, rawRoutes: [events] })
1582
+ ```
1583
+
1584
+ The client half is [`parseNDJSON`](./client.md#ndjson) — and the keep-alive
1585
+ frame is an **empty line**, so "blank lines are skipped" is part of the
1586
+ documented contract rather than an agreement between two halves of one project.
1587
+ `sseRoute` frames the same source as SSE and is read by `parseSSE` unchanged.
1588
+
1589
+ **Honour `context.signal`.** It is the one part the route cannot do for you, and
1590
+ the reason is worth knowing: an async generator serialises its requests, so
1591
+ `iterator.return()` issued while a `next()` is in flight is *queued behind it*.
1592
+ A subscription is in `next()` almost always, so the close would wait for an
1593
+ event that may never come. The signal is aborted the moment the consumer
1594
+ disconnects — through either route, a request abort or a stream cancel — and a
1595
+ source that waits on it stops at once. (`iterator.return()` is still called; it
1596
+ closes a source suspended at a `yield`.)
1597
+
1598
+ A failure part-way through arrives as a final frame carrying the framework
1599
+ error envelope, normalised — once the headers are gone there is no status left
1600
+ to send, and an internal message must not reach the wire raw.
1601
+
1543
1602
  ### Multipart
1544
1603
 
1545
1604
  The contract owns one descriptor for buffered and streaming delivery:
@@ -2172,7 +2231,42 @@ for await (const event of parseSSE(res)) {
2172
2231
  }
2173
2232
  ```
2174
2233
 
2175
- The server side is [`streamSSE`](./server.md#sse-streaming).
2234
+ The server side is [`streamSSE`](./server.md#sse-streaming), or
2235
+ [`sseRoute`](./server.md#long-lived-subscriptions) for a subscription that stays
2236
+ open.
2237
+
2238
+ ## NDJSON
2239
+
2240
+ `parseNDJSON` reads a newline-delimited JSON body — the client half of
2241
+ [`ndjsonRoute`](./server.md#long-lived-subscriptions):
2242
+
2243
+ ```ts
2244
+ import { parseNDJSON } from 'stitchkit'
2245
+
2246
+ const subscription = new AbortController()
2247
+ const res = await fetch('/api/events/subscribe', { signal: subscription.signal })
2248
+ for await (const event of parseNDJSON(res)) {
2249
+ console.log(event)
2250
+ }
2251
+
2252
+ // ...to unsubscribe:
2253
+ subscription.abort()
2254
+ ```
2255
+
2256
+ **Use the `AbortController` for a subscription.** Leaving the loop with `break`
2257
+ cancels the body, and on a stream that ends that is enough — but it is not a
2258
+ reliable way to tell the *server* you are gone: measured against Bun today, the
2259
+ source stayed alive for seconds after a client-side cancel. Aborting the request
2260
+ reaches [`context.signal`](./server.md#long-lived-subscriptions) on the other
2261
+ end at once, which is what actually ends the work.
2262
+
2263
+ **Blank lines are skipped**, and that is the contract rather than a
2264
+ convenience: a long-lived stream must send something while it is idle or
2265
+ intermediaries drop it, and an empty line is the natural pulse for this framing.
2266
+ Writing the rule down on both sides is what stops it being a verbal agreement —
2267
+ the server's keep-alive and the reader's skip are one decision with two
2268
+ implementations. A frame that is not valid JSON goes to `onParseError` rather
2269
+ than throwing, so one bad line does not end the subscription.
2176
2270
 
2177
2271
 
2178
2272
  ==============================================================================
@@ -3578,6 +3672,110 @@ process-local coordinator releases its lane only after terminal commit.
3578
3672
  input + queued run → running → execution settled → terminal CAS → successor
3579
3673
  ```
3580
3674
 
3675
+ ## What happens to a run when new input arrives
3676
+
3677
+ `runs.inputPolicy` decides. It takes three values (or a function returning one);
3678
+ the fourth row below is a behaviour it deliberately does not offer yet:
3679
+
3680
+ | policy | the run in flight | what it already produced |
3681
+ |--------|-------------------|--------------------------|
3682
+ | `queue` (default) | finishes first | kept |
3683
+ | `interrupt` | ends | kept, and marked as cut off |
3684
+ | `supersede` | ends | discarded from the prompt, kept in the record |
3685
+ | _inject_ | continues | — not supported; see below |
3686
+
3687
+ `interrupt` and `supersede` differ in exactly one thing, and the question that
3688
+ picks between them is **not** "was the run interrupted" but **"did anyone see
3689
+ what it produced"**:
3690
+
3691
+ - The user pressed **stop**. The partial answer was streamed to their screen and
3692
+ they read it. It belongs in the conversation — dropping it makes the history
3693
+ lie to the model about what the human has seen. That is `interrupt`.
3694
+ - A newer message **superseded** the run. Whether the partial reached anyone
3695
+ depends on the delivery surface: a token stream shows it as it is produced, a
3696
+ surface that sends nothing until the run is done never showed it at all. When
3697
+ it reached nobody, it is not part of the conversation. That is `supersede`.
3698
+
3699
+ **Stitchkit cannot answer that question for you** — delivery belongs to the
3700
+ transport, and the runtime sees an abort, not a screen. Hence a declared policy
3701
+ (→ ADR 0108), and hence `inputPolicy` accepting a function, so one application
3702
+ can hold two surfaces with different rules without the core learning which is
3703
+ which:
3704
+
3705
+ ```ts
3706
+ runs: {
3707
+ inputPolicy: (input) =>
3708
+ protocol.parseContext(input.context).surface === 'operator' ? 'queue' : 'supersede',
3709
+ }
3710
+ ```
3711
+
3712
+ `input.context` is the **raw** context here — admission runs before the runtime
3713
+ parses it, so the callback narrows it itself with the protocol it already has.
3714
+
3715
+ A superseded run ends with `terminalReason: 'superseded'`, run state
3716
+ `'superseded'` and an assistant message of status `'superseded'`. **The record
3717
+ is kept** — excluded from the projection, not deleted — so an operator can see
3718
+ what was thrown away, and run identity, admission receipts and the terminal CAS
3719
+ keep the row they depend on. Compaction leaves it alone for the same reason: a
3720
+ turn whose answer is never spoken is not a turn that may be summarised into one,
3721
+ because that would both feed the discarded text to the summariser and drop the
3722
+ record in `replacedMessageIds`.
3723
+
3724
+ It is also outside the token budget. `selectAgentHistory` removes it with reason
3725
+ `'superseded'` and does not count it, so an abandoned fragment cannot push a
3726
+ real turn out of a context it never occupies.
3727
+
3728
+ ### How an interrupted turn reaches the model
3729
+
3730
+ An interrupted turn is projected, and says so:
3731
+
3732
+ ```text
3733
+ { role: 'assistant', content: [
3734
+ { type: 'text', text: 'We are the team, where would you like' },
3735
+ { type: 'text', text: '[interrupted: this turn was cut off before it finished]' },
3736
+ ]}
3737
+ ```
3738
+
3739
+ `history.interruptedAssistant` chooses the form, and the difference between the
3740
+ first two is structural rather than cosmetic. **An assistant turn in provider
3741
+ history is a commitment**: the model reads its own previous turn as something it
3742
+ said and stays consistent with it. A system line is context.
3743
+
3744
+ | value | form | right when |
3745
+ |-------|------|-----------|
3746
+ | `assistant-marked` (default) | assistant turn plus a marker | the human read the text |
3747
+ | `system-note` | `[interrupted] partial response: …` as a system line | the fragment reached nobody |
3748
+ | `omit` | not projected at all | you want it gone from the request |
3749
+
3750
+ There is deliberately no value that reproduces what the projection used to do,
3751
+ which was to send the partial as an ordinary assistant turn and drop its
3752
+ `control` marker on the way. That was the defect, not a behaviour to stay
3753
+ compatible with.
3754
+
3755
+ `projectAgentHistoryDetailed` reports what reached the provider, including part
3756
+ types that no projected content stands for:
3757
+
3758
+ ```ts
3759
+ const { decisions } = await projectAgentHistoryDetailed(snapshot.messages)
3760
+ // → { messageId: 'assistant-2', action: 'projected', reason: 'projected',
3761
+ // omittedParts: ['source', 'provider'] }
3762
+ // → { messageId: 'assistant-1', action: 'omitted', reason: 'superseded' }
3763
+ ```
3764
+
3765
+ `runtime.stop(key, 'supersede')` is the same decision taken by hand: the
3766
+ process-local escape hatch chooses the reason, so a caller that knows the answer
3767
+ was never delivered can discard it without a newer input arriving.
3768
+
3769
+ ### The one that is not supported
3770
+
3771
+ **inject** — hand the input to the loop between tool calls and let the run
3772
+ continue — has no primitive. `loop.prepareStep` passes the AI SDK return type
3773
+ through, so an application *can* append messages between steps, but nothing
3774
+ hands `prepareStep` the pending inputs and nothing attaches an absorbed input to
3775
+ the running run's `inputMessageIds`. A run that answered two messages would
3776
+ carry a durable record claiming it answered one. It is reachable by hand, not
3777
+ supported.
3778
+
3581
3779
  With `runs.coalescePending: true`, an active lane has at most one queued
3582
3780
  successor. Every later accepted input is atomically appended to that successor;
3583
3781
  its `AgentRun.inputMessageIds` records the whole batch and every input ticket
@@ -3628,8 +3826,12 @@ coordinator signal. If provider completion races that revision change, the termi
3628
3826
  the canonical snapshot. An already-terminal winner settles the ticket directly; a still-owned
3629
3827
  `interrupt_requested` run is committed as `interrupted`, and unrelated aggregate-head conflicts
3630
3828
  remain retriable while the run is active with the same owner and fencing token. A stale owner or
3631
- fencing token remains a conflict. Only the execution that applies the terminal mutation emits the
3632
- terminal event and operator metrics; a loser settles from canonical state without republishing it.
3829
+ fencing token remains a conflict. Only the execution that applies the terminal mutation publishes the
3830
+ **delivery** `terminal` event a loser settles from canonical state without republishing the turn,
3831
+ and its `AgentRuntimeResult.metrics` is `undefined`. The **operator** `run-terminal` event is not
3832
+ gated that way: a losing execution still ran, and still spent whatever it spent, so it reports its
3833
+ own usage. The two channels answer to different readers — delivering a turn twice is a user's
3834
+ problem, and omitting a run's cost is an operator's.
3633
3835
  `runtime.stop(key)` is the process-local signal-only escape hatch.
3634
3836
 
3635
3837
  ## Store operations
@@ -3788,10 +3990,43 @@ sending new event kinds to existing request sinks. Product events omit provider
3788
3990
  causes. Operator `internalCause` is also redacted by default; an operator-only sink must explicitly
3789
3991
  set `includeInternalCause` and own its retention policy.
3790
3992
 
3791
- Usage values carry `provider-reported`, `computed`, `estimated` or
3792
- `unavailable` provenance. Cost additionally carries an ISO currency code;
3793
- OpenRouter-reported cost is normalized as USD. Missing values remain absent,
3794
- never zero-filled.
3993
+ ### What a run says it spent
3994
+
3995
+ Usage values carry `provider-reported`, `computed`, `estimated` or `unavailable`
3996
+ provenance, **per field**. Cost additionally carries an ISO currency code;
3997
+ OpenRouter-reported cost is normalized as USD.
3998
+
3999
+ Read the provenance before the number (→ ADR 0109):
4000
+
4001
+ - **`provider-reported`** — the provider handed us exactly this. On
4002
+ `step-finished`, that is what a step's figures are.
4003
+ - **`computed`** — a total, added up over steps. **Every figure on a terminal
4004
+ event is this**, tokens included: the AI SDK's `totalUsage` is a sum it
4005
+ performed, not a number a provider reported for the run. It is not a figure to
4006
+ bill against unchanged.
4007
+ - **`unavailable`** — nobody reported it. Not zero.
4008
+
4009
+ Two rules follow from that last one, and they differ by field on purpose:
4010
+
4011
+ - **A token total with an unreported step is a floor**, labelled `computed`. A
4012
+ token count is a diagnostic, and a floor is a useful one.
4013
+ - **A cost with an unreported step is `unavailable`, not a floor.** Money is what
4014
+ people bill against, and "at least $1.00" reported as `$1.00` is the same class
4015
+ of lie this whole section exists to remove. One step that did not report its
4016
+ cost makes the run's cost unknown — not smaller. It also stays unknown: later
4017
+ steps reporting normally cannot revive it.
4018
+
4019
+ **A terminal event always carries `usage`.** A run that ended before the provider
4020
+ reported anything — superseded, interrupted, timed out, shut down, failed —
4021
+ carries every field `unavailable`. That is deliberately different from a run that
4022
+ spent nothing, and an omitted object could not tell you which one you had.
4023
+
4024
+ Two costs in different currencies do not add: the sum reports `unavailable`
4025
+ rather than picking a label. The core records a currency and never converts one.
4026
+
4027
+ Usage is **not durable**. It reaches you on the operator and delivery event
4028
+ streams and in `AgentRuntimeResult.metrics`, and stitchkit writes no spend to the
4029
+ store — where a figure lives afterwards is the application's (→ ADR 0002).
3795
4030
 
3796
4031
  The sink deduplicates stable event IDs by default. Cross-crash exactly-once still requires a durable
3797
4032
  outbox.
@@ -3939,6 +4174,71 @@ may retain lifecycle `ready`, but snapshot health is `degraded`, never
3939
4174
 
3940
4175
  Readiness is not hidden polling. A resource reports health changes through its
3941
4176
  lifecycle context; the application decides when a database/provider probe runs.
4177
+
4178
+ **A `reportHealth` call inside `start` is kept.** A resource that says nothing
4179
+ is assumed healthy once it is ready; one that reports its own health has already
4180
+ answered the question, and the answer stands. (It used to be overwritten — and
4181
+ the example above hides that, because `healthy` is the same value that
4182
+ overwrote it.)
4183
+
4184
+ **A resource is required unless you write `required: false`.** That default is
4185
+ what makes the next sentence bite.
4186
+
4187
+ **Readiness requires every required resource to be healthy**, so "ready but
4188
+ degraded" is unreachable for a required resource by construction: an
4189
+ application whose required resource reports anything but `healthy` refuses to
4190
+ start, and says which resource and in what state. The refusal distinguishes the
4191
+ two ways to get there — a resource that was never healthy is pointed at
4192
+ `required: false`; one that was healthy and stopped is pointed at
4193
+ `onResourceFailure`. A resource that is *expected* to start
4194
+ degraded — up, but still dialling something external — belongs behind
4195
+ `required: false`, where it keeps its own health and does not gate the
4196
+ application:
4197
+
4198
+ ```ts
4199
+ defineManagedResource({
4200
+ id: 'dialling',
4201
+ required: false,
4202
+ start: ({ reportHealth }) => { reportHealth('degraded') },
4203
+ })
4204
+ ```
4205
+
4206
+ An optional resource reporting non-healthy does not gate readiness, but it does
4207
+ move the application **aggregate** to `degraded` — which a readiness endpoint
4208
+ mapping `degraded` to non-200 will notice.
4209
+
4210
+ If startup fails, every resource that was already started is closed in reverse
4211
+ order. The rollback runs **one** phase — `close` — not the five a real shutdown
4212
+ runs, and `close` receives the same deadlines a shutdown would give it, so a
4213
+ server drains what is in flight instead of aborting it.
4214
+
4215
+ Those deadlines come from the application's declared budget, and it is worth
4216
+ knowing what they cost. With nothing in flight the rollback returns at once: a
4217
+ grace period is a ceiling, not a sleep. With something in flight it waits for
4218
+ it — that is the point — and with something that **never finishes**, a hung
4219
+ upstream or a client that ignores a close frame, it waits out the whole budget
4220
+ before forcing. Under the default 30s+5s that turns a failed `start()` that used
4221
+ to reject in milliseconds into one that can take 35 seconds to reject.
4222
+
4223
+ An application that would rather hear about a broken start immediately says so,
4224
+ in the one place both stopping paths read:
4225
+
4226
+ ```ts
4227
+ createApplication({
4228
+ id: 'app',
4229
+ resources,
4230
+ // Applies to `shutdown()` called with no options AND to the rollback of a
4231
+ // failed `start()`, which has no call site of its own to be told.
4232
+ shutdown: { gracePeriodMs: 5_000, forceTimeoutMs: 1_000 },
4233
+ })
4234
+ ```
4235
+
4236
+ The budget is a real bound, not just a number handed to each resource: a `close`
4237
+ that never returns is abandoned when the budget runs out, reported as a `close`
4238
+ failure, and the startup error stays the `cause` of the `AggregateError` that
4239
+ `start()` rejects with. Without that, one unresponsive resource could keep a
4240
+ failed startup from ever reporting why it failed.
4241
+
3942
4242
  A required long-lived completion that rejects after startup makes readiness
3943
4243
  false and health unhealthy. Stitchkit records the failure but does not restart
3944
4244
  the resource or process.
@@ -4757,36 +5057,64 @@ const realtimeContract = defineRealtimeContract({
4757
5057
  })
4758
5058
  ```
4759
5059
 
4760
- The original `z.ZodError` is retained as `RealtimeRejectedEvent.error.cause`.
4761
- Because event arguments are a tuple, the first payload's `v` path is
4762
- `[0, 'v']` (not `['v']`):
5060
+ A frame that fails this check is **refused, and the sender is told so** — when
5061
+ the event has an acknowledgement. `request()` rejects with
5062
+ `RealtimeRequestRejectedError`, immediately, carrying the peer's own issues:
4763
5063
 
4764
5064
  ```ts
4765
- import type { RealtimeRejectedEvent } from 'stitchkit'
4766
- import { z } from 'zod'
5065
+ import { RealtimeRequestRejectedError } from 'stitchkit'
4767
5066
 
4768
- function isProtocolGenerationMismatch(rejected: RealtimeRejectedEvent): boolean {
4769
- const cause = rejected.error.cause
4770
- if (!(cause instanceof z.ZodError)) return false
4771
- const first = cause.issues[0]
4772
- return first?.code === 'invalid_value'
4773
- && first.path.length === 2
4774
- && first.path[0] === 0
4775
- && first.path[1] === 'v'
5067
+ try {
5068
+ await socket.request('replicate', message, { timeoutMs: 5_000 })
5069
+ } catch (error) {
5070
+ if (error instanceof RealtimeRequestRejectedError) {
5071
+ // reason: 'invalid-arguments'; issues: [{ path: '0.v', code: 'invalid_value', … }]
5072
+ if (error.issues?.some((issue) => issue.path === '0.v')) schedulePeerUpgrade()
5073
+ else reportMalformedRealtimePayload(error)
5074
+ }
4776
5075
  }
5076
+ ```
4777
5077
 
4778
- const socket = createRealtimeClient(realtimeContract, {
4779
- url,
4780
- onRejected: (rejected) => {
4781
- if (isProtocolGenerationMismatch(rejected)) schedulePeerUpgrade()
4782
- else reportMalformedRealtimePayload(rejected)
5078
+ `path` is `'0.v'` and not `'v'` because event arguments are a tuple: index `0`
5079
+ is the first payload. The issues are already flattened by Stitchkit's own
5080
+ normaliser, so telling "wrong generation" from "malformed payload" is one
5081
+ comparison rather than an inspection of a `ZodError`'s internals.
5082
+
5083
+ The receiving side still reports it locally through `onRejected` — a refusal is
5084
+ now visible on **both** ends rather than only where it happened.
5085
+
5086
+ **Two limits, both real.** A **fire-and-forget** event has no acknowledgement
5087
+ channel, so its refusal stays local: the sender learns nothing, and no
5088
+ convention in the payload can change that. And an event the receiver's contract
5089
+ does not contain has no listener at all, so there is nothing on that side to
5090
+ answer with — adding an event is not a change a generation field can announce.
5091
+
5092
+ ### Where protocol identity belongs
5093
+
5094
+ For a distributed pair whose planes are mostly fire-and-forget, compare
5095
+ identity **at the handshake** instead, where a mismatch is refused before the
5096
+ first frame is interpreted and both ends see it at once. The typed handshake is
5097
+ already the place:
5098
+
5099
+ ```ts
5100
+ const handshake = {
5101
+ schema: z.object({ token: z.string(), protocol: z.string() }),
5102
+ verify: (auth) => {
5103
+ if (auth.protocol !== PROTOCOL_IDENTITY) return null // refused, with a reason
5104
+ return { subject: verifyToken(auth.token) }
4783
5105
  },
4784
- })
5106
+ }
4785
5107
  ```
4786
5108
 
4787
- A generation mismatch means “upgrade the peer”; another schema rejection means
4788
- “fix the producer or payload”. This stays an application convention, not a core
4789
- API: only the application knows which field denotes protocol compatibility.
5109
+ The client sends it as `auth`, and a rejection reaches `onConnectError` with
5110
+ `terminal: true` distinguishable in a log from a bad token, so a half-rolled
5111
+ deployment reads as a half-rolled deployment and not as an access problem.
5112
+
5113
+ What that identity *is* remains the application's decision — a build version, a
5114
+ contract hash, a protocol generation. Stitchkit does not compare it for you
5115
+ (→ ADR 0002); it gives the place where the comparison happens before any frame
5116
+ is interpreted, and it makes the per-frame alternative honest by letting its
5117
+ refusals be seen.
4790
5118
 
4791
5119
  ## Server — `createSocketIOServer`
4792
5120
 
@@ -7422,6 +7750,260 @@ current one *up to* your target, and apply each snippet.
7422
7750
  runtime): bootstrap the server, one HTTP request, and any feature you rely on
7423
7751
  (Socket.IO connect, an MCP tool call, a multipart upload, …).
7424
7752
 
7753
+ ## Released migration: 0.62.0
7754
+
7755
+ Two groups of behaviour changes. Nothing moves an export — the surface is
7756
+ strictly additive — and every item changes what a running system reports or
7757
+ sends, which is what this heading is for. One of them changes a number you may
7758
+ already be billing against.
7759
+
7760
+ ### A run reports what it spent
7761
+
7762
+ Three changes to what the runtime says about cost and tokens. Nothing moves an
7763
+ export; all three change numbers a running system reports, and one of them
7764
+ changes a number you may already be billing against.
7765
+
7766
+ #### Multi-step cost was under-reported and is now summed
7767
+
7768
+ No code change is needed to get the fix — but check any predicate that reads
7769
+ `provenance`:
7770
+
7771
+ ```ts
7772
+ // before — accepted a number that was one step's cost, not the run's
7773
+ if (usage.cost?.provenance === 'provider-reported') bill(usage.cost.value)
7774
+
7775
+ // after — a sum stitchkit performed says so
7776
+ if (usage.cost && usage.cost.provenance !== 'unavailable') bill(usage.cost.value)
7777
+ ```
7778
+
7779
+ `'computed'` means stitchkit added up provider-reported parts. It is not a guess
7780
+ — `'estimated'` is the word for that — but it is deliberately not
7781
+ `'provider-reported'`, because that label is what a caller filters on when it
7782
+ wants a figure it can bill against unchanged, and a sum is not one.
7783
+
7784
+ Token totals moved with it. The AI SDK's `totalUsage` is a sum *it* performed
7785
+ over per-step provider figures — not a run total any provider handed over — so
7786
+ labelling it `provider-reported` was the same overstatement. **A run total on a
7787
+ terminal event is always `computed`.** If you want a figure with the provider's
7788
+ own word on it, read `step-finished`: each step carries what that call reported.
7789
+
7790
+ #### `usage` is always present on a terminal event
7791
+
7792
+ ```ts
7793
+ // before — absent when the run ended before the provider's `finish`
7794
+ const spent = event.usage?.cost?.value ?? 0 // silently 0 for a real spend
7795
+
7796
+ // after — present, and it says what it does not know
7797
+ event.usage?.cost?.provenance === 'unavailable' // we spent, and cannot say how much
7798
+ ```
7799
+
7800
+ Keep the optional chaining: one `AgentRunEvent` shape covers `run-started`,
7801
+ `step-finished` and `run-terminal`, so `usage` stays optional on the type. The
7802
+ guarantee is about terminal events, and a schema shared with `run-started`
7803
+ cannot express it.
7804
+
7805
+ **Do not read `unavailable` as zero.** A run aborted mid-stream has spent real
7806
+ money that nobody has counted; a run that never reached the provider has not.
7807
+ Both used to look the same and now do not.
7808
+
7809
+ #### A losing executor reports its own spend
7810
+
7811
+ An execution that loses the terminal compare-and-swap now emits an operator
7812
+ `run-terminal` event, because it ran and it spent. If a sink treated those events
7813
+ as "runs this process committed", that is no longer true — `AgentRuntimeResult.metrics`
7814
+ is still `undefined` for a losing executor and remains the way to tell.
7815
+
7816
+ The *delivery* `terminal` event is unchanged and still fires only for the winner,
7817
+ so nothing delivers a turn twice.
7818
+
7819
+ ### An interrupted answer stops passing as a finished one
7820
+
7821
+ Two behaviour changes, one shared cause: a run ended by a newer input used to
7822
+ leave its half-written answer in the conversation with no sign that it was cut
7823
+ off, and the next request to the provider carried it as an ordinary assistant
7824
+ turn.
7825
+
7826
+ #### The projection marks an interrupted turn
7827
+
7828
+ Nothing to change to adopt the fix — the default is the fixed behaviour. What to
7829
+ check is whether the marker is the *right* form for your surface, and the
7830
+ question that decides it is not "was the run interrupted" but **"did anyone see
7831
+ what it produced"**.
7832
+
7833
+ ```ts
7834
+ // after — pick the form; the default is 'assistant-marked'
7835
+ createAgentRuntime({ history: { interruptedAssistant: 'system-note' } })
7836
+ ```
7837
+
7838
+ - **The user pressed stop and the text was on their screen** — keep
7839
+ `'assistant-marked'`. The assistant turn is the truthful record of what the
7840
+ human read, and the model should stay consistent with it.
7841
+ - **The partial never reached anyone** — a surface that sends nothing until the
7842
+ run is done — prefer `'system-note'`. An assistant turn in provider history is
7843
+ a commitment the model stays consistent with; a system line is context.
7844
+
7845
+ If you pass `history.project` you own the projection outright and none of this
7846
+ applies — but the same question does.
7847
+
7848
+ #### A run ended by a newer input can now say so
7849
+
7850
+ ```ts
7851
+ // before
7852
+ runs: { inputPolicy: 'interrupt' } // ends the run, keeps its partial answer
7853
+
7854
+ // after — for a surface where a follow-up message invalidates the answer
7855
+ // in flight rather than merely stopping it
7856
+ runs: { inputPolicy: 'supersede' } // ends the run, discards its partial answer
7857
+ ```
7858
+
7859
+ `inputPolicy` also accepts `(input) => policy`, which is how one application
7860
+ gives two conversation surfaces different rules without the runtime learning
7861
+ which is which.
7862
+
7863
+ A superseded run terminates with `terminalReason: 'superseded'`, state
7864
+ `'superseded'` and an assistant message of status `'superseded'`. **If you switch
7865
+ exhaustively on any of those enums, add the arm** — that is the part of this
7866
+ release that can break a build rather than a behaviour. The record itself is
7867
+ kept: it is excluded from the projection, not deleted, so an operator can still
7868
+ see what was thrown away.
7869
+
7870
+ ## Released migration: 0.61.0
7871
+
7872
+ Three behaviour changes between versions. None moves an export — the surface is
7873
+ strictly additive — and all three change what a running system does, which is
7874
+ what this heading is for.
7875
+
7876
+ ### A failed `start()` now drains before it rejects
7877
+
7878
+ The rollback of a failed startup used to close every resource with a zero
7879
+ budget: it returned almost at once, by severing requests the server had already
7880
+ accepted. It now spends the application's shutdown budget, so a request already
7881
+ in flight is answered rather than killed.
7882
+
7883
+ **What to check.** Nothing, if a failed startup has nothing in flight — the
7884
+ rollback still returns immediately. The case to think about is a request that
7885
+ never finishes: a hung upstream, a client ignoring a close frame, a streaming
7886
+ subscription. Under the default 30s grace and 5s force, a `start()` that used to
7887
+ reject in milliseconds can now take 35 seconds to reject.
7888
+
7889
+ If a fast failure matters more than draining — a supervisor waiting to restart,
7890
+ a boot check in CI — declare a smaller budget. The same field is the default for
7891
+ `shutdown()` with no options, so this is one decision, not two:
7892
+
7893
+ ```ts
7894
+ // before
7895
+ createApplication({ id: 'app', resources })
7896
+
7897
+ // after
7898
+ createApplication({
7899
+ id: 'app',
7900
+ resources,
7901
+ shutdown: { gracePeriodMs: 5_000, forceTimeoutMs: 1_000 },
7902
+ })
7903
+ ```
7904
+
7905
+ The budget is a real bound: a `close` that never returns is abandoned when it
7906
+ runs out and reported as a `close` failure, and the startup error remains the
7907
+ `cause` of the `AggregateError` `start()` rejects with. → ADR 0107
7908
+
7909
+ ### A refused realtime frame now answers its sender
7910
+
7911
+ A frame that fails the receiver's `args` schema used to be dropped where it
7912
+ landed. If the event carries an acknowledgement, the receiver now answers it
7913
+ with a reserved envelope and the sender's `request()` rejects at once with
7914
+ `RealtimeRequestRejectedError`. → ADR 0106
7915
+
7916
+ **What to check before you upgrade one half of a distributed pair.** Look at the
7917
+ `ack` schemas on the OLDER peer:
7918
+
7919
+ ```ts
7920
+ // safe: a contract-first acknowledgement refuses the envelope, so the older
7921
+ // peer raises RealtimeRequestInvalidAcknowledgementError at once instead of
7922
+ // waiting out its deadline. Different error, still an error, and sooner.
7923
+ ack: z.object({ stored: z.boolean() })
7924
+
7925
+ // NOT safe: a schema that validates nothing accepts the refusal AS A VALUE.
7926
+ // The older peer reads a refusal as a successful acknowledgement — silently.
7927
+ ack: z.unknown()
7928
+ ack: z.looseObject({})
7929
+ ```
7930
+
7931
+ If any acknowledgement on the older side is permissive, tighten it before the
7932
+ rollout, or upgrade both halves together.
7933
+
7934
+ Also: the receiver now invokes the peer's raw acknowledgement callback for a
7935
+ refused frame, including when the peer is a plain Socket.IO client. That
7936
+ callback previously could not run on a refused frame and now can.
7937
+
7938
+ `RealtimeRejectedEvent['reason']` gained `'rejected-by-peer'`. If you `switch`
7939
+ over it exhaustively with an `assertNever` default, that stops compiling — add
7940
+ the case.
7941
+
7942
+ ### Two smaller behaviour changes, easy to miss
7943
+
7944
+ **`streamSSE`'s `cancel` no longer awaits the generator.** Teardown is now
7945
+ unordered relative to request completion. If your generator releases a resource
7946
+ in `return()`/`finally` — a temp file, a pooled connection — and anything
7947
+ downstream assumed that had finished by the time the response settled, it no
7948
+ longer has. Release in the generator's own `finally` and do not depend on the
7949
+ ordering.
7950
+
7951
+ **A `socket.io-client` peer that cannot load no longer kills the process.** With
7952
+ `onConnectError` configured, the failure is delivered there with
7953
+ `terminal: true` instead of crashing. If your handler logs and moves on, you now
7954
+ have a live process whose client will never connect, where a supervisor used to
7955
+ restart it. Treat `terminal: true` as fatal if that is what you want.
7956
+
7957
+ ### `reportHealth` inside `start` is no longer discarded
7958
+
7959
+ Becoming ready assigned `healthy` unconditionally, throwing away whatever a
7960
+ resource reported during `start`. It is now kept, and only a resource that
7961
+ reported nothing is assumed healthy.
7962
+
7963
+ **How to find what this touches:** grep your `start` bodies for **every**
7964
+ `reportHealth` call, not only the ones reporting `degraded`. The old
7965
+ unconditional assignment was also a repair — a resource that reported
7966
+ `'unhealthy'` early in `start` and never corrected itself was quietly fixed up
7967
+ on the way to ready.
7968
+
7969
+ Two cases, and they need opposite fixes.
7970
+
7971
+ **1. A resource that is genuinely expected to start degraded** — up, but still
7972
+ dialling something external. `required` defaults to **`true`**, and readiness
7973
+ requires every required resource to be healthy, so such a resource now refuses
7974
+ the whole startup where before its report vanished. That is the invariant
7975
+ working as intended; what changed is that it can be reached. Say what it is:
7976
+
7977
+ ```ts
7978
+ // before: started, and its report was discarded
7979
+ defineManagedResource({ id: 'dialling', start: ({ reportHealth }) => reportHealth('degraded') })
7980
+
7981
+ // after: says what it is, and does not gate the application
7982
+ defineManagedResource({ id: 'dialling', required: false, start: ({ reportHealth }) => reportHealth('degraded') })
7983
+ ```
7984
+
7985
+ **2. A resource that reported `'unhealthy'` early and became healthy later** —
7986
+ a pessimistic report before a connection settled. Here `required: false` is the
7987
+ **wrong** fix: it would hide a real failure. Report the recovery instead:
7988
+
7989
+ ```ts
7990
+ start: async ({ reportHealth }) => {
7991
+ reportHealth('unhealthy')
7992
+ await connect()
7993
+ reportHealth('healthy') // ← previously unnecessary; now it is the fix
7994
+ }
7995
+ ```
7996
+
7997
+ **Also check your health endpoint.** An **optional** resource reporting
7998
+ non-healthy during `start` now moves the application aggregate to `degraded`,
7999
+ where before it stayed `healthy`. A readiness probe that maps `degraded` to a
8000
+ non-200 will flip on upgrade — and a supervisor that restarts on that will loop.
8001
+
8002
+ The refusals now say which of the two happened: a resource that was never
8003
+ healthy is told it "is not healthy" and pointed at `required: false`; one that
8004
+ was healthy and stopped is told it "lost readiness" and pointed at
8005
+ `onResourceFailure`.
8006
+
7425
8007
  ## Released migration: 0.60.0
7426
8008
 
7427
8009
  ### close() says what it achieved
@@ -9131,7 +9713,9 @@ The browser-and-server entrypoint. Re-exports everything from
9131
9713
  | `bindRealtimeClient` | function | bind contract validation and typed acknowledgements to an existing Stitchkit client transport without owning its lifecycle |
9132
9714
  | `createRetainedTopics` | function | retained last-value store for sticky events — [guide](../guide/realtime.md#sticky-events) |
9133
9715
  | `parseSSE` | function | parse an SSE `Response` into an async generator — [guide](../guide/client.md#sse) |
9716
+ | `parseNDJSON` | function | parse an NDJSON `Response`; blank keep-alive lines are skipped — [guide](../guide/client.md#ndjson) |
9134
9717
  | `SocketIOClient` | _type_ | low-level client handle; `emit` reports disconnected drops and `emitWithAck` exposes the native Promise primitive used by validated `request()` |
9718
+ | `SocketIOClientPeerLoaders` | _type_ | inject `socket.io-client` so a bundler can put it in a self-contained artifact |
9135
9719
  | `SocketIOClientConfig` | _type_ | config for `createSocketIOClient` (incl. `retain`, `onConnectError`, `onDroppedEmit`) |
9136
9720
  | `SocketEventMap` | _type_ | the shape of an event map |
9137
9721
  | `RealtimeClient` | _type_ | validated client inferred from a realtime contract |
@@ -9146,6 +9730,12 @@ The browser-and-server entrypoint. Re-exports everything from
9146
9730
  | `RealtimeRequestTimeoutError` | class | stable `REALTIME_REQUEST_TIMEOUT` rejection |
9147
9731
  | `RealtimeRequestDisconnectedError` | class | stable `REALTIME_REQUEST_DISCONNECTED` rejection, including an immediate disconnected call |
9148
9732
  | `RealtimeRequestInvalidAcknowledgementError` | class | invalid ack was reported through `onRejected` and the request rejected |
9733
+ | `RealtimeRequestRejectedError` | class | the peer refused the frame against its own contract and said so — `reason`, `issues` — instead of leaving the sender to time out ([ADR 0106](../decisions/0106-a-refused-frame-answers-its-sender.md)) |
9734
+ | `REALTIME_REJECTION_KEY` | const | the reserved acknowledgement key a refusal travels under |
9735
+ | `RealtimeRejectionEnvelope` | _type_ | the wire shape of a refusal |
9736
+ | `RealtimeRejectionReport` | _type_ | what the sender is told: event, reason, message, issues |
9737
+ | `RealtimeRejectionIssue` | _type_ | one refused field, already flattened (`path: '0.v'`) |
9738
+ | `asRealtimeRejection` | function | recognise a refusal in an acknowledgement value, validating it |
9149
9739
  | `RealtimeContract` | _type_ | shared server-to-client and client-to-server event registries |
9150
9740
  | `RealtimeEventRegistry` | _type_ | string-keyed registry of event definitions |
9151
9741
  | `RealtimeEventDefinition` | _type_ | one tuple-shaped event and optional acknowledgement schema |
@@ -9159,6 +9749,7 @@ The browser-and-server entrypoint. Re-exports everything from
9159
9749
  | `ValidatedRealtimeSocket` | _type_ | runtime-validating `on`/`emit` surface inferred from registries; `emit` returns "accepted by the transport" (`false` only for a client-side disconnected drop) |
9160
9750
  | `RetainedTopics` | _type_ | the `createRetainedTopics` handle |
9161
9751
  | `ParseSSEOptions` | _type_ | options for `parseSSE` |
9752
+ | `ParseNDJSONOptions` | _type_ | options for `parseNDJSON` |
9162
9753
 
9163
9754
  ### Trace (client)
9164
9755
 
@@ -9414,6 +10005,10 @@ Also re-exports the error helpers from `stitchkit/contract`.
9414
10005
  | Export | Kind | Summary |
9415
10006
  |--------|------|---------|
9416
10007
  | `streamSSE` | function | an async generator → SSE `Response` — [guide](../guide/server.md#sse-streaming) |
10008
+ | `streamingRoute` | function | a long-lived subscription route: idle timeout, heartbeat, opening flush, cancellation — [guide](../guide/server.md#long-lived-subscriptions) |
10009
+ | `ndjsonRoute` | function | `streamingRoute` framed as NDJSON |
10010
+ | `sseRoute` | function | `streamingRoute` framed as SSE |
10011
+ | `DEFAULT_STREAM_HEARTBEAT_MS` | const | 5000 — deliberately well under Bun's ten-second idle threshold |
9417
10012
  | `parseSSE` | function | parse an SSE `Response` (also on the root entrypoint) |
9418
10013
  | `MultipartLifecycle` | _type_ | request-scoped rollback ownership for accepted streamed handles |
9419
10014
  | `MultipartResult` | _type_ | what `parseMultipart` returns |
@@ -9449,6 +10044,9 @@ Also re-exports the error helpers from `stitchkit/contract`.
9449
10044
  | `RateLimitConfig` | _type_ | config for `createRateLimiter` |
9450
10045
  | `ClientIpOptions` | _type_ | trusted-proxy config for `extractIp` / `resolveSocketIp` |
9451
10046
  | `ParseSSEOptions` | _type_ | options for `parseSSE` |
10047
+ | `StreamingRouteOptions` | _type_ | options for `streamingRoute` / `ndjsonRoute` / `sseRoute` |
10048
+ | `StreamingSourceContext` | _type_ | what a streaming source is given, including the cancellation `signal` |
10049
+ | `StreamingFormat` | _type_ | `'ndjson' \| 'sse'` |
9452
10050
 
9453
10051
  ### OpenAPI
9454
10052
 
@@ -9479,6 +10077,7 @@ cutovers are covered by the executable
9479
10077
  | `ApplicationResourceFailure` | _type_ | one resource failure with the cause its phase label cannot carry — delivered to `onResourceFailure` |
9480
10078
  | `ApplicationResourcePhase` | _type_ | the phase a managed resource failed in — the vocabulary of `ApplicationResourceShutdown.failures` |
9481
10079
  | `ApplicationShutdownOptionsSchema` / `ApplicationShutdownOptions` | schema / _type_ | the two shutdown budgets and an abort signal — without the HTTP-only `retryAfterSeconds` |
10080
+ | `ApplicationShutdownBudgetSchema` / `ApplicationShutdownBudget` | schema / _type_ | the same two budgets without a signal — `ApplicationConfig.shutdown`, the default for `shutdown()` and the only budget a failed startup's rollback can read |
9482
10081
  | `ActivityTokenBrand` | const | the brand symbol `ActivityToken` carries, exported so `ActivityProjection` is implementable |
9483
10082
  | `defineManagedResource` | function | retain the exact typed resource declaration; every invoked start is rollback-eligible |
9484
10083
  | `managedServerResource` | function | adapt an existing managed server without copying its HTTP/WebSocket shutdown machine |
@@ -9597,7 +10196,7 @@ Server-only optional application runtime. See the
9597
10196
  | `defineModelRegistry` | function | typed language-model descriptors, capabilities and provider construction |
9598
10197
  | `composeAgentPrompt` | function | ordered prompt contributions and provenance-aware context budget |
9599
10198
  | `structuredCompaction` | function | summarize a provider-valid snapshot range and replace it through CAS |
9600
- | `createAgentSessionCoordinator` | function | strict process-local queue/interrupt lifecycle |
10199
+ | `createAgentSessionCoordinator` | function | strict process-local queue/interrupt/supersede lifecycle |
9601
10200
  | `AgentRuntimeStopPolicy` | _type_ | named custom AI SDK stop condition persisted and published on policy stop |
9602
10201
  | `AgentRuntimePrepareStep` | _type_ | per-run controlled step callback with typed domain context and managed run signal/fence |
9603
10202
  | `AgentRuntimeRecordIds` | _type_ | optional caller-provided input, run and assistant IDs for stable application records |
@@ -9608,7 +10207,7 @@ Server-only optional application runtime. See the
9608
10207
  | `AgentRuntimeConflictError` | class | thrown when a store mutation loses to a concurrent writer — catchable by type from `stitchkit/agent-runtime` |
9609
10208
  | `AgentSessionCloseOptions` | _type_ | `gracePeriodMs` for natural settlement, then abort, then `forceTimeoutMs` for bounded settlement after it |
9610
10209
  | `AgentSessionCloseResult` | _type_ | what `close()` achieved: `settled`, or `timedOut` with `remaining` runs still in flight. Only omitting `forceTimeoutMs` guarantees nothing is in flight on return |
9611
- | `AgentHistoryProjectionOptions` | _type_ | storage-neutral file resolver and explicit unresolved-file behavior |
10210
+ | `AgentHistoryProjectionOptions` | _type_ | storage-neutral file resolver, explicit unresolved-file behavior, and how an interrupted turn reaches the model (`interruptedAssistant`) |
9612
10211
  | `createAgentToolFenceLifecycle` | function | pre-effect and post-effect run ownership fence for `mountAgent` |
9613
10212
  | `AgentRuntimeEventSchema` | schema | transient stream lifecycle plus post-commit admission/checkpoint/run-state/terminal projections |
9614
10213
  | `createAgentObservability` | function | separate agent-run sink over the shared bounded observability lifecycle |