@theokit/sdk 5.0.0-next.4 → 5.0.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 (48) hide show
  1. package/CHANGELOG.md +952 -0
  2. package/dist/{agent-IXRJYSBE.cjs → agent-5BJF255U.cjs} +6 -6
  3. package/dist/{agent-IXRJYSBE.cjs.map → agent-5BJF255U.cjs.map} +1 -1
  4. package/dist/{agent-L5Z6H53C.js → agent-HACVHN2V.js} +5 -5
  5. package/dist/{agent-L5Z6H53C.js.map → agent-HACVHN2V.js.map} +1 -1
  6. package/dist/{chunk-CYKKYNOT.js → chunk-7SZAV6QG.js} +4 -4
  7. package/dist/chunk-7SZAV6QG.js.map +1 -0
  8. package/dist/{chunk-A3FOEBYS.js → chunk-M3QNTHMV.js} +3 -3
  9. package/dist/{chunk-A3FOEBYS.js.map → chunk-M3QNTHMV.js.map} +1 -1
  10. package/dist/{chunk-RXZSVNDG.cjs → chunk-NQTNSHSB.cjs} +7 -3
  11. package/dist/chunk-NQTNSHSB.cjs.map +1 -0
  12. package/dist/{chunk-WKUBCBWS.js → chunk-OXQSNOYV.js} +4 -4
  13. package/dist/{chunk-WKUBCBWS.js.map → chunk-OXQSNOYV.js.map} +1 -1
  14. package/dist/{chunk-J2SH3PNH.js → chunk-SADXXGWU.js} +6 -3
  15. package/dist/chunk-SADXXGWU.js.map +1 -0
  16. package/dist/{chunk-VNDJS6I3.cjs → chunk-VPK6PHIE.cjs} +8 -8
  17. package/dist/{chunk-VNDJS6I3.cjs.map → chunk-VPK6PHIE.cjs.map} +1 -1
  18. package/dist/{chunk-OOEKESSL.cjs → chunk-VVJ7FQAQ.cjs} +19 -19
  19. package/dist/{chunk-OOEKESSL.cjs.map → chunk-VVJ7FQAQ.cjs.map} +1 -1
  20. package/dist/{chunk-DX4RUWOK.js → chunk-WS5ULCL4.js} +3 -3
  21. package/dist/{chunk-DX4RUWOK.js.map → chunk-WS5ULCL4.js.map} +1 -1
  22. package/dist/{chunk-NNA3HA6T.cjs → chunk-WWZO2TTK.cjs} +4 -4
  23. package/dist/{chunk-NNA3HA6T.cjs.map → chunk-WWZO2TTK.cjs.map} +1 -1
  24. package/dist/{chunk-KBXADFBJ.cjs → chunk-Z2JFX372.cjs} +15 -15
  25. package/dist/chunk-Z2JFX372.cjs.map +1 -0
  26. package/dist/cron.cjs +5 -5
  27. package/dist/cron.js +4 -4
  28. package/dist/eval.cjs +4 -4
  29. package/dist/eval.js +3 -3
  30. package/dist/{index-manager-OL6HTYAO.cjs → index-manager-3UNPYH34.cjs} +5 -5
  31. package/dist/{index-manager-OL6HTYAO.cjs.map → index-manager-3UNPYH34.cjs.map} +1 -1
  32. package/dist/{index-manager-E56WW5RO.js → index-manager-W7FDMGEG.js} +4 -4
  33. package/dist/{index-manager-E56WW5RO.js.map → index-manager-W7FDMGEG.js.map} +1 -1
  34. package/dist/index.cjs +27 -27
  35. package/dist/index.js +8 -8
  36. package/dist/internal/memory/storage/index.cjs +34 -30
  37. package/dist/internal/memory/storage/index.d.cts +1 -1
  38. package/dist/internal/memory/storage/index.d.ts +1 -1
  39. package/dist/internal/memory/storage/index.js +2 -2
  40. package/dist/internal/memory/storage/memory-root.d.cts +17 -0
  41. package/dist/internal/memory/storage/memory-root.d.ts +17 -0
  42. package/docs/harness-capability-map.md +3 -1
  43. package/docs/memory-decisions.md +41 -20
  44. package/package.json +2 -2
  45. package/dist/chunk-CYKKYNOT.js.map +0 -1
  46. package/dist/chunk-J2SH3PNH.js.map +0 -1
  47. package/dist/chunk-KBXADFBJ.cjs.map +0 -1
  48. package/dist/chunk-RXZSVNDG.cjs.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,957 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 667bd3d: **BREAKING:** a project's `.claude/` directory is no longer read unless the consumer declares it.
8
+ Pass `local: { compatSources: ["claude-code"] }` to restore today's behaviour.
9
+
10
+ Four subsystems — hooks, skills, subagents and plugin bundles — resolved `<cwd>/.claude` alongside
11
+ `<cwd>/.theokit` with no opt-in anywhere. A directory containing only `.claude/`, and no
12
+ configuration of this SDK at all, had its hooks executed, its subagents registered, and its skill
13
+ text folded into the system prompt.
14
+
15
+ **Trust is not consent.** A consumer's trust gate answers "do I trust the code in this directory?",
16
+ and it was doing double duty as the answer to a different question: "do I want another product's
17
+ configuration imported into this one?" Those come apart in the ordinary case — `.claude/` is
18
+ populated in exactly the repository one trusts most, for a different tool, under a different
19
+ contract, often by a teammate who never heard of this SDK. The measured cost of conflating them was
20
+ the defect fixed one commit earlier: every turn denied by a `PreToolUse` hook nobody had declared.
21
+
22
+ The skills path is the quieter half. A skill's text enters the system prompt, so importing prompt
23
+ content from a directory this SDK does not own is a prompt-injection surface that no consumer opted
24
+ into and none could see.
25
+
26
+ A workspace holding an undeclared `.claude/` now says so once, on the diagnostics channel, naming
27
+ the directory and the line that turns it back on. It goes there rather than to stderr because
28
+ ignoring an undeclared directory is the intended behaviour, not a failure — every repository that
29
+ has Claude Code set up and does *not* want it imported would otherwise pay a line on a TUI host's
30
+ render surface for behaving as instructed.
31
+
32
+ An unrecognised name in `compatSources` is dropped rather than turned into `<cwd>/<name>`: a typo
33
+ must fail closed, since a directory name was never enough to describe a dialect.
34
+
35
+ ### Minor Changes
36
+
37
+ - 88e87d0: A foreign configuration source can now be admitted to some surfaces and not others.
38
+
39
+ `compatSources: ["claude-code"]` was all-or-nothing: declaring it admitted `.claude/` to hooks,
40
+ plugins, skills AND subagents at once. The four carry very different risk — a skill is text that
41
+ enters the system prompt, a plugin is code loading, a hook is command execution — so a consumer who
42
+ wanted to reuse the skills they had already written was handed arbitrary command execution along
43
+ with them, and had no way to say otherwise.
44
+
45
+ ```ts
46
+ local: {
47
+ compatSources: [{ kind: "claude-code", import: ["skills", "subagents"] }],
48
+ }
49
+ ```
50
+
51
+ Hooks and plugins then resolve `.theokit/` alone. `CompatSurface` and `CompatSourceAdapter` are
52
+ exported.
53
+
54
+ Three rules, each failing closed:
55
+
56
+ - **The bare `"claude-code"` string still admits every surface.** It is what `5.0.0-next.1`
57
+ published, and narrowing it silently would turn a working opt-in into a no-op that says nothing —
58
+ the defect this option exists to fix, one level up.
59
+ - **An adapter with no `import` list admits nothing.** Safe to apply strictly because the object
60
+ form is new and nobody can be relying on it yet.
61
+ - **An unrecognised surface name is dropped**, exactly as an unrecognised `kind` already is. A typo
62
+ must narrow access, never widen it.
63
+
64
+ The `plugins` surface governs reading a foreign plugin directory even when the caller wants the
65
+ SKILLS a bundle carries: a bundle is code, and its skills arrive attached to it, so admitting
66
+ `skills` alone must not reach inside one. Otherwise the narrower permission would silently grant
67
+ the wider one.
68
+
69
+ Closes the per-surface half of usetheokit/theokit-sdk#524. The visibility half — skills, subagents
70
+ and plugins carrying the root they came from, the way hooks already carry `sourcePath` — and the
71
+ declarative `.theokit/config.toml` form are not in this change.
72
+ - 33aa170: A project can now declare its foreign compat sources in `.theokit/config.json`, instead of only in
73
+ code.
74
+
75
+ ```json
76
+ {
77
+ "compat": {
78
+ "adapters": [{ "kind": "claude-code", "import": ["skills", "subagents"] }]
79
+ }
80
+ }
81
+ ```
82
+
83
+ The DECLARATIVE half usetheokit/theokit-sdk#524 asked for, in a `## Sketch` written against TOML.
84
+ It ships as JSON: this SDK already reads JSON everywhere a project declares something
85
+ (`settings.json`, `mcp.json`, `context.json`) and carries no TOML parser or dependency for one —
86
+ adding one for a single optional section would be the opposite of what #522/#524 are about, reading
87
+ in a new format nobody asked this SDK to speak. The shape is unchanged: `compat.adapters` accepts
88
+ exactly what `local.compatSources` already does in code — a bare kind string, or `{ kind, import }`.
89
+
90
+ **Precedence, decided here because the issue does not state it:** explicit `local.compatSources` in
91
+ code wins over the file. The file is the default for a caller who declared nothing. A test or a
92
+ one-off script can therefore always override the file without editing or deleting it.
93
+
94
+ Read with `readFileSync`, not this package's usual async reader: the caller is `Agent`'s
95
+ synchronous constructor, which resolves `compatSources` before any submanager exists to await a
96
+ promise. `existsSync` already runs in the same constructor for the same reason.
97
+
98
+ One resolver, `resolveCompatSources(options, cwd)`, replaces five call sites that each wrote
99
+ `options.local?.compatSources ?? []` by hand — the same duplication `theokitConfigRoot` closed one
100
+ layer down, closed here one layer up, so the file form reaches all four surfaces (hooks, skills,
101
+ plugins, subagents) through the one place rather than needing five separate edits that could drift.
102
+
103
+ Closes the declarative half of #524. `#524` itself stays open until it is verified in an installed
104
+ release, per this project's issue-lifecycle convention.
105
+ - 85b5c2f: A host can read the messages a session already contains
106
+
107
+ `readSessionMessages` was compiled into the package and missing from the type surface, so a
108
+ surface that repointed a session could not re-render it: the messages were on disk, the SDK
109
+ read them to give the model its context, and the screen stayed empty while the model
110
+ demonstrably remembered.
111
+
112
+ ```ts
113
+ import { readSessionMessages } from "@theokit/sdk";
114
+
115
+ const history = await readSessionMessages({ sessionId, cwd: projectDir });
116
+ for (const m of history) render(m.role, m.text);
117
+ ```
118
+
119
+ `cwd` defaults to `process.cwd()`, and `sessionDir` is only needed when the agent was created
120
+ with `local.sessionDir`. A session that was never written resolves to `[]` — a fresh session
121
+ has no history, which is not an error.
122
+
123
+ The internal reader takes a `SessionStore`; this one does not, so the transcript layout and
124
+ the record shape stay private. A host that already has a custom store can read from it
125
+ directly. (#546)
126
+ - edfa59c: `Agent` can now be asked which operations it supports, instead of being told by an exception.
127
+
128
+ `SDKAgent` is one handle over two runtimes that do not offer the same operations, and the type did
129
+ not model the difference. `downloadArtifact` is a **required** member that rejects for every input on
130
+ a local agent; `listArtifacts` is required and returns `[]` for every state, so "no artifacts" and
131
+ "this runtime has no artifacts" were the same value. On a cloud agent, five members declared
132
+ _optional_ are present-but-throwing — so `typeof agent.fork === "function"` is `true` and calling it
133
+ throws. Neither requiredness nor optionality expresses "exists here, not there", which left a caller
134
+ no way to branch except a `try`/`catch` around a call it did not want to make.
135
+
136
+ Two additive members answer the question first, mirroring `Run.supports(op)` /
137
+ `Run.unsupportedReason(op)`, which already solved this one layer down:
138
+
139
+ ```ts
140
+ if (agent.supports("downloadArtifact")) {
141
+ await agent.downloadArtifact(id);
142
+ } else {
143
+ logger.info(agent.unsupportedReason("downloadArtifact"));
144
+ }
145
+ ```
146
+
147
+ The new `AgentOperation` union is exported. Nothing was removed and no signature changed, so a
148
+ caller that never asks behaves exactly as before.
149
+
150
+ This is a mitigation. The structural fix is to split `SDKAgent` into a common core plus
151
+ `LocalCapableAgent` / `CloudCapableAgent`, so the compiler refuses the call rather than the runtime.
152
+ That is breaking on a published 4.x surface and is deliberately not done here.
153
+ - e7cf2dd: A skill, subagent or plugin now says which directory it was read from.
154
+
155
+ usetheokit/theokit-sdk#524 asks for it in one line — *"whatever is imported should be reportable […]
156
+ silent inheritance is what made this take a debugging session to notice"* — and a consumer listing
157
+ its own skills could not tell that one had arrived from `.claude/skills/` rather than the project's
158
+ own directory.
159
+
160
+ What is new is not the data. It existed on all three and did not reach the caller:
161
+
162
+ - `Skill.source` was already the absolute path to the `SKILL.md`, and the projection that builds
163
+ `agent.skills` mapped it away along with the body. The projection is right to drop the BODY —
164
+ that is what `get()` is for — and dropping the PATH with it answered a question nobody asked.
165
+ - `agent.plugins.list()` has always returned `source` at runtime; the internal type's own docblock
166
+ says it carries provenance "so callers can audit where the plugin came from". `SDKPluginMetadata`
167
+ simply never declared it, so the caller received the field and the compiler denied it existed.
168
+ - `readSubagentsFrom` computes the file path on the line it reads the file, then dropped it.
169
+ `AgentDefinition.source` keeps it.
170
+
171
+ `source` is optional on all three, and absence means something specific: declared in code, not read
172
+ from disk. A subagent passed through `AgentOptions.subagents` has no file and `source` is absent.
173
+ An inline `createSkill` skill has no file either, but already carried the synthetic `inline://<name>`
174
+ marker before this change (`create-skill.ts`) — so a skill's `source` is now populated for every
175
+ entry `list()` returns, either a disk path or that marker, and a first version of this fix wrongly
176
+ described it as absent for that case. An existing regression test (`agent-skills-get.test.ts`,
177
+ SE21) asserted `list()` must NOT carry `source` at all; it predates #524 and is updated here to
178
+ assert the marker instead, while still proving the skill's body and references never leak.
179
+
180
+ `SkillsHandle.list` is now typed as the public `SystemPromptSkillRef` instead of restating
181
+ `{ name; description }` inline. The two had drifted, and an internal handle declaring a narrower
182
+ shape than the contract it serves silently deletes fields the projection produces — which is exactly
183
+ how `source` reached the caller at runtime while not existing to the compiler.
184
+
185
+ Closes the visibility half of #524. The declarative `.theokit/config.toml` form is not in this
186
+ change.
187
+ - 912e3b9: Three silent downgrades now tell you they happened. Behaviour is unchanged; visibility is not.
188
+
189
+ **A failing `MemoryProvider` no longer disappears quietly.** `initLoopContext` caught every provider
190
+ failure into an empty value: an `init` failure meant no memory tool was registered, a `buildTools`
191
+ failure meant no provider tools, an `activePass` failure meant no recalled context in the system
192
+ prompt. The agent answered without the memory it was configured with, and nothing recorded it. There
193
+ is now a `memory_degraded` run event — new `RunMemoryDegradedEvent`, carrying the stage and the
194
+ provider's own message — alongside a stderr diagnostic, so a host can show "memory degraded" instead
195
+ of a healthy run. Degrading to a working agent is still what happens.
196
+
197
+ **The memory FTS fallback is gated on the case it was written for.** Any SQL failure used to become a
198
+ `LIKE '%query%'` scan over the whole table, returning plausible hits at a fixed score — so a corrupt
199
+ database, a missing FTS table and a disk error all looked like a successful search with worse
200
+ relevance. The fallback still runs, and a non-CJK failure now reports that the index may be missing
201
+ or corrupt.
202
+
203
+ **A `@theokit/sdk-memory` peer that fails to load says so.** Absent is expected and stays silent;
204
+ present-but-unloadable — a module-format interop failure, a broken native dependency, a bundler
205
+ rewrite — is reported instead of falling back to the legacy path in silence.
206
+ - 16a996f: Every error this SDK throws is now catchable as `TheokitAgentError`.
207
+
208
+ The README tells you to catch `TheokitAgentError`, and twenty-four exported error classes were not
209
+ one — they extended bare `Error`, so that catch silently missed them and none of them told you
210
+ whether the failure was worth retrying. Among them: `GenerateObjectError`, `StreamObjectError`,
211
+ `FileNotFoundError` and its four siblings, `SandboxSecurityError`, `SandboxNotAvailableError`, the
212
+ three `Auth*Error`s, `A2ARequestTimeoutError`, `MaxDelegationDepthError`, `WorkflowToolError`, and the
213
+ two errors on the `./interactive` subpath.
214
+
215
+ All twenty-four now extend `TheokitAgentError`, carry a `code`, and answer `isRetryable`. The answer
216
+ was decided per class rather than defaulted, and the reasoning is in the source. Two are retryable —
217
+ `A2ARequestTimeoutError` (a peer that missed one deadline may answer the next) and
218
+ `CompressionFailedError` (a single LLM call that failed or came back empty) — plus `FilesystemError`,
219
+ where the underlying I/O failure genuinely can be transient. The rest are not, and say why.
220
+
221
+ This is additive: `instanceof Error` still holds, every `code` value is unchanged, and no signature
222
+ moved. Code that already caught these by their specific class keeps working.
223
+
224
+ `generateObject` and `streamObject` also stop declaring the same failure contract twice with
225
+ byte-identical messages. They share one internal base class and keep their two distinct public names,
226
+ so `streamObjectError instanceof GenerateObjectError` remains false.
227
+ - 374dd5f: The `MemoryProvider` port is now the only memory path. `THEOKIT_PORT_MEMORY_PATH`
228
+ is gone, and the adapter over the built-in memory runs by default; a
229
+ consumer-supplied `memoryProvider` still takes precedence.
230
+
231
+ No typed API was removed — the flag was internal and never appeared in your
232
+ TypeScript types — but three behaviours change:
233
+
234
+ - **Recalled memory is now escaped before it reaches the model.** The port path
235
+ concatenated the recall summary into the system prompt raw, while the assembly
236
+ pipeline has always wrapped it as `<active-memory>` with XML escaping. A recalled
237
+ fact containing `</active-memory>` could close the block early and have everything
238
+ after it read as a system instruction. Both paths now wrap and escape.
239
+ - **Memory tools receive the run's abort signal and transcript projection.** They
240
+ arrive through the same channel as your own tools, so a long memory search is now
241
+ cancellable with the run.
242
+ - **`.theokit/memory` is no longer created by a send that never reaches the agent
243
+ loop** — a fixture-mode send with a `theo_test_*` key used to leave an empty
244
+ SQLite index behind. Real runs are unchanged: the index is created and searchable
245
+ exactly as before.
246
+ - 374dd5f: `MemoryProvider.buildTools(handle, agent)` now declares its second parameter as
247
+ `MemoryProviderAgentRef` — `{ agentId, model }` — which is all the SDK has ever
248
+ passed it.
249
+
250
+ It declared `SDKAgent`, a 33-member interface, and satisfied that with a cast over
251
+ a two-field object. Any implementation reaching for one of the other 31 members —
252
+ `send()`, `fork()`, `dispose()` — got `undefined is not a function` at runtime, with
253
+ no compile-time warning, because the cast removed exactly that check.
254
+
255
+ Non-breaking in both directions: an `SDKAgent` still satisfies the new type, and an
256
+ existing implementation typed `agent: SDKAgent` still compiles. `MemoryProviderAgentRef`
257
+ is exported from the package root so you can name it.
258
+ - 618cd02: `ctx.on(...)` now returns a disposer, so a plugin can detach one hook.
259
+
260
+ It returned `void`, which made the plugin Observer a one-way door: a handler attached through
261
+ `initialize()` had no removal path and ran for the life of the process. The only documented dynamic
262
+ case — a permission plugin re-installed on every prompt — worked because the registry keys plugins by
263
+ name, so re-registering the whole plugin was the only way to remove one hook.
264
+
265
+ ```ts
266
+ const off = ctx.on("pre_tool_call", handler);
267
+ // ...later
268
+ off();
269
+ ```
270
+
271
+ The disposer detaches the registration it was given — attaching the same function twice and disposing
272
+ once leaves one — and is idempotent. A handler the SDK refused (a non-function, which is warned and
273
+ ignored) still returns a working no-op disposer, so a caller never has to branch on whether the
274
+ registration took.
275
+
276
+ Two observers in the SDK already worked this way (`Run.onDidChangeStatus`, `MessageBus.unregister`);
277
+ this closes the gap. The new `PluginHookDisposer` type is exported.
278
+ - 31fea8f: `Retry.run(fn, options)` is the new name for `Retry.create(fn, options)`.
279
+
280
+ `create` never created anything — it runs `fn` with retry and resolves to `fn`'s
281
+ result — and the name said otherwise. It is deprecated, still honoured, and
282
+ removed in the next major.
283
+ - 691d8e6: `SandboxBackend`'s derived `glob`, `grep` and `listDir` now throw when the command could not run.
284
+
285
+ They returned `[]` on any non-zero exit, so a search that could not execute reported the same thing as
286
+ a search that found nothing — opposite facts, one normal and one meaning the agent is looking at a
287
+ filesystem it cannot read. That is the failure a backend whose `execute` is not a POSIX shell hits,
288
+ which the class docblock warns about in prose and could not enforce.
289
+
290
+ A genuine no-match still returns `[]`, and the distinction is the one the tools themselves draw:
291
+ `grep` exits 1 for no match and ≥2 for an error, `find` exits 0 with empty output.
292
+
293
+ If you have a custom backend that is not a POSIX shell and relied on these silently returning nothing,
294
+ they now throw a `ConfigurationError` with code `sandbox_derived_helper_failed`, telling you to
295
+ override them — which the docblock already asked for.
296
+ - 0ceeddc: `sanitizeToolInput` now reaches values inside arrays.
297
+
298
+ It never did. `{ tag: " a " }` came back trimmed and `{ tags: [" a "] }` came back untouched, with
299
+ nothing in the type or the documentation distinguishing them — the `@public` docblock on `deep` said
300
+ "recurse into nested objects/arrays", and array elements were not reached by any rung, including
301
+ `trim`, which is on by default.
302
+
303
+ Elements follow the same rules as fields: a string element is sanitized by whichever rungs are on, an
304
+ object element is descended only under `deep`. Array descent itself is not gated by `deep`, because a
305
+ value does not stop being a string by sitting in a list; `maxDepth` counts every hop and is what
306
+ bounds it. Arrays stay arrays.
307
+
308
+ If you were relying on array contents passing through a sanitizer untouched, they no longer do.
309
+ - 558dd30: `SessionStore` declares the three lifecycle hooks the SDK was already calling.
310
+
311
+ The port declared two methods, and the SDK probed for `acquire`, `release` and `dispose` through
312
+ `as unknown` casts. They worked — but nothing in the interface mentioned them, so a store author
313
+ implementing the documented two-method contract got no writer lease, no release and no disposal, with
314
+ no way to discover that those hooks existed.
315
+
316
+ They are now optional members with their contracts written down, including the one that matters:
317
+ a rejection from `acquire` whose `name` is `SessionBusyError` **propagates to the caller**, because
318
+ another process holding the session is a decision the caller has to make. Every other rejection is
319
+ treated as "no lease here" and the turn proceeds.
320
+
321
+ Optional means optional: an existing two-method store keeps working unchanged. What changes is that
322
+ the capability is now readable in the type you implement.
323
+ - 94722e8: `StructuredOutputError` distinguishes the causes it already knew apart.
324
+
325
+ Three different failures reported `no_tool_call`: an agent run that errored
326
+ before producing an answer, a run that was cancelled, and a tool-only completion
327
+ with no text to structure. Only the free-text message differed, so a caller could
328
+ not branch on which had happened without parsing English.
329
+
330
+ They are now `upstream_run_failed`, `run_cancelled` and `no_text_answer`.
331
+ `no_tool_call` keeps its original meaning — the model did not call the forced
332
+ output tool — and `parse_failed` is unchanged.
333
+
334
+ BEHAVIOUR CHANGE for a caller matching `no_tool_call`: three of the five cases it
335
+ used to catch now carry their own code. A caller that branched on it for a
336
+ cancelled run was branching on a defect, but the string it matched does change.
337
+
338
+ The union is exported as `StructuredOutputErrorCode`.
339
+ - ae2a720: `THEO.md` now works at the project root, the way `AGENTS.md`, `GEMINI.md` and `CLAUDE.md` already
340
+ do — found from the root, and reachable by walking up from any subdirectory.
341
+
342
+ Before this, `THEO.md` was the only context file this SDK knows about that had to live inside
343
+ `.theokit/` and could not be found from a subdirectory. The miss was silent: a well-formed
344
+ `THEO.md` at the project root was simply never read, with no warning that it had been ignored.
345
+
346
+ ```
347
+ project/
348
+ ├── THEO.md ← now read, walking up from any subdirectory
349
+ └── .theokit/
350
+ └── THEO.md ← still works exactly as before
351
+ ```
352
+
353
+ A new `DEFAULT_DISCOVERY_SPECS` entry (`THEO.md.root`, priority 55) is added alongside the
354
+ existing one — nothing about `.theokit/THEO.md` changes, so a project already using it keeps
355
+ working unchanged. When both exist, `.theokit/THEO.md` is the later, winning source on conflict —
356
+ the precedence usetheokit/theokit-sdk#531 asked for.
357
+
358
+ The new entry sets `followImports: true`, unlike `.theokit/THEO.md` (`false`) and unlike
359
+ `AGENTS.md`. This is deliberate, not an inconsistency: a root-level `THEO.md` is edited by the same
360
+ people, in the same place, as `CLAUDE.md`/`GEMINI.md` — the two other root-level files, both
361
+ `followImports: true` — so it belongs in their category rather than `AGENTS.md`'s vendor-neutral,
362
+ import-free one. Flagged as an open decision in the issue; resolved here because adding a new spec
363
+ rather than reusing the existing one meant it could be decided on its own merits, with zero
364
+ behaviour change for existing `.theokit/THEO.md` files either way.
365
+
366
+ Closes usetheokit/theokit-sdk#531.
367
+ - 266ffc8: Nine failures that used to arrive as a bare `Error` now carry a type and a code.
368
+
369
+ `docs/error-codes.md` says to branch on `code`, never on the message — messages carry context and
370
+ change with it. These nine gave you no code to branch on:
371
+
372
+ - `MessageBus.send` / `request` against an unregistered peer now reject with the new
373
+ `A2APeerNotRegisteredError` (`a2a_peer_not_registered`), carrying `to`. The timeout branch of those
374
+ same two methods was typed under #380; this was the branch above it.
375
+ - The ChatGPT provider's missing-credential path now throws `AuthenticationError`
376
+ (`missing_credential`), matching the router path that handles the same condition.
377
+ - `createSkill`, `createTokenLimiter`, `defineSkillReadTool`, the two `Workflow` builder guards, and
378
+ `Security.addPattern` now throw `ConfigurationError` with a code each.
379
+
380
+ Because `isTransientError` is `err instanceof TheokitAgentError && err.isRetryable`, a bare `Error`
381
+ was also permanently invisible to retry logic. These now answer the question.
382
+
383
+ ### Patch Changes
384
+
385
+ - eae788a: **`memory.directory` now moves the search index with the store, except into the Claude Code CLI's own directory** (#554).
386
+
387
+ The index is not a pointer. `chunks.text` holds the fact TEXT — FTS5/BM25 needs it to search — and `files.path` holds the store's absolute path. So a copy of the index is a copy of the memory, readable with `strings`.
388
+
389
+ Leaving it at `<cwd>/.theokit/memory/.index/` while the facts moved meant an operator who pointed `directory` at one personal store had that store's contents written into **every repository the agent ran in**, untracked and un-ignored. Recall worked throughout, so nothing looked wrong; what was wrong was where the data landed.
390
+
391
+ ```
392
+ before /tmp/my-store/fact.md facts move
393
+ /any-project/.theokit/memory/.index/memory.sqlite ← and the content follows here
394
+
395
+ after /tmp/my-store/fact.md
396
+ /tmp/my-store/.index/memory.sqlite ← one store, one index
397
+ ```
398
+
399
+ ## The Claude Code case is unchanged, on its own argument
400
+
401
+ `docs/memory-decisions.md` § 1 keeps the index in the project store when `directory` names the directory the Claude Code CLI manages: that CLI has no index format, so a binary there is an artefact the partner does not understand inside a directory it owns. That argument reaches **that** directory and no other, and the fix narrows the behaviour to match it rather than reversing the decision. `tests/claude-code-e2e-compat.test.ts` passes unchanged.
402
+
403
+ `memoryIndexRoot` (`internal/memory/storage/memory-root.ts`) is the single place that decides, using the path-only test `indexBudgetWarning` already relied on — no new heuristic.
404
+
405
+ ## Two things corrected alongside
406
+
407
+ **The recorded decision said something that had stopped being true.** *"The index is derived data that can be rebuilt from them"* was true of its derivability and false of its contents, and was doing the work of both. § 1 now quotes the schema and dates the narrowed scope.
408
+
409
+ **A test fixed the behaviour more widely than its own reason supported.** `test_the_index_database_stays_in_the_project_store_even_when_the_facts_move` used a plain `mkdtempSync` tmpdir, not the CLI's directory — so it pinned § 1 for *every* location when § 1 argues for one. That is why the leak stayed invisible: the test read as the decision being enforced. It now covers the plain-directory case, and `tests/memory/index-follows-a-relocated-store.test.ts` pins both halves.
410
+ - 667bd3d: A hook or lifecycle command that exits without reading its stdin no longer raises an uncaught
411
+ `EPIPE` in the SDK's own process.
412
+
413
+ `spawnAndCollect` writes the JSON payload to the child's stdin. A child that never reads it —
414
+ `exit 1`, a hook that only inspects the environment, any command that ignores the payload — closes
415
+ the pipe first, and the write then raises `EPIPE` on a stream with no `error` listener, which Node
416
+ promotes to an uncaught exception. The child was behaving perfectly legitimately; the host process
417
+ took the fault.
418
+
419
+ The error is swallowed rather than surfaced: the child's exit code and stderr are the result, and
420
+ both are collected either way. A payload nobody read is not a failure of the spawn.
421
+ - 070ee92: A foreign plugin's entry file is now checked against the root it was actually discovered under.
422
+
423
+ `refresh()` iterates every root a compat source admits — `.theokit/plugins`, and `.claude/plugins`
424
+ once `compatSources` names the `plugins` surface — and checks each plugin's declared `entry` file
425
+ exists. That check reconstructed the plugin's directory as `.theokit/plugins/<folder>`
426
+ unconditionally, regardless of which root the plugin was actually found under.
427
+
428
+ A plugin discovered at `.claude/plugins/my-plugin/` was therefore checked against
429
+ `.theokit/plugins/my-plugin/` — a directory it never lived in. With nothing there, a legitimate
430
+ foreign plugin was refused as "entry file is missing." Had a same-named folder existed under
431
+ `.theokit/plugins/` instead, its entry file would have been read in place of the real one — a path
432
+ confusion the ADR D79-D80 traversal guard this check calls does not catch, because the guard runs
433
+ against the wrong root rather than against none.
434
+
435
+ This was reachable through the bare `compatSources: ["claude-code"]` form, which has always admitted
436
+ the `plugins` surface — not something the per-surface work landing alongside this introduced.
437
+
438
+ Found while testing the per-surface admission work for usetheokit/theokit-sdk#524.
439
+ - 131ab8b: A foreign plugin's `source` field is now a real relative path instead of a single character.
440
+
441
+ Both manifest loaders (Claude Code's `.claude-plugin/plugin.json` form and this SDK's own
442
+ `PLUGIN.md`/`plugin.json`) built `source` by searching the manifest path for the literal substring
443
+ `.theokit/` and slicing from there. A manifest read from `.claude/plugins/<name>/…` contains no such
444
+ substring: `indexOf` returns `-1`, and `.slice(-1)` silently returned the manifest path's LAST
445
+ CHARACTER — `"n"` from `.json`, `"d"` from `PLUGIN.md` — instead of a path.
446
+
447
+ `source` is exactly the audit trail the visibility half of usetheokit/theokit-sdk#524 exists to
448
+ provide, and this was broken for precisely the case that matters most: a plugin admitted from a
449
+ foreign root. Replaced the substring search with `path.relative(cwd, manifestPath)` — the stdlib
450
+ does this correctly, and it is what the substring search was trying to approximate.
451
+
452
+ Found alongside the entry-file root confusion, testing the same per-surface admission work.
453
+ - 4415f83: An unrecognised key under `local` is now reported on the diagnostics channel instead of being
454
+ accepted in silence.
455
+
456
+ Measured before this: `Agent.create({ local: { compatSourcess: [...] } })` — one letter wrong —
457
+ created the agent with no throw, no warning, and nothing anywhere. That made two very different
458
+ failures identical: a typo and an SDK too old to know the option both produced the default
459
+ behaviour and no complaint.
460
+
461
+ It is the reason `usetheokit/theokit#634` is blocked rather than merely unimplemented — a forward
462
+ of `compatSources` written against a published SDK would be inert, and no consumer could tell.
463
+ The same shape produced the `$CLAUDE_PROJECT_DIR` defect and motivated the `compatSources` opt-in:
464
+ a surface that accepts input and does nothing with it, where the absence of a complaint reads as
465
+ acceptance.
466
+
467
+ The message names the key and the nearest known one, so one letter wrong is one line to read
468
+ rather than a trip to the documentation. It is a warning, never a refusal: rejecting an unknown key
469
+ would break every consumer passing a forward-compatible extra — the ordinary way to write code that
470
+ runs against two SDK versions — and turn a diagnostic problem into an outage. A correct
471
+ configuration emits nothing, and there is a test for that, because a warning that fires on valid
472
+ input stops being read.
473
+ - 9181434: A failed atomic write no longer leaves its temp file behind.
474
+
475
+ `replaceFileAtomic` — which backs the agent registry, session transcripts, MCP token storage and
476
+ everything else the SDK persists — cleaned up its `.tmp` on a rename failure and on no other. A
477
+ failure between the open and the rename, meaning a write error, a full disk, or an fsync failure,
478
+ closed the file handle and propagated with the temp still on disk.
479
+
480
+ Every failure after the open now removes it. A process killed mid-write still leaves one, which no
481
+ code inside that process can prevent; `sweepStaleAtomicTemps` reaps those on the next registry load.
482
+ - edfa59c: `Agent.batch(prompts, { task })` now rejects when the batch task fails, instead of resolving with an
483
+ empty array.
484
+
485
+ The task-wrapped path assigned its results inside the task's `work` callback and then returned that
486
+ variable unconditionally, so three different failures produced one indistinguishable value: the work
487
+ threw, the task was cancelled, or a fixed 5000-iteration poll budget elapsed. Each returned `[]` on
488
+ a **resolved** promise — which a caller cannot tell apart from `Agent.batch([])` on empty input.
489
+ Nothing threw and nothing was logged, and the registry's own `{ code, message }` for the failure was
490
+ discarded by a loop that read only the task's `state`.
491
+
492
+ The poll is gone. The wait is now the task's terminal event, which carries the failure detail:
493
+
494
+ - work threw → rejects with `code: "batch_task_failed"`, the registry's code on `protoErrorCode`
495
+ - cancelled → rejects with `code: "batch_task_cancelled"` and the reason, when one was given
496
+
497
+ The removed budget was not a safety net: 5000 iterations of a 5 ms sleep is roughly 25 seconds, so a
498
+ batch legitimately longer than that would trip it and return `[]`. The bound generated the failure
499
+ it appeared to guard against.
500
+
501
+ If you were checking `results.length === 0` to detect a failed batch, catch the rejection instead —
502
+ an empty array now means only what it says.
503
+ - edfa59c: Corrected the published JSDoc for `AgentOptions.budgetTracker` and `AgentOptions.memoryProvider`,
504
+ which told consumers the opposite of what the SDK does.
505
+
506
+ Both carried a paragraph stating the option was "wired to the type surface only" and that a consumer
507
+ supplying one "gets the type guarantee but NOT runtime enforcement". Neither has been true for some
508
+ time. `budgetTracker` is read by the agent loop before every iteration (`evaluateBudgetGate`),
509
+ advanced with `nextIteration()`, and charged with `track(...)` after each completion.
510
+ `memoryProvider` has its full lifecycle driven — `init`, `buildTools`, `runActivePass`, `sync`,
511
+ `dispose`.
512
+
513
+ No behaviour changes here; the code was already correct. What changes is what the published `.d.ts`
514
+ tells you, and it was wrong in the expensive direction: a consumer reading it was told the SDK would
515
+ not enforce their cost ceiling, so the rational response was to build a second control outside the
516
+ SDK, or to stop passing the option at all.
517
+
518
+ Six occurrences of the claim were corrected across `types/agent.ts`, `index.ts` and the loop's own
519
+ input types, and a lint now requires any "not implemented yet" note to carry a tracking reference and
520
+ a date, so the next one expires instead of outliving the work it describes.
521
+ - 4be7411: `Budget.create` now refuses `scope: "agent"` and `scope: "call"` with a
522
+ `ConfigurationError` (`unimplemented_budget_scope`).
523
+
524
+ Only `"process"` was ever implemented. Nothing outside the registry read `scope`,
525
+ so the other two were accepted and silently ignored: a caller asking for
526
+ per-agent accounting got process-wide accounting with no signal. A cost control
527
+ that reports the wrong number is worse than a missing feature.
528
+
529
+ `FnStep.compensate` is marked deprecated. It was never implemented — setting it
530
+ arms `WorkflowCompensateNotImplementedError`, so the step fails at run time.
531
+
532
+ `AgentLoopInputs` declares `maxConsecutiveToolErrors` and `maxConcurrentTools`,
533
+ which were read through inline casts and appeared in no type. A typo in either
534
+ name now fails to compile instead of silently taking the default.
535
+ - 24fb692: `Cron.create()` now reports when the job will actually next fire.
536
+
537
+ It reported `now + 1 hour` for every expression. The function behind it read neither the cron
538
+ expression nor the timezone — a `@yearly` job said it would run within the hour, and so did a
539
+ `*/5 * * * *` one. Its own docstring scoped it to fixture mode ("real scheduling uses a proper
540
+ evaluator wired in by the local scheduler"), and its only caller was `Cron.create()`.
541
+
542
+ The local scheduler overwrote the value for jobs it picked up, which is why this survived: the wrong
543
+ number was visible between creating a job and the scheduler reaching it, and permanently for a job
544
+ the scheduler never runs — a job created against a cloud runtime, or created while the scheduler is
545
+ stopped.
546
+
547
+ `nextRunAt` is now computed with croner, which was already a dependency and already doing exactly
548
+ this inside the scheduler. When an expression has no next run — `0 0 30 2 *`, a date that never
549
+ occurs — the field is absent rather than filled with a number, which is what the optional
550
+ `CronJob.nextRunAt` already meant.
551
+ - edfa59c: Two abstractions with no implementers and no consumers are gone. Nothing published changes.
552
+
553
+ `internal/security/secret-redactor.ts` declared a `SecretRedactor` interface that `redactSecrets`
554
+ happened to satisfy. It was added to raise the module's abstractness out of a coupling metric's "zone
555
+ of pain", and nothing ever held it — no implementer, no consumer, absent from every barrel. An
556
+ interface nobody holds does not change what any module depends on, so the number it was added to move
557
+ could not have moved either. Its README section now records that, and keeps the reasoning that
558
+ rejects chasing the metric in the first place.
559
+
560
+ `server/adapter/express.ts`, `fastify.ts` and `hono.ts` were byte-identical below their docblocks:
561
+ two imports and a one-line delegation each, with no framework type imported or adapted anywhere. They
562
+ are replaced by a single `server/adapter/index.ts` whose docblock says what the function actually
563
+ returns — a route descriptor the host binds itself, not middleware. The three per-framework docblocks
564
+ claimed an adaptation that did not exist, which is the half of this that could mislead a reader.
565
+
566
+ Their three test files, which had quietly drifted into three different levels of coverage, are one
567
+ file carrying the union of their cases plus one new case asserting the descriptor contract directly.
568
+ - 374dd5f: Embedding requests now retry with the same jittered exponential backoff the rest of
569
+ the SDK uses, and honour the provider's `Retry-After` header.
570
+
571
+ They used to back off linearly at `50ms * attempt` and ignore `Retry-After`, so two
572
+ clients hitting a rate-limited embedding endpoint retried in lockstep and neither
573
+ waited as long as the provider asked. The delay is tuned tighter than the LLM
574
+ transport's — 250ms base, 4s cap rather than 500ms/32s — because an embedding retry
575
+ sits inside a memory write on the run's critical path.
576
+ - 63617cd: Hooks imported from `.claude/settings.json` now run with `$CLAUDE_PROJECT_DIR` defined, so a
577
+ repository that also uses Claude Code stops denying every turn.
578
+
579
+ Reading that file is a deliberate compatibility decision, but the commands inside it are written for
580
+ Claude Code's runtime, which defines that variable and whose documentation tells hook authors to
581
+ reach project files through it. This SDK did not define it, so `sh` expanded it to the empty string
582
+ and `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/guard.sh"` ran as `bash "/.claude/hooks/guard.sh"` — a
583
+ file that does not exist, which a hook runner correctly reads as a refusal. The result was every
584
+ tool call denied, in any repository whose only unusual property was having Claude Code set up, with
585
+ a message naming a script that was present and executable all along.
586
+
587
+ A denial caused by an undefined variable now names the variable. `$CLAUDE_PLUGIN_ROOT` and the rest
588
+ of that runtime's surface are still not supplied — inventing a value would send a script somewhere
589
+ real and wrong — but a hook that needs one fails saying which, instead of reporting a path that
590
+ failed ten characters later.
591
+ - edfa59c: HTTP 408 is now classified as a retryable timeout instead of a configuration error.
592
+
593
+ `mapHttpStatusToError` in `internal/http.ts` had no arm for 408, so a Request Timeout fell through
594
+ to the generic `4xx` branch and came back as a `ConfigurationError` — `isRetryable: false`. Every
595
+ one of the four provider-specific mappers already did the opposite: `openai-compatible`, `anthropic`,
596
+ `bedrock` and `vertex` all map 408 to a `NetworkError` carrying a `timeout` code, which is retryable.
597
+ The generic ladder is a fifth copy of the same knowledge and it was the copy that drifted.
598
+
599
+ The failure was silent and pointed the wrong way. Nothing threw: a caller branching on
600
+ `isTransientError` simply refused to retry a request that would very likely have succeeded, and did
601
+ so only on the paths that went through the generic mapper rather than a provider one.
602
+
603
+ If you were catching `ConfigurationError` to handle 408 specifically, catch `NetworkError` instead —
604
+ or branch on `code`, which is what `docs/error-codes.md` asks for.
605
+ - edfa59c: The HTTP status ladder now has one definition instead of four, and two drifted copies are repaired.
606
+
607
+ `401/403 → auth_failed`, `402 → quota_exceeded`, `408 → timeout`, `429 → rate_limit`,
608
+ `400 → invalid_request`, `5xx → server_error` is RFC 9110 semantics, not a vendor contract: a 429
609
+ means the same thing whichever provider sent it. It was nevertheless written out in all four
610
+ provider mappers, and the copies had already diverged in two ways that reached users:
611
+
612
+ - **HTTP 402 reached one mapper of four.** `quota_exceeded` was wired into the OpenAI-compatible
613
+ mapper only, so a Bedrock, Vertex or Anthropic endpoint answering 402 fell through every arm and
614
+ surfaced as `unknown`. The canonical bucket existed and three of four mappers could not reach it.
615
+ - **The server arm had two different upper bounds.** Anthropic and OpenAI-compatible guarded
616
+ `>= 500 && < 600`; Bedrock and Vertex guarded `>= 500` with no ceiling, so a malformed or
617
+ proxy-injected 6xx was `server_error` in two mappers and `unknown` in the other two.
618
+
619
+ The ladder now lives once, in `internal/error-mappers/shared.ts`, beside the other dialect-agnostic
620
+ helpers. Each mapper keeps its own body dialect — Anthropic's `context_too_long`, OpenAI's
621
+ `insufficient_quota`, Bedrock's AWS `__type` strings and 404 rule, Vertex's `google.rpc` enum with
622
+ its finer `unauthenticated`/`permission` split — because those *are* per-vendor contracts. The
623
+ shape is now `classifyVendorBody(body) ?? httpStatusToErrorCode(status)`.
624
+
625
+ Visible changes: 402 now yields `quota_exceeded` (was `unknown`) on Anthropic, Bedrock and Vertex,
626
+ and a status of 600 or above now yields `unknown` (was `server_error`) on Bedrock and Vertex. HTTP
627
+ 404 is deliberately unchanged everywhere.
628
+ - 926cb81: Removed `LanceMemoryAdapter.unwrap()`, which handed callers the raw `LanceIndex`
629
+ behind the adapter. It had no callers anywhere in the monorepo — including the
630
+ migration tool and benchmark script its own docblock named, both of which open a
631
+ `LanceIndex` directly and never go through the adapter.
632
+
633
+ A caller that needs `addFacts` / `countFacts` / `removeFacts` needs a
634
+ `LanceIndex`, and opening one is the honest way to get it.
635
+ - 7f91326: The Lance memory backend now honours `SearchOptions.vectorWeight` and
636
+ `textWeight`. It blended hits with hard-coded 0.7 / 0.3 literals and never read
637
+ the options, so a caller that tuned the weights had its tuning applied on the
638
+ SQLite backend and silently dropped on Lance.
639
+
640
+ Unweighted Lance results shift slightly as a consequence: the shared defaults are
641
+ 0.6 / 0.4, and one of the two hard-coded numbers was never the contract's.
642
+
643
+ Workflow step logging (`ctx.log.debug` / `.info` / `.warn`) now goes through the
644
+ SDK's diagnostics channel instead of `console`, so a host that installs a
645
+ diagnostics sink — a TUI, for instance — receives it instead of having its frame
646
+ written over.
647
+ - edfa59c: Two internal modules moved to the layer they belong to. No public API changed.
648
+
649
+ `src/errors.ts` is the package's leaf — fifteen files under `internal/runtime/` import the typed
650
+ error hierarchy from it — and it imported back up into `internal/runtime/retry/` for one helper. The
651
+ helper encodes which error codes are retriable, which is a property of the error taxonomy rather
652
+ than of the retry runtime, so it now lives in `internal/error-mappers/` beside the other mapping
653
+ knowledge. One import path changed; the file itself was moved, not rewritten.
654
+
655
+ `internal/security/` is the most-depended-upon module in the tree and held node builtins and
656
+ `errors.js` and one exception: a path-containment primitive it reached for in
657
+ `internal/runtime/context/`. That primitive had four consumers and only two were in the folder it
658
+ sat in — it lived there because that is where it was extracted from, not because it belonged there.
659
+ It is now `internal/security/path-containment.ts`, and all four consumers import downward into
660
+ `security/`, the direction the rest of the tree already runs.
661
+ - 243bd2c: The live-agent registry and the session cache survive a package loaded twice.
662
+
663
+ `liveAgentRegistry` and the session cache's two maps were plain module-level `const`s, which are
664
+ singletons per module INSTANCE. A package can be loaded more than once in one process — two copies in
665
+ `node_modules`, ESM and CJS side by side, a monorepo with distinct versions — and each copy then gets
666
+ its own registry. For the live-agent registry, the public one, that means two views of which agents
667
+ are running, and a caller reading the wrong one sees none.
668
+
669
+ All three now go through the same `Symbol.for`-keyed helper the rest of the SDK uses.
670
+
671
+ The session cache's docblock asserted that the instances "remain the only ones in the process, because
672
+ an ES module is a singleton". That is the claim the helper exists to refute; the docblock now says so.
673
+ - edfa59c: `LiveSessionError` from `@theokit/sdk/persistence` is renamed to `LiveTranscriptError`. The old name
674
+ still works and is deprecated.
675
+
676
+ Two different classes were called `LiveSessionError`, exported from two declared subpaths that one
677
+ consumer can hold at once. They have incompatible shapes: the root barrel's is
678
+ `new LiveSessionError(sessionId, reason)` with a `reason` field and no `code`; the persistence one
679
+ was `new LiveSessionError(path)` with a `path` field and `code: "live_session_protected"`.
680
+
681
+ The failure was quiet in the way that costs most. `instanceof` is class identity, so a `catch`
682
+ checking the class imported from the root silently did not match the one thrown from persistence, and
683
+ the fallback ran for a condition the code believed it had handled. A `name` check looked like it
684
+ worked — `err.name === "LiveSessionError"` matched *both* — and then read `err.reason`, which only
685
+ one of them has.
686
+
687
+ The names now say what each refusal is about: refusing to destroy a **session**, and refusing to
688
+ overwrite a **transcript** file. `LiveSessionError` remains exported from `@theokit/sdk/persistence`
689
+ as a deprecated alias so existing imports keep working; it will be removed in the next major.
690
+ - ba6549f: An MCP client with a tight `requestTimeoutMs` can reconnect after a drop.
691
+
692
+ `reconnect()` recovers by spawning a fresh child and running the `initialize` handshake, and that
693
+ handshake was bounded by the same `requestTimeoutMs` the caller set for ordinary requests. Setting a
694
+ tight request budget — an ordinary thing to do for a latency SLO — silently made a client unable to
695
+ recover: every reconnect attempt spawned a process that could not finish inside a steady-state budget,
696
+ the bounded loop exhausted, and the client surfaced `mcp_disconnected`. That is the wedge the bounded
697
+ loop exists to prevent.
698
+
699
+ The reconnect handshake now takes `max(requestTimeoutMs, 10s)`.
700
+
701
+ The **first** connect is unchanged and keeps your budget exactly. The difference is which failure is
702
+ visible: a `requestTimeoutMs` too small to connect at all fails at the call you made, immediately, and
703
+ is yours to correct. The reconnect is the SDK's own recovery, which you never sized and never see
704
+ until a drop happens.
705
+ - edfa59c: `MemoryIndex.sync()` and `.status()` now say whether their numbers were measured.
706
+
707
+ `MemoryIndex` has two implementations. `IndexManager` walks a markdown corpus and counts rows with
708
+ `SELECT COUNT(*)`. The Lance backend has no corpus — it is a vector store fed by explicit writes —
709
+ and it answered with a frozen all-zeros `SyncResult` and a hardcoded `filesIndexed: 0,
710
+ chunksIndexed: 0`. Those are indistinguishable from a real sync that found nothing to do and a real
711
+ index that is empty, and the comment above them stated that as the goal: *"Returns zero counts so
712
+ callers' existing logging does not break."*
713
+
714
+ The consequence was a false negative rather than a crash. A caller deciding "is the index
715
+ populated?" from `chunksIndexed > 0` got `false` on every Lance run, however many rows the table
716
+ held.
717
+
718
+ Two required fields make the difference visible:
719
+
720
+ - `SyncResult.supported` — `true` from `IndexManager`, `false` from Lance
721
+ - `IndexStatus.countsExact` — `true` when counted, `false` when the number is a placeholder
722
+
723
+ The counts stay zero. Inventing a number would have traded one false claim for another; what changed
724
+ is that a caller can no longer read a placeholder as a measurement. Both fields are also on the
725
+ public `MemoryIndexHandle`, so a consumer holding the handle can see them. If you need the real Lance
726
+ count, `unwrap().countFacts()` still returns it.
727
+ - 21be5cb: `migrateSqliteToLance` now rejects a `batchSize` its loop cannot advance with,
728
+ before touching the workspace.
729
+
730
+ A `0` or a negative made the migration spin forever, calling `addFacts([])` and
731
+ logging a progress line every iteration. `NaN` — which `Number("abc")` produces —
732
+ made it migrate nothing and report "Validation FAILED. SQLite preserved.",
733
+ blaming the migration for a typo. Both now raise a `ConfigurationError` with code
734
+ `invalid_batch_size`, naming the value received.
735
+ - edfa59c: A dropped connection to Ollama is now retried instead of surfacing on the first attempt.
736
+
737
+ `OllamaNativeClient` rethrew the raw `fetch` rejection when its own body-dialect mapper did not
738
+ recognise the failure, and threw a bare `new Error` for any HTTP status the dialect did not cover.
739
+ Both land outside the SDK error hierarchy, and that decides retry behaviour by contract rather than
740
+ by chance: `isTransientError` is `err instanceof TheokitAgentError && err.isRetryable === true`, and
741
+ the router wraps every resolved client in `RetryingLlmClient`. A foreign error is therefore
742
+ non-transient by definition — so the most ordinary failure a local Ollama can produce, a dropped
743
+ connection, was never retried.
744
+
745
+ The repository had already found and fixed this for the other transports; `openai.ts` records the
746
+ measurement and names Ollama as the one still carrying it. Transport failures now go through
747
+ `wrapTransportError` (which passes `AbortError` and any already-mapped SDK error through untouched,
748
+ so nothing gets relabelled), and unrecognised statuses go through the shared HTTP status ladder
749
+ rather than a bare `Error`.
750
+
751
+ Visible change: these two paths now reject with `NetworkError` (`code: "transport_failure"`) and a
752
+ typed error carrying the status, instead of a `TypeError` and an `Error`. A caller branching on
753
+ `instanceof Error` is unaffected; a caller branching on `isTransientError` starts getting retries.
754
+ - 5d174f2: Three pieces of duplicated logic now have one owner each. One internal error message improves.
755
+
756
+ The `~4 chars per token` estimate had two `@public` implementations reachable from two entry points —
757
+ `built-in-processors.ts` with a named constant, `compaction.ts` with the ratio inlined. Tuning it, or
758
+ switching to code points instead of UTF-16 units (a caveat both docblocks already carried), would have
759
+ silently diverged them. It lives in `compaction.ts` now, with `CHARS_PER_TOKEN` exported beside it;
760
+ `built-in-processors.ts` re-exports both under the same names, so nothing published changes.
761
+
762
+ The error-body reader duplicated character-for-character in the Bedrock and Vertex mappers is one
763
+ `parseErrorBody` in their shared module. It describes how `fetch` surfaces a body, which is the same
764
+ whoever sent it.
765
+
766
+ `abortError` had three copies — including inside the two files the extraction's own docblock named as
767
+ the ones that should stop having one. **One of the three behaved differently**: the pool-aware client
768
+ discarded a non-`Error` abort reason and raised a generic `"AbortError"`. All three are now the shared
769
+ implementation, which carries the caller's reason through. If you cancel with
770
+ `controller.abort("shutting down")` and the pool-aware client is in the path, the rejection message is
771
+ now `shutting down` rather than `AbortError`.
772
+ - 7b4063b: One resolver now answers "where does this project's configuration live?" — `theokitConfigRoot(cwd)`,
773
+ in `internal/persistence/paths.ts`, semver-exempt.
774
+
775
+ Five readers hand-rolled `join(cwd, ".theokit", ...)` independently: `mcp.json`, the context
776
+ directory + `context.json`, the hooks-root fallback check, `registry.json`, and the personality
777
+ `PROJECT_SUBDIR`. `projectConfigRoots` (hooks/skills/subagents/plugins, per usetheokit/theokit-sdk#524)
778
+ already resolved its native root the same way, inline, making six independent copies of one
779
+ constant.
780
+
781
+ No filename, format or resulting path changes — this is a pure consolidation, and the project's own
782
+ lint gate (`no-hardcoded-theokit-path.test.ts`, ratcheted 23 → 14) is the proof: every literal this
783
+ change removed was already flagged as migration debt, and the full suite is unchanged.
784
+
785
+ Deliberately does NOT touch homedir-anchored state (sessions, credentials, the personality
786
+ `USER_SUBDIR`, provider discovery) — those follow `getTheokitHome`/`THEOKIT_HOME` by design, and
787
+ folding them into this resolver would be the exact silent behaviour change
788
+ `theokitConfigRoot`'s own docblock warns against: a project's committed configuration must never
789
+ follow an operator's relocated state directory. A regression test pins this — swapping the
790
+ resolver's body for `getTheokitHome`'s would move all six readers under `THEOKIT_HOME` at once, in
791
+ one line, with no caller-side signal.
792
+ - 0c4df84: The `PermissionRule` documentation described a bug that was fixed, and told you to work around it.
793
+
794
+ The public docblock warned that a predicate matcher is invoked with `undefined` when the call omitted
795
+ the argument — so an allow-rule written to narrow would authorize an argument-less call, and a
796
+ deny-rule would throw a `TypeError` out of the permission gate. It closed by telling you to guard the
797
+ parameter in every predicate you write.
798
+
799
+ None of that has been true since `argMatches` started checking for a missing argument first, for
800
+ every matcher form. **A rule that declares an argument the call did not supply does not match, and
801
+ the predicate is not invoked.** You do not need the hand-written guards.
802
+
803
+ The fixed behaviour also had no test — deleting the guard left the entire suite green — so three
804
+ cases now cover it, including the two the old docblock described.
805
+ - 01630ec: The README explains that three unrelated things in this ecosystem are called "plugin", and that
806
+ two of them share one option.
807
+
808
+ A **framework plugin** (`@theokit/plugin-canvas`, `@theokit/auth-github`, …) extends a `theokit`
809
+ application — routes, UI, devtools, CLI verbs. An **SDK code plugin** (`PermissionPlugin.create(…)`,
810
+ `Handoff.asPlugin(…)`) extends an agent and is passed as `Agent.create({ plugins: [ … ] })`. The
811
+ same option also accepts `{ enabled: ["name"] }`, which selects **file-discovered** plugins under
812
+ `.theokit/plugins/` and is mutually exclusive with the array form.
813
+
814
+ Reaching for the wrong one raises no error; it simply has no effect. Installing `plugin-payments`
815
+ does nothing for an agent, and passing `PermissionPlugin` does nothing for a route.
816
+
817
+ It also records the observation that sends people looking for a bug that is not there:
818
+ `agent.pluginsManager` only ever holds the file-discovered form, so it reports `plugins: []` while
819
+ a code plugin is registered and working. An empty manager beside a populated `options.plugins` is
820
+ the normal shape.
821
+ - edfa59c: The Responses-API transport's SSE state machine is a class, and the eight event kinds it handles are
822
+ now covered by tests.
823
+
824
+ `ResponsesApiClient.stream` inlined the whole dispatch: one 165-line generator, ten mutable locals
825
+ and eight `else if` arms, carrying a suppression that described it as "mirroring
826
+ `OpenAIStreamAccumulator.consume`". It mirrored what that method does, not how it is organised —
827
+ `consume` is seven lines delegating to small private methods. The state machine now lives in a
828
+ `ResponsesStreamAccumulator` shaped like its sibling, the suppression is gone, and every function in
829
+ the file is under the complexity threshold the project sets for itself.
830
+
831
+ The refactor is behaviour-preserving, and that claim was measured rather than asserted. Each arm of
832
+ the dispatch was mutated in turn before the change: three of eight killed a test, five did not — the
833
+ reasoning deltas, the incremental tool-argument accumulation, `response.incomplete` → `max_tokens`,
834
+ the reasoning/cache token counters, and the in-stream failure path. Notably, a comment in the
835
+ existing suite claimed the argument deltas were exercised "because the parsed input below can only
836
+ be right if they were accumulated"; deleting the accumulation left that suite green, because the
837
+ recorded fixture repeats the full arguments on the terminal event.
838
+
839
+ Eight characterisation tests close those gaps, and re-running the battery after the extraction kills
840
+ all eleven mutants. Two behaviours documented for the first time by that battery: the tool name is
841
+ taken from the frame that announced the call when the completion frame omits it, and frames sent
842
+ after `[DONE]` are ignored.
843
+
844
+ No public API changed.
845
+ - d1182ae: A resumed session now replays its history as structured tool calls instead of flat text, so the
846
+ model stops learning to type `[tool call] <name>` as prose.
847
+
848
+ Hydration has always produced two projections of each stored turn: `text`, in which a tool call
849
+ folds to the marker `[tool call] NAME`, and structured `parts`, which carries the call id, the tool
850
+ name and the arguments. The replay read `text` alone. So a resumed session showed the model its own
851
+ prior turn as prose containing the marker, and the model did the reasonable thing with a pattern it
852
+ is shown — it wrote the marker instead of calling the tool. Downstream that surfaced as an assistant
853
+ message ending `"…report its output.[tool call] run_shell"` with no tool call behind it: the tool
854
+ did not run, nothing errored, and the transcript read as the model narrating an action it never took.
855
+
856
+ A turn with no `parts` replays exactly as before, so sessions stored by an older SDK keep the
857
+ behaviour they were written under. Tool results replay as a user message, which is the convention
858
+ the live loop already uses.
859
+
860
+ **An already-affected session recovers on its next turn.** No need to start a new one or delete
861
+ anything: the stored `parts` were always correct — only the `text` projection carried the marker —
862
+ so reading structure instead of prose heals a contaminated transcript rather than merely stopping
863
+ new contamination. Verified against a session that had accumulated seven occurrences of the marker.
864
+ - 1499923: `@theokit/sdk/sandbox` marks `resetInteractiveWarnLatch` and `resetSandboxWarnLatch`
865
+ as deprecated. Both are test seams for WARN-once latches that were re-exported
866
+ under plain camelCase, reading like ordinary API. They still work and are removed
867
+ in the next major; there is no replacement, because production code has no reason
868
+ to reset a warn-once latch.
869
+
870
+ `resetBwrapMemo` is NOT deprecated and now documents why it is public: it is the
871
+ companion to `detectBwrapMemoized`, and the only way to make a long-lived host
872
+ re-probe after `bwrap` is installed.
873
+ - f64ab2b: `SessionManager` gains an optional `getCookieSecret()`, the member `defineAuth`
874
+ uses to encrypt the OAuth transaction cookie.
875
+
876
+ It is additive: a manager without it falls back to `THEOKIT_OAUTH_TX_SECRET`
877
+ exactly as before. What changes is that the orchestrator no longer casts its own
878
+ port away to read an undeclared `secret` field — a shape no conforming
879
+ implementation could supply.
880
+ - 6aeeadb: Telemetry auto-instrumentation now logs what each adapter actually wired.
881
+
882
+ Five of the seven adapters install something concrete — an OTel span processor,
883
+ an event processor, a vendor client. Braintrust and LangSmith cannot: those
884
+ vendors auto-instrument from an env var, so loading the module is the whole
885
+ contribution. Both are legitimate, but the registry printed
886
+ `Braintrust auto-instrumented.` for the second kind, which read as a wired
887
+ telemetry pipeline when nothing had been installed.
888
+
889
+ `register()` now returns what it wired and the registry reports that instead of
890
+ asserting a single outcome for all seven. A vendor that is detected but cannot
891
+ be wired says so too, rather than being logged as instrumented.
892
+ - 7f2bce4: Test-only: the MCP token-store fixtures create their temporary directories atomically.
893
+
894
+ CodeQL reported an insecure temporary file at high severity, and the report was right. The
895
+ directories were built from a predictable name — `theokit-mcp-tokens-${hrtime}` under a
896
+ world-writable `/tmp` — and one of them was created world-writable and populated afterwards. On a
897
+ shared machine another local user can predict the path, win the race to create it, and plant a file
898
+ the test is about to trust; the restrictive mode passed to `mkdirSync` arrives after the name has
899
+ already been claimed.
900
+
901
+ `mkdtempSync` creates with a random suffix and mode 0700 in one atomic step. The one fixture whose
902
+ loose modes ARE the subject — the case proving the gate refuses a world-writable store — creates
903
+ restricted and loosens with `chmod`, so the state under test is identical and the window is closed.
904
+
905
+ No production code is affected and no assertion changed.
906
+ - edfa59c: The "no transport" error no longer tells you to install a package that cannot exist.
907
+
908
+ When a provider declares an `apiMode` the SDK has no transport for, the thrown `ConfigurationError`
909
+ advised: *"Install a third-party transport plugin (`@theokit-transport-{apiMode}`)"*. There is no
910
+ plugin mechanism to install into — `registerTransport` and `transportRegistry` appear nowhere in the
911
+ package, and the only other mention of `theokit-transport` was a docblock describing that very
912
+ message. Someone could publish the package; nothing would load it.
913
+
914
+ The message now says transports are a built-in, closed set, and names all four of them —
915
+ `chat_completions`, `anthropic_messages`, `bedrock_anthropic`, `responses_api` — instead of two. The
916
+ `transport_unavailable` code is unchanged, so anything branching on `code` is unaffected.
917
+ - e0a1ab9: Three public failure paths now carry a type and a code instead of a sentence.
918
+
919
+ `normalizeSchema` threw bare `Error` for both of its failures — a missing
920
+ `@valibot/to-json-schema` peer and an unsupported schema — so a caller could
921
+ branch on nothing but the message. Both are `ConfigurationError` now, with codes
922
+ `valibot_converter_missing` and `unsupported_schema`. It also detects the missing
923
+ peer from `err.code === "ERR_MODULE_NOT_FOUND"` before falling back to matching
924
+ the message text.
925
+
926
+ Resuming an agent whose persisted workspace path exists but is a file now says
927
+ so, instead of reporting it as "missing or inaccessible".
928
+
929
+ Subscription error frames carry the server error's own `code` over the wire, and
930
+ the WebSocket client prefers it over its blanket `ws_server_error`. A caller can
931
+ tell an invalid input from a disconnect without parsing English.
932
+ - edfa59c: Workflow errors now carry `code` and `isRetryable`, so the SDK's own retry helper can see them.
933
+
934
+ Eleven public workflow error classes extended plain `Error`. `isTransientError` is
935
+ `err instanceof TheokitAgentError && err.isRetryable === true`, and it is the default predicate of
936
+ `Retry.create` — so a class outside the hierarchy is permanent *by contract*, whatever it actually
937
+ represents. Wrapping `workflow.run()` in the SDK's own retry helper therefore got `false` for every
938
+ workflow failure, including `WorkflowAlreadyRunningError`, which is precisely the
939
+ try-again-in-a-moment condition.
940
+
941
+ They now extend `TheokitAgentError`, each with a stable `code`:
942
+
943
+ | code | retryable |
944
+ |---|---|
945
+ | `workflow_already_running` | **yes** — another run holds the single-flight lock |
946
+ | `workflow_duplicate_step_id`, `workflow_input_invalid`, `workflow_output_invalid`, `workflow_state_invalid`, `workflow_nested_failed`, `workflow_snapshot_not_found`, `workflow_max_iterations_exceeded`, `workflow_not_serializable`, `workflow_resume_step_not_found`, `workflow_compensate_not_implemented` | no |
947
+
948
+ Source-compatible: `TheokitAgentError extends Error`, so `instanceof Error` and `err.name` are
949
+ unchanged, and every existing field (`stepId`, `workflowName`, `detail`, …) stays where it was.
950
+
951
+ `WorkflowParallelError` is deliberately unchanged — it extends `AggregateError`, and the standard
952
+ `errors` array is why callers catch it. It stays outside the hierarchy, and therefore stays
953
+ non-retryable; inspect `err.errors` and decide per branch.
954
+
3
955
  ## 5.0.0-next.4
4
956
 
5
957
  ### Minor Changes