stitchkit 0.91.2 → 0.93.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 (86) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/dist/agent-runtime-coding-tools.js +3 -3
  3. package/dist/agent-runtime-harness.js +5 -5
  4. package/dist/agent-runtime-sandbox.js +3 -3
  5. package/dist/agent-runtime.js +8 -8
  6. package/dist/cli.d.ts +6 -3
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +377 -49
  9. package/dist/contract/define.d.ts +84 -2
  10. package/dist/contract/define.d.ts.map +1 -1
  11. package/dist/contract/index.d.ts +1 -1
  12. package/dist/contract/index.d.ts.map +1 -1
  13. package/dist/{index-a2nzz56k.js → index-2c2bjw3s.js} +3 -3
  14. package/dist/{index-seyy8dvn.js → index-3bmnp41g.js} +245 -116
  15. package/dist/{index-axvavfr4.js → index-bx75zwt7.js} +3 -3
  16. package/dist/{index-709nh7z7.js → index-cxqhvq21.js} +2 -1
  17. package/dist/{index-pena034b.js → index-e42wzzz5.js} +3 -3
  18. package/dist/{index-qjeyx5t2.js → index-fq77yfzc.js} +1 -1
  19. package/dist/{index-fenaekmk.js → index-h9etkkyk.js} +37 -27
  20. package/dist/{index-4x809vqd.js → index-mzvgxp0n.js} +2 -2
  21. package/dist/{index-jpfqfg5c.js → index-n0f51dfn.js} +2 -2
  22. package/dist/{index-bv7mytab.js → index-sr93cwf9.js} +1 -1
  23. package/dist/{index-vsbzgd7b.js → index-tmwcrptx.js} +1 -0
  24. package/dist/{index-8z9we758.js → index-vjpvjqpn.js} +1 -1
  25. package/dist/{index-7hz88dy8.js → index-vk3kq9sb.js} +1 -1
  26. package/dist/{index-s3dssqxp.js → index-yn8remb1.js} +60 -56
  27. package/dist/internal/atomic-file.d.ts +21 -0
  28. package/dist/internal/atomic-file.d.ts.map +1 -0
  29. package/dist/internal/secure-fetch.d.ts +12 -0
  30. package/dist/internal/secure-fetch.d.ts.map +1 -1
  31. package/dist/node.js +3 -3
  32. package/dist/observability/audit.d.ts +5 -1
  33. package/dist/observability/audit.d.ts.map +1 -1
  34. package/dist/observability/changes.d.ts.map +1 -1
  35. package/dist/observability/context.d.ts +27 -6
  36. package/dist/observability/context.d.ts.map +1 -1
  37. package/dist/observability/event.d.ts +34 -6
  38. package/dist/observability/event.d.ts.map +1 -1
  39. package/dist/observability/index.d.ts +2 -1
  40. package/dist/observability/index.d.ts.map +1 -1
  41. package/dist/observability/index.js +50 -6
  42. package/dist/observability/work.d.ts +55 -0
  43. package/dist/observability/work.d.ts.map +1 -0
  44. package/dist/server/context-contribution.d.ts.map +1 -1
  45. package/dist/server/index.js +4 -4
  46. package/dist/server/types.d.ts +20 -6
  47. package/dist/server/types.d.ts.map +1 -1
  48. package/dist/testing/surface-manifest.d.ts +34 -1
  49. package/dist/testing/surface-manifest.d.ts.map +1 -1
  50. package/dist/testing.d.ts +1 -1
  51. package/dist/testing.d.ts.map +1 -1
  52. package/dist/testing.js +31 -9
  53. package/dist/tool-invoker.js +5 -5
  54. package/dist/tools/cli-checkpoint.d.ts +24 -0
  55. package/dist/tools/cli-checkpoint.d.ts.map +1 -0
  56. package/dist/tools/cli-command.d.ts +18 -0
  57. package/dist/tools/cli-command.d.ts.map +1 -1
  58. package/dist/tools/cli-format.d.ts +10 -0
  59. package/dist/tools/cli-format.d.ts.map +1 -1
  60. package/dist/tools/cli-invoke.d.ts +143 -0
  61. package/dist/tools/cli-invoke.d.ts.map +1 -0
  62. package/dist/tools/cli-manifest.d.ts +6 -9
  63. package/dist/tools/cli-manifest.d.ts.map +1 -1
  64. package/dist/tools/cli-signature.d.ts +81 -0
  65. package/dist/tools/cli-signature.d.ts.map +1 -0
  66. package/dist/tools/cli-stream.d.ts +93 -0
  67. package/dist/tools/cli-stream.d.ts.map +1 -0
  68. package/dist/tools/cli-update.d.ts +62 -0
  69. package/dist/tools/cli-update.d.ts.map +1 -1
  70. package/dist/tools/cli.d.ts +4 -16
  71. package/dist/tools/cli.d.ts.map +1 -1
  72. package/dist/tools/connections/index.js +2 -2
  73. package/dist/tools/execute.d.ts +21 -0
  74. package/dist/tools/execute.d.ts.map +1 -1
  75. package/dist/tools/mcp-progress.d.ts +46 -0
  76. package/dist/tools/mcp-progress.d.ts.map +1 -0
  77. package/dist/tools/mcp-round-policy.d.ts +25 -1
  78. package/dist/tools/mcp-round-policy.d.ts.map +1 -1
  79. package/dist/tools/mcp-round.d.ts +20 -1
  80. package/dist/tools/mcp-round.d.ts.map +1 -1
  81. package/dist/tools/mcp-trace.d.ts +9 -1
  82. package/dist/tools/mcp-trace.d.ts.map +1 -1
  83. package/dist/tools/mount-wait.d.ts.map +1 -1
  84. package/dist/tools.js +159 -50
  85. package/llms-full.txt +449 -8
  86. package/package.json +1 -1
package/llms-full.txt CHANGED
@@ -66,11 +66,11 @@ own, recorded as an ADR.
66
66
  | `stitchkit/geo` | server (Bun or Node) | evolving | managed GeoIP reader generations, last-known-good reload and the optional MaxMind adapter |
67
67
  | `stitchkit/oauth` | browser **and** server | evolving | provider-neutral Authorization Code + PKCE request and one-shot callback transaction mechanics |
68
68
  | `stitchkit/google` | server (Bun or Node) | evolving | optional Google code exchange and verified OIDC identity adapter |
69
- | `stitchkit/observability` | server | stable<br>_redefined in 1 of the 36 minors since 0.56.2, most recently 0.83.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
69
+ | `stitchkit/observability` | server | stable<br>_redefined in 2 of the 38 minors since 0.56.2, most recently 0.92.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
70
70
  | `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, plus the store and managed-resource conformance kits |
71
71
  | `stitchkit/declaration` | browser + build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
72
72
  | `stitchkit/react` | browser + server rendering | stable | `createCursorQuery`, `createCacheBridge`, QueryClient and `ApiError` retry policy |
73
- | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 20 of the 36 minors since 0.56.2, most recently 0.89.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
73
+ | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 20 of the 38 minors since 0.56.2, most recently 0.89.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
74
74
  | `stitchkit/agent-runtime/sandbox` | server | evolving | optional Linux Bubblewrap sessions, durable workspaces and a host HTTP credential gateway; see [sandbox guide](sandbox.md) |
75
75
  | `stitchkit/agent-runtime/testing` | tests on Bun or Node | evolving | credential-free replay, scripted provider faults and deterministic race controls |
76
76
  | `stitchkit/agent-runtime/harness` | server | evolving | resource-aware process-local facade over the canonical Agent runtime; supervision stays outside |
@@ -80,7 +80,7 @@ own, recorded as an ADR.
80
80
  | `stitchkit/agent-runtime/sqlite/bun` | server (Bun) | evolving | durable built-in SQLite store for the agent runtime |
81
81
  | `stitchkit/agent-runtime/sqlite/node` | server (Node ≥ 22.5) | evolving | durable built-in SQLite store for the agent runtime |
82
82
  | `stitchkit-tui` | terminal (Bun) | evolving | optional official OpenTUI host over a caller-composed headless runtime |
83
- | `stitchkit/application` | browser + server | evolving<br>_redefined in 7 of the 36 minors since 0.56.2, most recently 0.83.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
83
+ | `stitchkit/application` | browser + server | evolving<br>_redefined in 7 of the 38 minors since 0.56.2, most recently 0.83.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
84
84
  | `stitchkit/application/grammy` | server | evolving | isolated grammY polling and webhook lifecycle adapters |
85
85
  | `stitchkit/application/opentelemetry` | server | evolving | maps application snapshots onto an injected OpenTelemetry `Meter` |
86
86
  | `stitchkit/application/schemas` | browser + server | evolving | the application's snapshot, health and shutdown schemas alone, without the kernel |
@@ -3639,6 +3639,48 @@ never for authentication, RBAC, tenant selection or rate limiting. Verified
3639
3639
  identity still comes from `auth`; the `context(auth)` callback remains the
3640
3640
  application-context factory and receives no protocol metadata.
3641
3641
 
3642
+ ### Telling the host what a long call is doing — `ctx.reportProgress`
3643
+
3644
+ A tool that takes ten minutes tells a text host nothing: the call is made, and
3645
+ the next thing the host shows is the answer. The protocol has a channel for
3646
+ this — the host sends a `progressToken` with the call, the server relates
3647
+ progress notifications to it — and a handler reaches it through
3648
+ `ctx.reportProgress`:
3649
+
3650
+ ```ts
3651
+ render: async (ctx) => {
3652
+ await ctx.reportProgress?.({ message: 'queued' })
3653
+ const job = await jobs.start(ctx.input)
3654
+ await ctx.reportProgress?.({ message: 'rendering', progress: 1, total: 3 })
3655
+ return await jobs.finish(job)
3656
+ }
3657
+ ```
3658
+
3659
+ `reportProgress` is present on every **tool** call — MCP, agent and CLI — so a
3660
+ handler never branches on transport, and it does nothing wherever nobody is
3661
+ listening. It is absent on the HTTP transport, which has no channel for it, so a
3662
+ handler that also serves HTTP writes `ctx.reportProgress?.(…)`. It never throws and never
3663
+ rejects: a message about work must not be able to kill the work it describes, so
3664
+ a refused notification leaves the host exactly where a host that never asked for
3665
+ progress already is.
3666
+
3667
+ `progress` is a number the protocol requires and a handler often does not have —
3668
+ there is a stage to name and no scale to name it on. Omit it and the ordinal of
3669
+ the update is sent, with no `total`, so the host shows a counter rather than a
3670
+ bar stuck at 3%. That is a fact about what happened; a synthesised percentage
3671
+ would be a claim nobody measured.
3672
+
3673
+ The framework sends nothing on its own. Progress is meaningful only where an
3674
+ operation has observable stages, and manufacturing one per call would be
3675
+ inventing a second transport beside the one the protocol already provides. The
3676
+ one exception is the managed wait tool (`mountWait`), which already polls and
3677
+ therefore already knows: when the host asked for progress it reports each poll
3678
+ and the phase it last saw, which is the tool whose silence raised the question
3679
+ in the first place.
3680
+
3681
+ The token itself is on `ctx.mcp.progressToken` when a caller wants to know
3682
+ whether anyone is listening.
3683
+
3642
3684
  ### Multi-round tool input (`input_required`)
3643
3685
 
3644
3686
  An MCP-only operation can require typed user input before its final side effect:
@@ -3690,6 +3732,58 @@ ordinary unsupported result rather than a falsely advertised round. Signed
3690
3732
  state is tamper-resistant, not an exactly-once replay store; destructive
3691
3733
  handlers must still be idempotent where retries matter.
3692
3734
 
3735
+ #### Questions chosen from the arguments
3736
+
3737
+ The list above is fixed when the contract is written, which is right when the
3738
+ questions belong to the operation. It is not enough when they belong to the
3739
+ **arguments** — one model takes `aspect_ratio`, another `duration`, a third an
3740
+ input image — and there a declared list can only be empty. `inputRequired` also
3741
+ accepts a function of the parsed call:
3742
+
3743
+ ```ts
3744
+ mcp: {
3745
+ inputRequired: async ({ input }) => {
3746
+ const model = await catalog.get(input.model)
3747
+ return model.needsRatio
3748
+ ? [{ key: 'ratio', schema: RatioSchema, message: 'Which aspect ratio?' }]
3749
+ : []
3750
+ },
3751
+ }
3752
+ ```
3753
+
3754
+ It receives the same `params` / `input` the handler receives — parsed, so
3755
+ defaults and coercions are already applied. Returning an empty array means this
3756
+ call needs nothing and runs straight through. Everything else is unchanged: the
3757
+ state is still signed and bound to principal, operation and argument digest, the
3758
+ rounds are still counted against `maxRounds`, and a resolved list with duplicate
3759
+ keys or more rounds than allowed is refused.
3760
+
3761
+ **The plan is fingerprinted, and that matters more than it looks.** Between two
3762
+ rounds there is a full round trip to the host, and a resolver that reads
3763
+ anything outside its arguments — a model catalog, a feature flag — can answer
3764
+ differently the second time. Every other check would still pass: same principal,
3765
+ same operation, same arguments, round still in range. Round 2's question would
3766
+ be asked under round 1's key, and if the schemas happened to be compatible the
3767
+ user's answer would be accepted for a question nobody asked. So the resolved
3768
+ list is fingerprinted into the signed state and re-checked every round; a plan
3769
+ that moved is refused, not asked. A resolver that reads only its arguments never
3770
+ sees this.
3771
+
3772
+ **What a dynamic declaration costs.** One extra pass through the contract
3773
+ pipeline per round — `lifecycle.beforeHandle` runs once more and one more
3774
+ `toolPhase: 'input-round'` audit row is written. That is the price of ordering:
3775
+ the resolver is your code and may reach a network, so it runs **after**
3776
+ authorisation, never before, and the only thing that can authorise is a pipeline
3777
+ pass. Resolving itself is free — the arguments are parsed by the same pure
3778
+ function the call parses with, not by a second run of anything. A fixed list
3779
+ pays none of this; its call sequence is unchanged.
3780
+
3781
+ One thing does change for a fixed list: the signed round state now carries a
3782
+ fingerprint of the question plan, and it is checked on every round. A
3783
+ conversation already in flight when you deploy this has no fingerprint in its
3784
+ state and is refused once with `INVALID_REQUEST_STATE`; the host re-asks and the
3785
+ retry succeeds.
3786
+
3693
3787
  ### Guarding tools — `lifecycle`
3694
3788
 
3695
3789
  A tool call runs the same handler an HTTP request would. `lifecycle` makes it
@@ -7976,6 +8070,223 @@ Carry the build stamp inside the binary (`CliBuildStampSchema`,
7976
8070
  `formatCliBuildStamp`) so the tool can say what it is rather than leaving the
7977
8071
  reader to infer it from behaviour.
7978
8072
 
8073
+ #### A manifest inside your own network
8074
+
8075
+ The check goes through the same SSRF boundary every outbound fetch does, so a
8076
+ manifest served from `10.x`, `localhost` or a `.internal` name is refused and
8077
+ the answer is `unknown`. For a self-hosted deployment that is a wrong answer
8078
+ dressed as a careful one, and the refusal says so — it names the field that
8079
+ lifts it rather than reading like a timeout:
8080
+
8081
+ ```text
8082
+ refusing to fetch an internal host — the manifest endpoint is not public.
8083
+ Set `allowPrivateHosts` if it is your own deployment; the download keeps its
8084
+ own setting, because an asset URL comes from the document.
8085
+ ```
8086
+
8087
+ ```ts
8088
+ const check = await checkCliUpdate({ manifestUrl, currentVersion, allowPrivateHosts: true })
8089
+ ```
8090
+
8091
+ Set it from whether **your** endpoint is private, not as a blanket switch, and
8092
+ leave `applyCliUpdate` at its default. The two are not the same decision: the
8093
+ manifest URL is configuration you wrote, while an asset URL arrives inside a
8094
+ document the endpoint served, and a document is exactly what an SSRF boundary
8095
+ exists to distrust.
8096
+
8097
+ ### Proving who built it — signing the manifest
8098
+
8099
+ The asset digest proves the bytes that arrived are the bytes the manifest named.
8100
+ It proves nothing about who named them: the manifest and the assets come from
8101
+ one origin, so whoever can replace one can replace the other. Authorship needs a
8102
+ key the build carries and the server never holds.
8103
+
8104
+ ```ts
8105
+ // Publishing — the only side that holds a private key.
8106
+ const signature = signCliManifest(manifest, { keyId: 'release-2026', privateKey })
8107
+ publish({ ...manifest, signature })
8108
+
8109
+ // Installing — the trust root is compiled into the binary. One fetched at run
8110
+ // time from the same origin as the manifest would prove nothing.
8111
+ const trust = { keys: { 'release-2026': PUBLISHED_KEY } }
8112
+ const check = await checkCliUpdate({ manifestUrl, currentVersion, trust })
8113
+ if (check.status === 'outdated' && check.asset) {
8114
+ await applyCliUpdate({ asset: check.asset, manifest: check.manifest, trust, backupPath })
8115
+ }
8116
+ ```
8117
+
8118
+ The signature covers `{name, version, commit, builtAt, assets[]}` with **every
8119
+ asset's digest**, so the chain closes on the file that will execute rather than
8120
+ on the document describing it; moving an asset to a new URL does not invalidate
8121
+ it, changing what an asset contains does.
8122
+
8123
+ Five verdicts, and `unenforced` is the one that earns its keep: a build with no
8124
+ pinned key keeps updating, and the fact that nothing was checked is *visible*
8125
+ rather than assumed. `missing`, `unknown-key` and `invalid` are distinguished
8126
+ because they need different answers — a key you forgot to pin is not a forged
8127
+ signature.
8128
+
8129
+ A bad verdict does not produce a fifth status. `outdated` is an instruction to
8130
+ install, and a manifest that failed its signature has not established that there
8131
+ is a newer build worth installing — only that a document claims one. The check
8132
+ answers `unknown` with the verdict in its reason, so it is never mistaken for a
8133
+ network failure. `applyCliUpdate` checks again and refuses **before** the
8134
+ download: a signature verified after the bytes are on the machine produces a
8135
+ cleanup problem rather than a refusal.
8136
+
8137
+ ### A way back — `backupPath` and `rollbackCliUpdate`
8138
+
8139
+ Rolling back is a property of updating, not of the application:
8140
+
8141
+ ```ts
8142
+ const applied = await applyCliUpdate({ asset, targetPath, backupPath })
8143
+ // …the new build turns out to be wrong. `backupSha256` is absent when there was
8144
+ // nothing to keep — a first install — and that is the case with nothing to roll
8145
+ // back to:
8146
+ if (applied.backupSha256) {
8147
+ rollbackCliUpdate({ targetPath, backupPath, expectedSha256: applied.backupSha256 })
8148
+ }
8149
+ ```
8150
+
8151
+ The copy is taken between "the new bytes verified" and the replacement — earlier
8152
+ would preserve a binary about to be replaced by a download that then failed its
8153
+ digest, later has nothing left to copy — and it carries the target's file mode,
8154
+ because a backup that cannot be executed is not a way back. A first install has
8155
+ nothing to keep, which is not a failure.
8156
+
8157
+ `expectedSha256` is required, not optional. A rollback that installs whatever
8158
+ happens to be at the backup path is a second install of an unverified binary,
8159
+ and the day it is used is the day nobody is in a position to check.
8160
+
8161
+ ### Two release tracks
8162
+
8163
+ `assertCliPublishable` takes one manifest **or every manifest already
8164
+ published**:
8165
+
8166
+ ```ts
8167
+ assertCliPublishable([stableManifest, betaManifest], next)
8168
+ ```
8169
+
8170
+ With a single argument and two tracks the check is silently useless: `1.2.3`
8171
+ goes to beta from one commit and to stable from another, the function is called
8172
+ with the track being published and sees no conflict, and the person who
8173
+ installed the beta is told they are current forever. Nothing on their machine
8174
+ looks wrong.
8175
+
8176
+ **The channel itself is not an argument of the framework, and will not become
8177
+ one.** The framework has no model of how your URLs are built and should not: an
8178
+ asset's address comes from the manifest, and the manifest's address belongs to
8179
+ your server. A channel is two documents at two addresses — which of them a build
8180
+ consults is your decision, and `assertCliPublishable` is told about all of them
8181
+ rather than taught the shape of any.
8182
+
8183
+ ## One operation per line — `createCliInvoker`
8184
+
8185
+ The most common way an agent drives a CLI is not one command: it is a stream —
8186
+ a JSON line per operation, an answer per line — and a resumable batch of the
8187
+ same. The mechanics contain nothing product-specific, and until now the
8188
+ framework left the one piece that cannot be written outside it: running an
8189
+ already-parsed call.
8190
+
8191
+ ```ts
8192
+ import { createCliInvoker, defineCliStreamCommand, defineCliBatchCommand } from 'stitchkit/cli'
8193
+
8194
+ const invoker = await createCliInvoker({ name: 'myapp', services })
8195
+
8196
+ const outcome = await invoker.invoke('create_item', { title: 'hello', tags: ['a'] })
8197
+ // { ok: true, exitCode: 0, data: { … } } — nothing written, nothing exited
8198
+ ```
8199
+
8200
+ `invoke` runs the same pipeline a typed command runs — the same validation, the
8201
+ same `lifecycle` gate, the same hooks — and returns the handler's validated
8202
+ output with **the exit code the printed path would give**, from the same table.
8203
+ `data` is typed `unknown`: the surface is resolved at run time from services and
8204
+ runtime tools, so there is no compile-time name to key a result type on. Narrow
8205
+ it with the contract's own output schema. That table used to
8206
+ live inside the function that prints, so the only way to learn a code was to
8207
+ print it.
8208
+
8209
+ Without this, a stream loop had to re-spawn the binary per line: arguments
8210
+ serialised back into `--flag value` strings, nested objects pushed through
8211
+ `JSON.stringify` into one argv slot and parsed again on the other side, the
8212
+ result read back out of stdout text. Three conversions of data the framework was
8213
+ already holding, plus a process start measured at 0.15 s — thirty seconds for a
8214
+ two-hundred-line manifest before any work begins. The ban on nesting one stream
8215
+ inside another was a consequence of that child process, not a rule anyone wanted.
8216
+
8217
+ ### Native commands in the stream
8218
+
8219
+ A native command is in the invoker's surface when — and only when — it declares
8220
+ `output`:
8221
+
8222
+ ```ts
8223
+ const describe = defineCliCommand({
8224
+ name: 'describe', description: 'Describe the catalogue',
8225
+ input: z.object({ model: z.string() }),
8226
+ output: z.object({ model: z.string(), ratios: z.array(z.string()) }),
8227
+ handler: ({ input }) => ({ model: input.model, ratios: ['1:1', '16:9'] }),
8228
+ })
8229
+
8230
+ await createCliInvoker({ name: 'myapp', services, commands: [describe] })
8231
+ // invoke('describe', { model: 'flux' }) → { ok: true, exitCode: 0, data: { … } }
8232
+ ```
8233
+
8234
+ A command that declares `output?: never` prints itself and returns nothing, so
8235
+ there is no result to hand back; it answers `NOT_FOUND` and stays on the command
8236
+ line, where stdout exists. Your stream command is one of these, which is why a
8237
+ stream still cannot invoke itself.
8238
+
8239
+ On this path `present` is not called — it is stdout formatting, and there is no
8240
+ stdout — while `exitCode` is applied through the same function the command line
8241
+ uses, so a script branching on the code sees one answer from both. Anything the
8242
+ handler writes comes back as `written` rather than interleaving with your own
8243
+ output:
8244
+
8245
+ ```ts
8246
+ const outcome = await invoker.invoke('describe', { model: 'flux' })
8247
+ outcome.written // { stderr: 'warming the cache\n' } — absent when nothing was written
8248
+ ```
8249
+
8250
+ ### Mounting the loop
8251
+
8252
+ ```ts
8253
+ await createCli({
8254
+ name: 'myapp', version, services,
8255
+ commands: [
8256
+ defineCliStreamCommand({ name: 'jsonl', invoker }),
8257
+ defineCliBatchCommand({ name: 'batch', invoker, checkpointPath: '.myapp-batch.json' }),
8258
+ ],
8259
+ })
8260
+ ```
8261
+
8262
+ They are factories, not framework-owned names. Reserving `jsonl` and `batch`
8263
+ would take two names out of a namespace that belongs to the application — one
8264
+ that already has a `batch` command would either fail at startup or find its own
8265
+ command silently shadowed — so you mount them under whatever you call them, like
8266
+ any other command of yours.
8267
+
8268
+ Each line is `{ id, command, args }`; each answer is the invocation result plus
8269
+ that `id`. A line that is not JSON, or has no `id`, is **answered** and the
8270
+ stream continues: a stream that goes quiet on one line leaves its consumer
8271
+ unable to tell which request it lost. Answers go to stdout, one object per line,
8272
+ and nothing else does.
8273
+
8274
+ ### Resuming a batch
8275
+
8276
+ The checkpoint records, per id, a digest of the line that produced the recorded
8277
+ answer, and is written after **every** line by atomic rename — a checkpoint that
8278
+ only survives a clean finish protects against exactly the case that does not
8279
+ happen.
8280
+
8281
+ A re-run replays what already ran. A line whose content changed under the same
8282
+ id is **refused**: replaying it would report success for an operation nobody
8283
+ ran, and re-running it would repeat a paid call. Both answer a question nobody
8284
+ asked.
8285
+
8286
+ What stays yours: what makes a repeat safe (an idempotency key derived from the
8287
+ line id), which operations a batch may contain, and how much of it runs at once.
8288
+ That is product knowledge, and the framework has no business guessing it.
8289
+
7979
8290
  ## Commands discovered from a running server
7980
8291
 
7981
8292
  A CLI compiled from contracts carries the surface of the build it was compiled
@@ -10453,7 +10764,7 @@ export const observability = createObservability({
10453
10764
  write,
10454
10765
  includePayload: false, // default: no Request.clone(), payload is null
10455
10766
  includeCancelled: false, // default: keep client closes in access logs only
10456
- filter: (event) => event.method !== 'GET',
10767
+ filter: (event) => event.method !== 'GET', // see the note on jobs below
10457
10768
  },
10458
10769
  tools: {
10459
10770
  write,
@@ -10639,21 +10950,56 @@ queryable across all three:
10639
10950
 
10640
10951
  | Field | Notes |
10641
10952
  |-------|-------|
10642
- | `source` | `http` \| `mcp` \| `agent` |
10643
- | `method` / `path` | the verb + path, or `TOOL` + `/{source}/{tool}` |
10953
+ | `source` | `http` \| `mcp` \| `agent` \| `job` |
10954
+ | `kind` | `request` \| `job` — written on **every** row, so a filter never has to read it off an absent `method` |
10955
+ | `name` | what the work is, on a job (`agent-loop`, `broadcast-send`); absent on a request, which its method and path name |
10956
+ | `method` / `path` | the verb + path, or `TOOL` + `/{source}/{tool}` — **absent** on a job, which arrived over no transport |
10644
10957
  | `serviceName` / `action` | stable contract identity of the operation (→ ADR 0022) — from the contract, not parsed from `path`; set on every surface, present even on a pre-handler 400 |
10645
10958
  | `toolName` | tool calls only |
10646
10959
  | `httpMethod` | the contract verb on **tool** events (their `method` is `TOOL`) — filter reads vs writes across both surfaces with `(event.httpMethod ?? event.method) !== 'GET'` |
10647
10960
  | `dimensions` | app-defined domain dimensions (tenant / project / entity id) — see [request context](#request-context) |
10648
10961
  | `traceId` / `spanId` / `parentSpanId` | [W3C trace context](#trace-context) |
10649
10962
  | `outcome` | optional `'cancelled'` on explicitly enabled HTTP client-close rows; ordinary rows omit it |
10650
- | `ok` / `statusCode` | legacy success bit plus real HTTP status, or `200`/`400` for a tool; an opted-in cancellation is `false` / `499` |
10963
+ | `ok` / `statusCode` | legacy success bit plus real HTTP status, or `200`/`400` for a tool; an opted-in cancellation is `false` / `499`. `statusCode` is **absent** on a job — it had no transport to have a status on, and inventing one would be the same defect as the `method: 'AGENT'` this replaced. A job's outcome is `ok`, plus `errorCode` when it failed |
10651
10964
  | `durationMs` / `startedAt` | timing |
10652
10965
  | `errorCode` / `errorMessage` / `errorDetail` | failures only — `errorDetail` carries the structure the message flattens (e.g. Zod issues) |
10653
10966
  | `payload` | sanitised tool arguments; HTTP is `null` unless request `includePayload` is enabled |
10654
10967
  | `resultSize` / `responseBytes` | result item count + serialised size |
10655
10968
  | `userId` / `ipAddress` / `userAgent` | identity |
10656
10969
 
10970
+ ### Work that is not a request — `runUnitOfWork`
10971
+
10972
+ An agent loop launched fire-and-forget, a scheduled broadcast, a scenario engine
10973
+ tick: each has a start, a duration and an outcome, and none of them arrived over
10974
+ a transport. Run one inside a context and it is audited like everything else:
10975
+
10976
+ ```ts
10977
+ await runUnitOfWork({ name: 'broadcast-send', observability }, async () => {
10978
+ setRequestDimensions({ projectId })
10979
+ await send()
10980
+ })
10981
+ ```
10982
+
10983
+ The row carries `kind: 'job'`, the `name`, the duration and the outcome, and
10984
+ carries **no** `method`, `path` or `statusCode`, because the work had none. That
10985
+ absence is the point. The alternative is what consuming code had to do before —
10986
+ write `method: 'AGENT'` into the field for verbs — and a row that names a verb
10987
+ nobody used is worse than a row that names none, because a filter reading
10988
+ `method !== 'GET'` counts it as a write and nothing looks wrong.
10989
+
10990
+ Which is why **a filter on `method` needs a decision about jobs.** `auditChanges`
10991
+ makes it explicitly: a row with no verb is kept, because whether it changed
10992
+ anything cannot be answered from the row, and an audit that drops what it did not
10993
+ examine reports zero and looks exactly like a system in which nothing happened.
10994
+ A hand-written `event.method !== 'GET'` keeps jobs too (`undefined !== 'GET'`);
10995
+ `event.kind === 'request' && event.method !== 'GET'` is how you exclude them on
10996
+ purpose.
10997
+
10998
+ Without an `observability` the work still runs inside a context, so trace ids,
10999
+ `setRequestDimensions` and the bounded logger behave — recording is a separate
11000
+ decision from having a context. `traceparent` continues the trace of whatever
11001
+ scheduled the work, instead of starting an unrelated one.
11002
+
10657
11003
  ### Write the calls that changed something
10658
11004
 
10659
11005
  `auditChanges` is the filter most projects end up writing, shipped so they do not
@@ -12132,6 +12478,84 @@ analytics policy belong to the application.
12132
12478
 
12133
12479
  # Upgrading stitchkit
12134
12480
 
12481
+ ## Released migration: 0.93.0
12482
+
12483
+ 1. **Regenerate every committed surface snapshot, once.** `manifestVersion` is
12484
+ `3` and each operation row gained `mcp` — the rounds it declares before it
12485
+ runs. Most rows get `null`, so the first diff is large and mechanical.
12486
+ **Who must act:** anyone with a committed snapshot. A snapshot on the older
12487
+ version is refused by name rather than failing a schema literal:
12488
+
12489
+ ```text
12490
+ Surface snapshot is manifestVersion 2; this build writes 3. Regenerate it
12491
+ and review the diff — the format changed, so the first regeneration is
12492
+ expected to be large.
12493
+ ```
12494
+
12495
+ ```ts
12496
+ // before — a declared round left no trace, so declaring or removing one
12497
+ // passed review invisibly
12498
+ manifest.operations[0]
12499
+ // after
12500
+ manifest.operations[0].mcp
12501
+ // null | { inputRequired: [{ key, message, schema }] }
12502
+ // | { inputRequired: 'resolved-per-call' }
12503
+ ```
12504
+
12505
+ A project that kept its own test for "declared `inputRequired` implies a
12506
+ configured state key" can drop it: the snapshot holds that link now.
12507
+
12508
+ 2. **Nothing else to do.** Native commands that declare `output` became
12509
+ invokable in process; a name that answered `NOT_FOUND` from
12510
+ `createCliInvoker` in 0.92.0 may now resolve. If an application relied on
12511
+ native commands being unreachable there, stop passing them to the invoker.
12512
+
12513
+ ## Released migration: 0.92.0
12514
+
12515
+ 1. **`RequestEvent.method`, `path` and `statusCode` are optional.** Work that did
12516
+ not arrive over a transport now records their **absence** instead of inventing
12517
+ values — a background loop used to write `method: 'AGENT'`, which is not a
12518
+ verb. **Who must act:** anyone whose `RequestEventSinkConfig.write` puts these
12519
+ into NOT NULL columns, or whose filter reads `event.method`.
12520
+
12521
+ ```ts
12522
+ // before
12523
+ write: (event) => db.audit.create({ data: { method: event.method, path: event.path, status: event.statusCode } })
12524
+ // after — a job has none of the three; `kind` says which kind of row it is
12525
+ write: (event) => db.audit.create({ data: {
12526
+ kind: event.kind,
12527
+ name: event.name ?? null,
12528
+ method: event.method ?? null,
12529
+ path: event.path ?? null,
12530
+ status: event.statusCode ?? null,
12531
+ } })
12532
+ ```
12533
+
12534
+ A filter written as `event.method !== 'GET'` keeps jobs (`undefined !== 'GET'`),
12535
+ which is usually what you want. To exclude them deliberately:
12536
+ `event.kind === 'request' && event.method !== 'GET'`.
12537
+
12538
+ 2. **`applyCliUpdate` needs the manifest when given a trust root.** The signature
12539
+ covers the manifest and every asset digest together, so an asset alone cannot
12540
+ be verified. **Who must act:** only callers adopting signature verification;
12541
+ the unsigned call is unchanged.
12542
+
12543
+ ```ts
12544
+ // before
12545
+ await applyCliUpdate({ asset })
12546
+ // after
12547
+ await applyCliUpdate({ asset, manifest: check.manifest, trust })
12548
+ ```
12549
+
12550
+ 3. **An MCP continuation minted before this release is refused once.** The signed
12551
+ round state now carries a fingerprint of the question plan, checked on every
12552
+ round, so a plan that moved between rounds is a refusal rather than a
12553
+ different question asked under the previous one's key. A conversation already
12554
+ in flight at deploy time has no fingerprint and gets
12555
+ `INVALID_REQUEST_STATE`; the host re-asks and the second attempt succeeds.
12556
+ **Who must act:** nobody — but expect a small number of these in the minutes
12557
+ after a deploy, and do not read them as tampering.
12558
+
12135
12559
  ## Released migration: 0.91.0
12136
12560
 
12137
12561
  1. `STITCH_ERROR_STATUS` gained `NOT_IMPLEMENTED` (501). An **exhaustive** error
@@ -15832,6 +16256,10 @@ from the root `stitchkit`.
15832
16256
  | `HandlerContext` | _type_ | the typed context seen by a handler, including optional typed `mcp` metadata |
15833
16257
  | `McpCallContext` | _type_ | validated metadata for the active managed MCP call (`era`, method, tool, client and multi-round fields) |
15834
16258
  | `McpClientInfo` | _type_ | self-reported MCP host name/version; attribution only, never application identity |
16259
+ | `McpInputRequiredCall` | _type_ | the parsed `params` / `input` an elicitation resolver chooses its questions from |
16260
+ | `McpInputRequiredResolver` | _type_ | `mcp.inputRequired` as a function of the parsed arguments — returns the rounds for this call, or none |
16261
+ | `McpProgressUpdate` | _type_ | one mid-call progress update — `message`, and `progress`/`total` when the operation knows a scale |
16262
+ | `McpReportProgress` | _type_ | `ctx.reportProgress` — sends one update to the host that asked for progress; a no-op when none did, and never throws |
15835
16263
  | `McpRoundOutcome` | _type_ | managed multi-round attempt outcome |
15836
16264
  | `EndpointHandlerContext` | _type_ | one endpoint handler's fully inferred params, input, files and runtime context |
15837
16265
  | `EndpointFn` | _type_ | the call signature of one client method |
@@ -16949,6 +17377,8 @@ audit event. See the [Observability guide](../guide/observability.md).
16949
17377
  | `DEFAULT_REDACT_PATHS` | constant | baseline credential/token paths added to the sanitizer's sensitive-key policy |
16950
17378
  | `BoundedLoggerBounds` / `BoundedLoggerOptions` | _type_ | per-value and total record ceilings (`stringLengthByKey` raises the string bound for named keys such as `stack`) plus sink/redaction configuration |
16951
17379
  | `RequestEvent` | _type_ | the normalised audit event handed to the sink; opt-in HTTP cancellation rows carry `outcome: 'cancelled'` |
17380
+ | `runUnitOfWork` / `RunUnitOfWorkOptions` | function / _type_ | run work that did not arrive over a transport — an agent loop, a scheduled broadcast — inside one context, with the same completion record a request gets |
17381
+ | `RequestContextKind` | _type_ | `'request'` \| `'job'` — what the row describes, written on every event so a filter never has to read it off an absent `method` |
16952
17382
  | `ObservabilityConfig` | _type_ | independent request and tool sink configuration |
16953
17383
  | `Observability` | _type_ | `{ request?, toolCall, getStatus(), flush(bound?): Promise<boolean>, close(bound?) }` with bounded sink lifecycle |
16954
17384
  | `ObservabilityDrainBound` | _type_ | `timeoutMs` and/or `signal` limiting how long `flush`/`close` wait; the wait ends, outstanding writes do not |
@@ -17391,6 +17821,8 @@ handler pipeline without opening a TCP port.
17391
17821
  | `TransportObservation` | _type_ | validated normalized driver result |
17392
17822
  | `SurfaceManifest` / `SurfaceManifestConfig` | _type_ | deterministic surface snapshot and its inputs |
17393
17823
  | `SurfaceManifestOperation` / `SurfaceManifestOperationSchema` | _type_ / schema | one contract or runtime operation row |
17824
+ | `SurfaceManifestOperationMcp` / `SurfaceManifestOperationMcpSchema` | _type_ / schema | the rounds an operation declares before it runs: a fixed list, `'resolved-per-call'`, or `null` for no MCP policy |
17825
+ | `SURFACE_MANIFEST_VERSION` | const | the format version this build writes; a committed snapshot on an older one is refused by name |
17394
17826
  | `SurfaceManifestTool` / `SurfaceManifestToolSchema` | _type_ / schema | one mounted tool row with advertised input digest |
17395
17827
  | `SurfaceManifestToolSurface` / `SurfaceManifestToolSurfaceSchema` | _type_ / schema | one static transport projection, optionally keyed for a finite MCP surface |
17396
17828
  | `SurfaceManifestRealtimeEvent` / `SurfaceManifestRealtimeEventSchema` | _type_ / schema | one named directional realtime event with input/output args and ack digests |
@@ -17690,7 +18122,11 @@ SDK nor the `ai` peer.
17690
18122
  | `renderCliInstaller` | function | generate the one-line installer from a build manifest, with the URL and digest substituted — [guide](../guide/cli.md#distribution-and-self-update) |
17691
18123
  | `checkCliUpdate` | function | bounded, interval-limited, never-throwing check for a newer published build |
17692
18124
  | `applyCliUpdate` | function | download, verify the decompressed digest and replace the binary by rename |
17693
- | `assertCliPublishable` | function | refuse republishing one version from a different commit |
18125
+ | `assertCliPublishable` | function | refuse republishing one version from a different commit — takes one manifest or every manifest already published, because the promise a version makes does not stop at a channel boundary |
18126
+ | `rollbackCliUpdate` / `CliRollbackConfig` / `RolledBackCliUpdate` | function / _types_ | put the kept previous build back, digest-checked first and written by the same atomic rename the update uses |
18127
+ | `signCliManifest` / `verifyCliManifest` / `cliManifestSigningPayload` | function | Ed25519 over `{name, version, commit, builtAt, assets[]}` — every asset's digest included, so the chain closes on the file that executes |
18128
+ | `cliSignatureAccepted` / `CliSignatureVerdict` | function / _type_ | `valid` \| `unenforced` \| `missing` \| `unknown-key` \| `invalid`; `unenforced` keeps an unpinned build updating while making the absence of a check visible |
18129
+ | `CliTrustRoot` / `CliSigningKey` / `CliBuildSignature` / `CliBuildSignatureSchema` | _types_ / schema | the keys a build trusts, by id, and the detached signature a manifest carries |
17694
18130
  | `selectCliBuildAsset` | function | the asset for one target, or `undefined` |
17695
18131
  | `currentCliBuildTarget` | function | `{ platform, arch }` of the running process |
17696
18132
  | `formatCliBuildStamp` | function | one line saying what the running build is |
@@ -17709,6 +18145,11 @@ SDK nor the `ai` peer.
17709
18145
  | `CliArgvRoute` | _type_ | `{ command, commandArgv, topLevelHelp, version, error? }` returned by `routeCliArgv` |
17710
18146
  | `pollUntilDone` | function | the generic `--wait` poller (advanced) |
17711
18147
  | `emitResult` | function | write a pretty or compact `ToolResult` record to stdout/stderr + exit code (advanced) |
18148
+ | `cliExitCode` | function | the exit code a result earns, from the same table, without writing anything — so an in-process run and the printed path cannot disagree |
18149
+ | `createCliInvoker` / `CliInvoker` / `CliInvokerConfig` / `CliInvocationResult` / `CliInvokerCommand` / `cliInvocationResult` | function / _types_ | compile the managed surface once and run parsed calls against it in process — no argv round-trip, no child process, no writing |
18150
+ | `defineCliStreamCommand` / `CliStreamCommandConfig` / `CliStreamAnswer` / `CliStreamHooks` / `runCliStream` | function / _types_ | one operation per JSON line of stdin, answered with the same `id`; a bad line answers and the stream continues |
18151
+ | `defineCliBatchCommand` / `CliBatchCommandConfig` | function / _type_ | the same loop, resumable: a recorded line replays, a line whose content changed under the same id is refused |
18152
+ | `readCliCheckpoint` / `writeCliCheckpoint` / `CliCheckpoint` / `CliCheckpointEntry` | function / _types_ | the batch's record — id plus a digest of the line, written by atomic rename after every line |
17712
18153
  | `DEFAULT_EXIT_CODES` | const | the default `ToolResult.code` → exit-code map |
17713
18154
  | `CliConfig` | _type_ | config for `createCli`; `defaultCommand`, `globalOptions`, `optionAliases` and `positionals` define the shared command presentation policy |
17714
18155
  | `CliPresentationPolicyConfig` | _type_ | shared command presentation-policy subset of `CliConfig` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.91.2",
3
+ "version": "0.93.0",
4
4
  "description": "Contract-first backend framework — one defineContract() into an HTTP API, MCP tools, AI-agent tools and a typed client. Bun and Node.",
5
5
  "keywords": [
6
6
  "bun",