Repository navigation
stitchkit v0.105.0
⚠️ Breaking changes
Who must act: consumers who bundle stitchkit/files, stitchkit/process or contained-file
tools for Darwin and relied on the bundler embedding or copying the native addon, and callers
of withExclusiveLock that depended on age-based reclaim of an empty lock file; code that names
a removed runtime-tool type, imports stitchkit/agent-runtime/harness-tools or compares agent
control error codes; callers that send a JSON array as text to a string-or-array tool field;
code that constructs an AppError or an ApiError with more than its code; build scripts
that read sourcePath from a createNativePackaging asset, pass it a platform other than
'darwin' or parse native-assets.json; and
operators of a diagnostic journal that must not start over damaged retained files, code that
constructs or reads DiagnosticJournalRecoveryError, and protocols that embed
DiagnosticJournalRecoveryStatusSchema; and callers of runNativeCommand that pass killGraceMs
or rely on a helper in the command's process group outliving the command;
and code that imports publishCli or its types from stitchkit/cli.
-
stitchkit/cli—publishClimoves to the new evolving entrypointstitchkit/cli/publish.
publishCli,CliPublicationOptions,CliPublicationPhaseandCliPublicationResultare no
longer exported fromstitchkit/cli, with no re-export left behind. Publication is release
infrastructure whose limits, staging and recovery are still being found, and the stable CLI
could not hold its shape; the manifest, signatures, installer and updater it writes for stay in
stitchkit/cli, which remains stable. The call, its options and its result are unchanged.
// before: import { publishCli, type CliPublicationOptions } from 'stitchkit/cli'→
// after: import { publishCli, type CliPublicationOptions } from 'stitchkit/cli/publish'
Who must act: code that importspublishClior aCliPublication*type from
stitchkit/cli. See migration.
→ ADR 0245 -
stitchkit/process—killGraceMsbecomes thestoppolicy. How a cancelled command
stops is one named object:target'group'(the signal goes to every member, as before) or
'leader'(cooperative cancellation: only the leader is signalled, its exit ends the grace and
whatever it left in the group gets KILL),signal(SIGTERMby default, orSIGINT,SIGHUP,
SIGQUIT,SIGUSR1,SIGUSR2),graceMsup to one hour (was 10 000 ms), andkillOn, an
AbortSignalthat ends any grace with KILL to the whole group. The output pipes stay open
through the grace and its output is dropped, so a leader writing while it shuts down does not
meet a broken pipe. The process surface ceiling grows by one name,NativeCommandStopPolicy.
// before: runNativeCommand({ …, killGraceMs: 500 })→
// after: runNativeCommand({ …, stop: { target: 'group', graceMs: 500 } })
Who must act: callers that passkillGraceMs. → ADR 0243 -
stitchkit/process— a command's leftover group members are stopped after its leader
exits.runNativeCommandtakesdescendants: 'terminate-after-leader' | 'leave', and
omitting it means'terminate-after-leader': once the leader has exited, whatever it left in
the command's process group is stopped by thestoppolicy before the pipes drain, so a
command no longer leaks its helpers or waits for one that holds its output pipe. A real daemon
leaves the group withsetsidand is unaffected.'leave'keeps a helper that has to outlive
the command.
// before: runNativeCommand({ …, timeoutMs }) (the leader's helpers kept running)→
// after: runNativeCommand({ …, timeoutMs, descendants: 'leave' })
Who must act: callers whose command starts a helper in its own process group that must
keep running after the command returns. See
migration.
→ ADR 0246 -
stitchkit/server,stitchkit/files,stitchkit/process— bundles no longer emit Darwin
addons. The packaged Darwin loader names its addon by a computed path, so a Bun bundle of
these entrypoints is one JS file with no.nodeoutput on every operating system, and
bun build --outfileworks again for Linux server apps. An artifact that must carry the
Darwin addon is built with thestitchkit/files/packagingplugin; without it the bundle looks
for the addon beside the original package files and reports the backendunavailable
elsewhere, never a dead owner.
// before: bun build src/native.ts --compile --outfile=dist/native (addon embedded)→
// after: Bun.build({ …, plugins: [createNativePackaging({ platform: 'darwin', architecture: process.arch, delivery: 'embedded', entryPath, assetPath }).plugin] })
Who must act: Darwin packagers of bundled or compiled artifacts using these entrypoints.
Linux and portable bundles need no change. See migration.
→ ADR 0235 -
stitchkit/files/packaging—createNativePackagingchecks each addon against a digest the
package published, and returns the verified bytes.native-assets.jsonis now written when
Stitchkit is built, atformatVersion: 2, with thepath,sizeandsha256of every Darwin
addon present then; the reader refuses any other format version. Before,sha256was computed
from whatever file sat innode_modules, so an addon substituted after installation got its own
"original" digest. Each selected addon is now read once and compared by size and SHA256; a
difference returns{ state: 'mismatch', code: 'NATIVE_ASSET_DIGEST_MISMATCH', architecture, expected: { size, sha256 }, actual: { size, sha256 } }for the first differing addon, and an
architecture the manifest does not list ismissing.platformis the closed enum'darwin':
another platform is a type error and a schema refusal that throws, no longer anunsupported
result, which now names only an unsupported architecture. A ready asset dropssourcePathand
carriesbytes(the verified bytes) and the publishedsizeandsha256; embedded delivery
gives Bun those same bytes instead of reading the file again. Call it once per build and reuse
the result.
// before: copyFileSync(asset.sourcePath, join(outdir, asset.outputPath)) (asset: { sourcePath, outputPath, sha256 })→
// after: writeFileSync(join(outdir, asset.outputPath), asset.bytes) (asset: { outputPath, size, sha256, bytes })
// before: native-assets.json { formatVersion: 1, assets: { arm64: 'native/darwin-arm64.node' } }→
// after: native-assets.json { formatVersion: 2, assets: { arm64: { path, size, sha256 } } }
// before: createNativePackaging({ platform: target.platform, … }) (non-Darwin → 'unsupported')→
// after: if (target.platform === 'darwin') createNativePackaging({ platform: target.platform, … })
Who must act: build scripts that readasset.sourcePath, re-hash the addon themselves,
switch over the refusalcode/state, parsenative-assets.json, or pass aplatformother
than the literal'darwin'.
See migration.
→ ADR 0241 -
stitchkit/files—withExclusiveLockpublishes its lock with the owner already recorded,
andownerlessGraceMsdefaults tonull. The owner record is written to a temporary file
and hard-linked to the lock name, so the name never exists without an owner and a stalled
holder can no longer be displaced after the former 5 000 ms default. An empty lock file
left by an older writer is reclaimed by age only when asked for:
// before: withExclusiveLock(path, run) (an empty lock older than 5 s was taken)→
// after: withExclusiveLock(path, run, { ownerlessGraceMs: 5_000 }) (only for legacy empty files)
The lock directory must support hard links, and a holder killed mid-publication leaves a
.lock-*.tmpsibling that the next reclaim removes.
Who must act: callers that relied on the age-based reclaim of an empty or unreadable lock
file, and locks on a filesystem without hard links; every other caller is unaffected.
See migration.
→ ADR 0238 -
stitchkit/tools/connections— atimeoutMsormaxResponseBytesthat is not a positive
integer within range throws aRangeErrorinstead of falling back to the default. This
shipped in 0.104.0 without a breaking entry.
// before: timeoutMs: 0 (the default applied silently)→
// after: timeoutMs: 0 (RangeError; omit the option to take the default)
Who must act: callers that passed 0, a negative, a fractional or an oversized limit.
See migration. -
stitchkit/testing—serializeSurfaceValueis removed. It was a second name for the
package's one canonical serializer.canonicalJsonfromstitchkit/primitivesis that
serializer, bounded and with a typed refusal:
// before: serializeSurfaceValue(manifest)→
// after: canonicalJson(manifest, { maxBytes: 16 * 1024 * 1024 })
Who must act: tests that callserializeSurfaceValue.
See migration.
→ ADR 0199, ADR 0238 -
stitchkit/agent-runtime—canonicalAgentJsonfollows the one admissibility table of
canonicalJson. A value nested deeper than 100 levels is refused with a
CanonicalJsonError, and anundefinedobject member is omitted instead of failing. It still
reads a value asz.json()did, so-0hashes as0and an own__proto__member is dropped
(the publiccanonicalJsonrefuses-0and keeps__proto__). Bytes of every value both
accepted are unchanged, so stored digests and archives still match.
// before: canonicalAgentJson({ a: undefined }) threw, a 101-level value was accepted→
// after: canonicalAgentJson({ a: undefined }) === '{}', a 101-level value throws (reason 'depth')
Who must act: code that puts a value nested deeper than 100 levels into agent store events,
archives or projections. See
migration. -
stitchkit/application—DirectoryInboxsets a hard-linked entry file aside as
invalid, andaccepttakes the schema's input type. A dropped file with more than one
link is never trusted, because another name can still rewrite it (since 0.104.0, without a
changelog entry).DirectoryInboxConfiggains the schema input type as a second type
parameter, andDirectoryInboxandDirectoryInboxResourcetake that input type as their one
parameter (it was the entry type); for a schema without a transform the two are equal and
nothing changes.
// before: accept({ ...identity, entry: { amount: 42 } }) (the output of z.string().transform(Number))→
// after: accept({ ...identity, entry: { amount: '42' } }) (the input; it is stored as given and parsed on delivery)
Who must act: producers that drop hard links into an inbox directory, and callers that pass
acceptthe output of a transforming schema.
See migration. -
stitchkit/application—createDiagnosticJournalchecks only the last line of each file
when it opens (startupScan: 'tails', the default), so opening no longer parses and
validates every line of every retained generation. The scan still reads each file once in
large blocks to count lines, so opening still takes time proportional to the bytes retained,
at read speed.recoveryin the status no longer reports a damaged row in the
middle of a file unless the journal is opened withstartupScan: 'full'.
// before: createDiagnosticJournal({ path, eventSchema, limits }) (every line of every file was parsed)→
// after: createDiagnosticJournal({ path, eventSchema, limits, startupScan: 'full' }) (the same full scan)
Who must act: operators that readrecoveryat startup to find damaged rows in the middle
of a file. See migration. -
stitchkit/application/diagnostic-journal,stitchkit/application— opening a diagnostic
journal moves a file it cannot keep in place aside and starts, instead of refusing to start.
A torn active file withmaxFiles: 1, a generation name that is not a regular file and a
retained file that cannot be read are renamed to<file>.quarantined-<epoch>beside the
journal (onStartupRefusal: 'quarantine', the default) and never deleted. Every open lists
every quarantined file beside the journal ingetStatus().recovery.quarantineduntil the
operator removes it: the ones it moved first, with theirreason, then earlier ones without
one; at most 32, withquarantinedUnlistedcounting the rest.
onStartupRefusal: 'fail'keeps refusing, now always with
one typedDiagnosticJournalRecoveryErrorwhosereasonis
'torn-without-retention-slot' | 'not-a-regular-file' | 'unreadable'; its constructor takes
an options object andrecoveryis present only for a torn single slot.
DiagnosticJournalRecoveryStatusSchemagains the optionalquarantinedand
quarantinedUnlistedfields; the schema is.strict(), so a reader parsing with an older copy
refuses a status that carries them.
// before: createDiagnosticJournal({ path, eventSchema, limits }) (threw on a damaged or unreadable retained file)→
// after: createDiagnosticJournal({ path, eventSchema, limits, onStartupRefusal: 'fail' }) (the same refusal, typed)
Who must act: operators of a journal that is a source of truth and must not start over
damage (declare'fail'); code that constructsDiagnosticJournalRecoveryErroror reads its
recovery; protocols that embedDiagnosticJournalRecoveryStatusSchemaand their readers.
See migration.
→ ADR 0240 -
stitchkit/cli,stitchkit/tools,stitchkit/tools/mcp— one runtime-tool declaration.
RuntimeToolExecution,RuntimeToolExecutionWithOutput(cli),RuntimeMcpToolDefinition,
RuntimeMcpToolDefinitionWithOutput(tools/mcp) andToolSurfaceDefinition(tools) are removed.
RuntimeToolDefinition,RuntimeToolDefinitionWithOutputandRuntimeToolDefinitionWithoutOutput
are the same neutral, peer-free types on all three entrypoints, and the presenters are the
adapter's extension: the fourth type argument ofRuntimeToolDefinitionWithOutput
(RuntimeMcpToolPresenters<…>ontools/mcp,RuntimeToolPresenters<…>ontools), or
defineRuntimeTool, which fixes it.RuntimeToolDefinitionis the one registration type, and an
inline handler written inruntimeTools: [...]is typed again instead of failing with TS2339 on
never.ToolSurfaceProjectionis the one surface container.
// before: satisfies RuntimeToolExecutionWithOutput<typeof input, typeof output>→
// after: satisfies RuntimeToolDefinitionWithOutput<typeof input, typeof output>
// before: satisfies RuntimeMcpToolDefinitionWithOutput<typeof input, typeof output>→
// after: satisfies RuntimeToolDefinitionWithOutput<typeof input, typeof output, undefined, RuntimeMcpToolPresenters<z.output<typeof output>>>
Who must act: code that names a removed type, and code that annotates a definition with
presenters asRuntimeToolDefinitionWithOutput<I, O>fromstitchkit/tools(usedefineRuntimeTool
or pass the presenter type as the fourth argument). Values returned bydefineRuntimeTooland
createRuntimeToolFactoryneed no change.
See migration.
→ ADR 0236, ADR 0238 -
stitchkit/tools,stitchkit/tools/mcp— a flattened union keeps declaration order.
WithflattenUnionInput: true, properties, enum values and branch labels are advertised in the
order of their first appearance across the variants instead of alphabetically, so the bytes of
every flattened tool listing (and any provider prompt cache keyed on them) change once.
Alternatives of different kinds (anyOf) keep their one canonical order.
// before: union([{ kind: 'b', z: 1 }, { kind: 'a', a: 1 }]) → properties kind, a, z; kind enum a|b→
// after: the same union → properties kind, z, a; kind enum b|a
Who must act: consumers with snapshot tests or caches of flattened tool schemas, and
anyone usingflattenUnionInput: true: regenerate the snapshots.
See migration.
→ ADR 0238 -
stitchkit/tools,stitchkit/cli— a list written as text is refused in every
string-or-array field.coerceJsonArgs, and so every tool surface, throws a validation error
naming the field when a union that keeps a string member receives a string that parses to an
array the union's array member accepts. Before,'["a","b"]'inz.union([z.string(), z.array(z.string())])was read as one plain string. Theview_file-only refusal is this rule;
a list written as text as an element of the array is no longer refused by name.
// before: { paths: '["a.png"]' } (the string "[\"a.png\"]" reached the handler)→
// after: { paths: ['a.png'] } (the text form is VALIDATION_ERROR: "paths is a list written as text")
Who must act: callers that send a JSON array as text to a string-or-array field; a plain
string, including one that is not a list the array member accepts, is unchanged.
→ ADR 0218, ADR 0239, ADR 0238 -
stitchkit/agent-runtime— thestitchkit/agent-runtime/harness-toolsleaf is removed. It
was onemountAgentcall with the run's fence composed in; no consumer imported it.
// before: tools: createAgentHarnessTools(({ context }) => ({ services: [svc], context }))→
// after: tools: ({ context, toolFenceLifecycle }) => mountAgent([svc], { context, lifecycle: composeToolLifecycle(undefined, toolFenceLifecycle) })
Who must act: imports ofstitchkit/agent-runtime/harness-tools. See
migration. -
stitchkit/agent-runtime/realtime,stitchkit/agent-runtime/browser— control errors use one
vocabulary, and the binding authorizes requests, not events.ACCESS_DENIEDisFORBIDDEN, and
a closed connection answersCONNECTION_CLOSEDinstead of a denial.AgentControlErrorCode
(AgentControlErrorCodeSchema) is the closed set every response andAgentControllerState.error
carries.authorizereceivessource: 'request' | 'delivery'and runs once per request; the
events of an attached conversation flow under the grant that request created until
binding.revoke(conversationId, matches?)drops it, and a refusal then reaches the browser as an
access-denieddelivery that puts the controller inerror(FORBIDDEN). A reconnect rejects
the previous connection's requests so a smallmaxPendingRequestscannot leave the controller in
connecting. Both leaves are evolving and experimental.
// before: if (state.error?.code === 'ACCESS_DENIED') …→
// after: if (state.error?.code === 'FORBIDDEN') …
Who must act: code that compares control error codes, and applications that revoke access while
a stream is open (callbinding.revoke; before, every event re-askedauthorize). -
stitchkit/contract,stitchkit/server,stitchkit/node—AppErrortakes its fields by
name. The constructor had seven positions, so a call that set onlyretryableread
new AppError(code, message, 409, undefined, undefined, undefined, true). The code stays first;
everything else is one options object, andnew AppError(code)still works:
// before: new AppError('CONFLICT', 'stale', 409, { revision: 7 }, 'reload', undefined, false)→
// after: new AppError('CONFLICT', { message: 'stale', status: 409, details: { revision: 7 }, hint: 'reload', retryable: false })
The options aremessage,status(default 500),details,hint,traceId,retryableand
the standardcause. A subclass callssuper(code, { message, status })the same way.
Who must act: code that constructs anAppErroror extends it with more than the code.
ThenotFound,badRequest,unauthorized,forbidden,conflictandrateLimitedhelpers
are unchanged. See migration.
→ ADR 0238 -
stitchkit—ApiErrortakes its fields by name. The constructor had eight positions
and call sites ended inundefined, undefined, retryable. The code stays first and the rest
is one options object;new ApiError(code)still works:
// before: new ApiError('RATE_LIMITED', 429, undefined, 'slow down', 'wait', 'trace-1', { cause }, true)→
// after: new ApiError('RATE_LIMITED', { status: 429, message: 'slow down', hint: 'wait', traceId: 'trace-1', retryable: true, cause })
The options arestatus(default 0),details,message,hint,traceId,retryableand
cause. Who must act: code that constructs anApiError, which is mostly tests and
custom transports; code that only readscode,status,details,hint,traceId,
retryableorcauseis unaffected. See
migration.
→ ADR 0238
Added
stitchkit/files—isAtomicStagingName(name)andsweepAtomicStaging({ directory, olderThanMs, signal })recognise and remove the staging files that an atomic write killed
before publication leaves beside its target. The staging name.stitchkit-<24 lowercase hex>.tmpis now a stable public contract: one module produces it forwriteFileAtomic,
writeFileAtomicSync, the managed writer, the chunk spool and the agent runtime's contained
write, and changing it is a breaking change. The sweep removes only regular files older than
the requiredolderThanMs, never follows a symlink and never enters a subdirectory; sweeping
abandoned staging is the caller's responsibility. The files surface ceiling grows by three
names. → ADR 0242stitchkit/primitives—canonicalJsonrefuses with aCanonicalJsonError(aTypeError)
whosereasonis'cycle' | 'negative-zero' | 'depth' | 'nodes' | 'bytes' | 'not-json'
(CanonicalJsonRefusal), so an exceeded limit is told apart from a value that is not JSON.
The primitives surface ceiling grows by two names and the testing ceiling shrinks by one.stitchkit/application—FileStateStoreOptions.maxBytesbounds the state read (default
64 MiB); a larger file is refused before it is parsed and is not reported as corruption.
DiagnosticJournalConfig.startupScan: 'tails' | 'full'chooses what opening reads.
Documentation
stitchkit/observability: request rows,runUnitOfWorkrows and schedule status carry no
per-operation CPU, by decision (ADR 0244). Aprocess.cpuUsage()delta across an operation's
wall-clock interval absorbs every interleaved operation and timer, and Bun gives no async
segment boundary to measure correctly. The guide
(CPU is not attributed to work)
states the process-CPU window you can observe and the rule for building attribution yourself.- 0.103.13 shipped two breaking changes as a patch: the Darwin native-asset delivery and the
refusal of repeated non-array CLI options. The upgrade guide now carries a migration for each
(native assets,
repeated options).release:check
refuses a patch whose notes carry any breaking marker.
Fixed
stitchkit/agent-runtime:probeAgentProcessSandboxnever rejects. Aprobe()that throws
synchronously, rejects or returns something that is not a grade resolves to
{ grade: 'unavailable', reason }with a fixed reason ('sandbox probe failed'or
'sandbox probe returned no valid grade'), cached like any grade untilrefresh: true. The
cause (an adapter error may carry absolute paths) stays out of the grade and is emitted as a
process warning. A synchronous throw used to escape raw and was not cached. The probe cache is
keyed by the sandbox object, so a sandbox is expected to be long-lived.stitchkit/agent-runtime/coding-tools: a sandboxprepare()that throws refuses the command
withSANDBOX_UNAVAILABLEand the fixed reason'sandbox prepare failed'; the adapter's error
message no longer reaches the agent. The original error of a failedprepare()orprobe()
rides on the refusal as itscause, which the mount'shooks.onToolError(and
afterToolCall'serror) receives, so an application with a structured log records it
there; the tool envelope carries only the code and the fixed reason, and the coding tools emit
no process warning.probeAgentProcessSandboxcalled directly still warns, because it
returns only the grade.stitchkit/process: a group that reportedESRCHis not sent SIGKILL afterwards, so a
recycled group id is never signalled.stitchkit/agent-runtime: a stopped coding command is classified by a typed stop reason
instead of its message text; the sandbox terminates a direct child's group without wrapping it
in a placeholder command, and a slot whose cleanup was not proven stays occupied (documented).stitchkit/application: aDirectoryInboxentry whose name is already inrejected/no
longer stops the pass withEEXIST. Identical bytes are the entry already set aside and the
new file is only removed; different bytes are kept under<name>.<first 16 hex of SHA-256>.
A failure of one entry's own file is reported throughonErrorand the pass continues with the
next entry, and an entry that changes during its read or is not a regular file is released
for a later attempt instead of throwing out of the pass.stitchkit/application:createFileStateStorecommits nothing for a transition whose
resulting state encodes to the bytes it started from (the directory inbox passes over
already-receipted files without a single write), and reads and fences the lock fewer times per update. Every update still reads and
rewrites the whole state, so keepretainof the directory inbox as small as duplicate
detection needs.stitchkit/cli/publish:publishCliremoves.publish-*staging directories left by a publisher that
died mid-build, under its publication lock, so they no longer fill the directory entry cap.
Each stored version is decompressed once per publication instead of up to five times.stitchkit/files: one descriptor open and one atomic staging sequence serve every managed
read and write.rejectSymlinksalone decides whether a symlink leaf is followed, a
too-large file reports its observation whatever its link count, and the managed writer stages
through a hidden.stitchkit-<random>.tmpfile whose length does not depend on the target name. The synchronous atomic write has the same
fault controls as the asynchronous one.stitchkit/remote:implementRemotekeeps the status and the retryability of a remote failure
that carries no stitchkit error envelope. A503page, a429or a404becomesHTTP_ERROR
with that status and a fixed message, and a refused connection becomesCONNECTION_REQUEST_FAILED
(502); neither carriesdetails, so the upstream body, URL and credentials stay in the raw cause.
Before, both becameINTERNAL_SERVER_ERROR500, which saysretryable: false. One rule decides
retryablefor every origin: the declared value, else the status (408included, so a deadline the
remote client hit is retryable like a local408; a cancelled call staysfalse).stitchkit/cli: command help prints only the bounds a schema declares. An integer prints
<integer>instead of[>=-9007199254740991, <=9007199254740991];.min(1)prints>=1and
.positive()prints>0. Repeating-hor--helpin any spelling shows help instead of an
argument error.stitchkit/telegram: a broadcast caps its own backoff between transient failures at
maxRetryDelayMs(computed by the shared backoff formula) and halts only when the provider's
retry-afterexceeds that limit. WithmaxRetryDelayMs: 5000the fourth transient failure used
to halt the whole broadcast.stitchkit/agent-runtime: the realtime binding'sauthorizehook gets one deadline shared
with every other bounded wait, and deliveries that arrive while it is deciding share its one
answer instead of asking again.- Limit options that are not positive safe integers are refused with one message that ends in the
value received, institchkit/application,stitchkit/files,stitchkit/server,
stitchkit/agent-runtime,stitchkit/primitivesandstitchkit/process.