@theokit/agents 13.0.0-next.9 → 13.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.
- package/CHANGELOG.md +1682 -0
- package/README.md +31 -0
- package/dist/{agent-compiler-DZorqtK2.d.ts → agent-compiler-B0hb6HCo.d.ts} +177 -198
- package/dist/ask.d.ts +1 -0
- package/dist/auth.d.ts +148 -1
- package/dist/auth.js +36 -0
- package/dist/auth.js.map +1 -1
- package/dist/{bridge-entry-B0FqqPlt.d.ts → bridge-entry-DBkqwe6b.d.ts} +72 -6
- package/dist/bridge.d.ts +6 -4
- package/dist/bridge.js +9 -4
- package/dist/{chunk-TZCHACY7.js → chunk-G7QBDGZ4.js} +93 -18
- package/dist/chunk-G7QBDGZ4.js.map +1 -0
- package/dist/chunk-HGVT4VCE.js +97 -0
- package/dist/chunk-HGVT4VCE.js.map +1 -0
- package/dist/{chunk-6WFRR24F.js → chunk-KTQID5V3.js} +560 -342
- package/dist/chunk-KTQID5V3.js.map +1 -0
- package/dist/chunk-M5J3Q6YC.js +17 -0
- package/dist/chunk-M5J3Q6YC.js.map +1 -0
- package/dist/{chunk-RKWCXVYG.js → chunk-MJ6FRILJ.js} +67 -5
- package/dist/chunk-MJ6FRILJ.js.map +1 -0
- package/dist/{chunk-LPS65NGG.js → chunk-PMMOOXR6.js} +33 -10
- package/dist/chunk-PMMOOXR6.js.map +1 -0
- package/dist/chunk-U72XTMYB.js +7 -0
- package/dist/chunk-U72XTMYB.js.map +1 -0
- package/dist/client-react.d.ts +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/config.d.ts +163 -36
- package/dist/config.js +119 -7
- package/dist/config.js.map +1 -1
- package/dist/{define-agent-D9b3h3VU.d.ts → define-agent-DhwNmdej.d.ts} +27 -3
- package/dist/{delegation-scoring-MbnqL68u.d.ts → delegation-scoring-BzJEheml.d.ts} +1 -1
- package/dist/hooks.d.ts +0 -32
- package/dist/hooks.js +42 -14
- package/dist/hooks.js.map +1 -1
- package/dist/index.d.ts +191 -16
- package/dist/index.js +43 -5
- package/dist/index.js.map +1 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/setting-sources-gate-DFu51i50.d.ts +278 -0
- package/dist/testing.d.ts +5 -2
- package/dist/testing.js +1 -1
- package/dist/tools.d.ts +4 -2
- package/dist/tools.js +22 -4
- package/dist/tools.js.map +1 -1
- package/dist/usage.d.ts +30 -0
- package/dist/usage.js +3 -0
- package/dist/usage.js.map +1 -1
- package/package.json +3 -3
- package/dist/chunk-6WFRR24F.js.map +0 -1
- package/dist/chunk-LPS65NGG.js.map +0 -1
- package/dist/chunk-RKWCXVYG.js.map +0 -1
- package/dist/chunk-TZCHACY7.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,1687 @@
|
|
|
1
1
|
# @theokit/agents
|
|
2
2
|
|
|
3
|
+
## 13.0.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c59abf6: A foreign configuration root can be imported in part
|
|
8
|
+
|
|
9
|
+
`resolveCompatSources` returned the bare literal `'claude-code'`, which the SDK reads as "import
|
|
10
|
+
every surface" — hooks, plugins, skills, subagents. `settingSources.claudeCode.import` now names the
|
|
11
|
+
surfaces, and the resolved value carries them.
|
|
12
|
+
|
|
13
|
+
The distinction is the reason the grant exists: `.claude/` usually arrives with the clone and its
|
|
14
|
+
`hooks.json` executes shell, so "take the skills, refuse the hooks" is the ordinary thing to want,
|
|
15
|
+
and the only choices were all of it or none of it.
|
|
16
|
+
|
|
17
|
+
Absent `import` still means the whole root, so nothing existing changes. An EMPTY list is refused
|
|
18
|
+
rather than guessed: "no surfaces" and "unset, so all of them" are both defensible readings of `[]`,
|
|
19
|
+
they differ by whether shell executes, and picking one would settle a security question by
|
|
20
|
+
convention.
|
|
21
|
+
|
|
22
|
+
`CompatSurface` and `ResolvedCompatSource` are declared here rather than imported from the SDK, for
|
|
23
|
+
the reason already recorded beside the literal: they do not exist in `@theokit/sdk@4.52.1`, this
|
|
24
|
+
package's declared floor.
|
|
25
|
+
|
|
26
|
+
Closes usetheokit/theokit#686.
|
|
27
|
+
|
|
28
|
+
- ca44ee7: A narrowed foreign root can name `commands`
|
|
29
|
+
|
|
30
|
+
`settingSources.claudeCode.import` narrows the foreign root to named surfaces, and `CompatSurface`
|
|
31
|
+
listed four of them — `hooks`, `plugins`, `skills`, `subagents`. It fed a fifth: this package loads
|
|
32
|
+
`<projectDir>/.claude/commands/*.md` itself, outside the SDK's `compatSources` path. The name was
|
|
33
|
+
missing from the vocabulary, so a consumer could neither ask for that directory nor be told it had
|
|
34
|
+
gone unread, and `loadCustomCommands` tested `sources.includes('claude-code')` — string equality
|
|
35
|
+
against a union whose narrowed member is an object, so every narrowed list read as _not declared_.
|
|
36
|
+
|
|
37
|
+
`CompatSurface` now includes `'commands'`, and the loader understands both shapes: the bare source
|
|
38
|
+
name grants every surface the root feeds, the narrowed form grants the ones it names.
|
|
39
|
+
|
|
40
|
+
Purely additive — before this, no narrowed list reached the directory at all, so nothing that works
|
|
41
|
+
today stops working. A narrowed list that wants foreign commands adds `'commands'` to `import`.
|
|
42
|
+
|
|
43
|
+
An enumeration used to narrow a root must cover every surface that root feeds; otherwise it is not
|
|
44
|
+
a narrowing but an undeclared drop. Reported as usetheokit/theokit#704, found by measuring a claim
|
|
45
|
+
in a consumer's adoption report rather than by any test here — the fifth time a gap in this
|
|
46
|
+
package's public surface was invisible from inside it.
|
|
47
|
+
|
|
48
|
+
- dcb461f: `.plugins()` refuses a filesystem-bundle entry instead of accepting and ignoring it, and its parameter says what belongs there.
|
|
49
|
+
|
|
50
|
+
**BREAKING:** `plugins` was `readonly unknown[]` and is now `readonly CodePlugin[]`. Anything that
|
|
51
|
+
already passed `{ name, register }` objects is unaffected.
|
|
52
|
+
|
|
53
|
+
The word `plugins` appears in both vocabularies and means different things. This layer's are CODE
|
|
54
|
+
objects registered with the runtime's lifecycle seam; the Claude Code format's are filesystem
|
|
55
|
+
BUNDLES. A consumer who read the other product's documentation passed
|
|
56
|
+
`[{ type: 'local', path: './p' }]`, the compiler accepted it because the parameter was `unknown[]`,
|
|
57
|
+
and the agent ran with the plugin absent — **the typecheck that should have caught it was what let it
|
|
58
|
+
through**.
|
|
59
|
+
|
|
60
|
+
**The survey's "not implemented on either side" was too strong, and the correction matters for the
|
|
61
|
+
error message.** A bundle _directory_ is discovered: `pluginBundleDirs` reads `.claude/plugins/*` and
|
|
62
|
+
`.theokit/plugins/*`, and the `skills/` and `agents/` subdirectories of each are loaded. What is
|
|
63
|
+
absent is the manifest (`.claude-plugin/plugin.json`), marketplaces, and the format's other
|
|
64
|
+
contributions — hooks, MCP servers, output styles, LSP servers.
|
|
65
|
+
|
|
66
|
+
So a path-shaped entry is not refused because bundles are unsupported. It is refused because this
|
|
67
|
+
_parameter_ is not how a bundle is declared, and the message says where one goes and what a bundle
|
|
68
|
+
does and does not contribute. A refusal that does not name where the capability lives sends the
|
|
69
|
+
reader to a changelog.
|
|
70
|
+
|
|
71
|
+
The check runs at the projection, the single point every authoring path converges on. Putting it on
|
|
72
|
+
the builder method would miss `defineAgent({ plugins })` and the capability — which is how the shape
|
|
73
|
+
reached the runtime unexamined in the first place.
|
|
74
|
+
|
|
75
|
+
A plain wrong value gets a different message from a bundle reference: a string is not a bundle, and
|
|
76
|
+
pointing its author at a `plugins/` directory would send them somewhere that cannot help.
|
|
77
|
+
|
|
78
|
+
- 6b07960: New `grantGate(store, classify)` in `@theokit/agents/auth` — the supported way to put a
|
|
79
|
+
`PermissionStore` in force.
|
|
80
|
+
|
|
81
|
+
The store shipped with a careful grant key, a "deny by default, always" docblock and no reader.
|
|
82
|
+
Measured: `isGranted` had zero callers outside its own unit test, and `PermissionStore` appeared in
|
|
83
|
+
zero files across six sibling repositories. An operator reading `.theokit/tool-permissions.json` to
|
|
84
|
+
learn what an agent may run was reading a control that was not in force — a grant and its revocation
|
|
85
|
+
produced identical behaviour.
|
|
86
|
+
|
|
87
|
+
`grantGate` adapts the store to `pre_tool_call`, which is documented as the only hook with veto
|
|
88
|
+
power and runs before the tool by construction, so a refusal is a refusal before the side effect. No
|
|
89
|
+
new gate and no framework wiring: nothing is enforced unless a consumer attaches the handler, and an
|
|
90
|
+
agent that does not is unaffected.
|
|
91
|
+
|
|
92
|
+
`classify` returns `{ governed: true, query }` **or** `{ governed: false }` — a tagged union whose
|
|
93
|
+
BOTH arms carry the discriminant. A bare `undefined` would say "this tool needs no permission" and
|
|
94
|
+
"I forgot this tool" in the same word, and on a security gate the second must not silently pass.
|
|
95
|
+
|
|
96
|
+
The discriminant is on both arms because the first shape — discriminated by whether a `governed` KEY
|
|
97
|
+
was present — **failed open**: a consumer writing a policy record `{ governed: true, scope }` and
|
|
98
|
+
spreading it into the query got the tool waved through, and so did `governed: undefined`. TypeScript
|
|
99
|
+
does not stop that; excess properties pass freely through a variable or a spread. Fail-closed is now
|
|
100
|
+
measured across `true` / `undefined` / `false` / `null`, and only `false` passes.
|
|
101
|
+
|
|
102
|
+
Named `grantGate`, not `permissionGate`, because `@theokit/sdk` already exports `PermissionGate`,
|
|
103
|
+
`PermissionGateContext`, `PermissionGateDecision`, `PermissionEngine` and `PermissionPlugin`. The
|
|
104
|
+
rename also says something true: this gates on a standing grant the operator made, and the SDK's
|
|
105
|
+
permission engine is a separate system — neither satisfies the other.
|
|
106
|
+
|
|
107
|
+
A classifier that throws DENIES, naming the throw, rather than ending the turn. A corrupt store
|
|
108
|
+
denies with a message that says so — "the permission store could not be read, so no grant applies" —
|
|
109
|
+
distinct from "no standing grant matches", so an operator can tell the two apart.
|
|
110
|
+
|
|
111
|
+
The read error's own text is deliberately NOT in that message: the veto travels to the model, and
|
|
112
|
+
`lastReadError.message` carries the absolute store path and its file mode. It stays on
|
|
113
|
+
`store.lastReadError` for the operator, which is where the actionable remedy (`chmod 600 …`) lives.
|
|
114
|
+
The scope IS still interpolated, so this narrows the exposure rather than eliminating it.
|
|
115
|
+
|
|
116
|
+
**Composing with a `pre_tool_call` you already have**: the field is singular, so assigning the gate
|
|
117
|
+
over an existing handler loses one of the two silently. Compose explicitly —
|
|
118
|
+
`async (ctx) => (await gate(ctx)) ?? (await mine(ctx))`; first veto wins.
|
|
119
|
+
|
|
120
|
+
`PermissionStore`'s docblock now states that the class enforces nothing on its own, and records the
|
|
121
|
+
precedence among the surfaces IN THIS PACKAGE that can refuse a tool — saying plainly that the list
|
|
122
|
+
is not exhaustive, because `@theokit/sdk` has its own permission system that neither knows about
|
|
123
|
+
these nor is known by them.
|
|
124
|
+
|
|
125
|
+
- a7e35bb: A guardrail returning `action: 'redact'` with no replacement `text` now throws
|
|
126
|
+
`MalformedGuardrailResultError` instead of silently redacting nothing.
|
|
127
|
+
|
|
128
|
+
`GuardrailResult.text` is optional, so such a guard compiles and reads like a working one. The
|
|
129
|
+
pipeline tested `r.text !== undefined` and moved on, so the caller received the original text and
|
|
130
|
+
believed a guard had run on it — the operator believing a protection is in place when none is.
|
|
131
|
+
|
|
132
|
+
**This is a behaviour change.** A guard relying on the previous no-op will now throw. That is
|
|
133
|
+
deliberate: the alternative is unredacted output reaching a model because a guard was written wrong.
|
|
134
|
+
`text: ''` is unaffected and always was a real redaction — a guard choosing to erase everything.
|
|
135
|
+
|
|
136
|
+
`MalformedGuardrailResultError` is exported from `@theokit/agents`, carries the guard's name and the
|
|
137
|
+
phase, and is not retryable.
|
|
138
|
+
|
|
139
|
+
This also reaches the streaming path (`moderateOutputStream`), which shares the same pipeline: a
|
|
140
|
+
malformed guard there now throws where it previously continued.
|
|
141
|
+
|
|
142
|
+
- dcb461f: `blockAppliesTo(block, filePath)` is exported from `@theokit/agents/config`.
|
|
143
|
+
|
|
144
|
+
`InstructionBlock.scopes` carries the `paths:` frontmatter, and the package shipped no way to apply
|
|
145
|
+
it — no glob matcher existed anywhere in the tree. A consumer who wanted to honour a scope had to
|
|
146
|
+
invent the semantics, and two consumers would invent two.
|
|
147
|
+
|
|
148
|
+
The field's own docblock names the consequence of getting it wrong: "A consumer rendering the block
|
|
149
|
+
would then apply a rule written for one subtree EVERYWHERE — the one frontmatter failure with a
|
|
150
|
+
consequence, and a silent one."
|
|
151
|
+
|
|
152
|
+
Delegating the **decision** to the product is deliberate and unchanged: a consumer still chooses
|
|
153
|
+
whether to filter. What changes is that it now has the **means**.
|
|
154
|
+
|
|
155
|
+
`scopesUnreadable` answers `false` for every path. That is the fail-closed half the flag was invented
|
|
156
|
+
for — a `paths:` key that was declared and yielded nothing must not read as "no scope declared",
|
|
157
|
+
because those two are indistinguishable in `scopes` alone and only one of them is safe to publish
|
|
158
|
+
everywhere.
|
|
159
|
+
|
|
160
|
+
**Supported:** `**` (crosses separators), `*` (does not), `?` (one character) — the three the SDK's
|
|
161
|
+
own rule activation implements. **Not supported:** brace expansion `{a,b}` and character classes
|
|
162
|
+
`[abc]`, which match literally and therefore almost certainly not at all. That floor is deliberate:
|
|
163
|
+
three wildcards are a dozen lines, and a glob dependency would carry a transitive tree into a package
|
|
164
|
+
with three direct dependencies. Needing the fuller grammar is a decision to make out loud.
|
|
165
|
+
|
|
166
|
+
- dcb461f: A shared session store can be declared through the authoring surface. Serverless and multi-pod were unreachable without it.
|
|
167
|
+
|
|
168
|
+
The SDK takes `local.sessionStore` — a Postgres / Redis / KV / durable-object store used as the
|
|
169
|
+
primary session store and resume source, for deployments where the filesystem is ephemeral or the
|
|
170
|
+
next request lands on a different host. Measured: `sessionStore` returned 0 files in this layer.
|
|
171
|
+
|
|
172
|
+
This layer's stated doctrine, repeated across roughly eight docblocks, is that a consumer should not
|
|
173
|
+
import `@theokit/sdk` directly. With no authoring surface for the store, the only way to reach it was
|
|
174
|
+
to do exactly that — so **the doctrine and the capability disagreed, and the consumer paid**.
|
|
175
|
+
|
|
176
|
+
`SessionStoreCapability` writes the field and `assembleM8CreateOptions` projects it. `SessionStore`
|
|
177
|
+
crosses the barrel with it: forwarding a capability whose type cannot be named does not close the
|
|
178
|
+
gap, and the item says so.
|
|
179
|
+
|
|
180
|
+
The block is written only when a store was declared. An unconditional write creates `local` for every
|
|
181
|
+
agent — a claim about setting sources and a cwd that no author made. The first version of the control
|
|
182
|
+
asserted only `local?.sessionStore`, which passes for the unconditional write too since the key lands
|
|
183
|
+
as `undefined` either way; it asserts the block now.
|
|
184
|
+
|
|
185
|
+
Two existing gates fired on this change and were right both times: the compile-time waist-field
|
|
186
|
+
exhaustiveness check demanded the new field be classified, and the derived coverage test demanded the
|
|
187
|
+
capability join the "everything switched on" fixture. Neither needed to be found by review.
|
|
188
|
+
|
|
189
|
+
- 5211721: `moderateOutputStream` now delivers the redacted text to the client instead of computing it and
|
|
190
|
+
replaying the original events.
|
|
191
|
+
|
|
192
|
+
It called `runOutputGuards` and discarded the return value, so a guard that redacted correctly had
|
|
193
|
+
its work thrown away: measured against the built artifact, a guard returning `[REDACTED]` delivered
|
|
194
|
+
`sk-abc123`. Only `block` reached the client honestly.
|
|
195
|
+
|
|
196
|
+
**Signature change**: `moderateOutputStream` takes a fourth argument,
|
|
197
|
+
`rebuildText: (text: string, replaced: E) => E`, which builds one event carrying the
|
|
198
|
+
moderated text, given the text-carrying event it replaces. It is required rather than optional —
|
|
199
|
+
optional would let the function compute a redaction it cannot apply, which is the defect being
|
|
200
|
+
removed. Only the caller knows how to construct its own events.
|
|
201
|
+
|
|
202
|
+
**`extractText` MUST match exactly one event kind.** When it matches several, they COLLAPSE INTO
|
|
203
|
+
ONE — measured: `[thinking('CoT: the key is sk-abc'), message(' Here you go.')]` yields a single
|
|
204
|
+
`message` reading `"CoT: the key is [R] Here you go."`, with no `thinking` event surviving. A
|
|
205
|
+
consumer who wants reasoning moderated runs a SECOND pass over that kind rather than widening one
|
|
206
|
+
extractor.
|
|
207
|
+
|
|
208
|
+
`replaced` does not prevent that collapse, and an earlier draft of this entry said it did. What it
|
|
209
|
+
buys is narrower: the surviving event keeps the KIND and metadata of the text-carrying event it
|
|
210
|
+
replaces, instead of being rebuilt from the text alone. `replaced` is ALWAYS the event being replaced.
|
|
211
|
+
It was typed `E | undefined` for a case that cannot happen — a stream where no event carried text
|
|
212
|
+
returns from the absence check before the guards run, so `rebuildText` is not reached at all. It is
|
|
213
|
+
also never a non-text event, because a caller spreading one would emit a duplicate of it.
|
|
214
|
+
|
|
215
|
+
**Second signature change**: a fifth argument, `rebuildResult: (text, result) => R`, applies the
|
|
216
|
+
moderated text to the generator's RETURN value. A stream has two channels and the first release of
|
|
217
|
+
this fix moderated one: the events were redacted while `step.value` — the aggregate `run()` returns
|
|
218
|
+
— still carried the original text. Measured: the guard computed `"the key is [R]"` and
|
|
219
|
+
`run().response` was `"the key is sk-abc123"`, so **`run()`, the primary non-streaming API, kept
|
|
220
|
+
delivering the secret**. That was this fix's own defect one channel over. Required for the same
|
|
221
|
+
reason `rebuildText` is; passed rather than re-derived, because re-running the guards on the
|
|
222
|
+
aggregate would apply a non-idempotent guard twice.
|
|
223
|
+
|
|
224
|
+
When the text is unchanged, the buffered events are replayed verbatim as before. When it changed,
|
|
225
|
+
the **last** text-carrying event is REPLACED by a newly built event carrying the whole moderated
|
|
226
|
+
string, and the earlier text events are dropped. Events carrying no text are never dropped. Note
|
|
227
|
+
"replaced", not "modified": any non-text payload the surviving event carried is lost, as is that of
|
|
228
|
+
the dropped ones — a consumer whose text events carry per-event metadata should moderate one kind
|
|
229
|
+
only, or rebuild from `replaced`. An event whose extracted text is the EMPTY STRING is still
|
|
230
|
+
text-carrying and can be the one replaced.
|
|
231
|
+
|
|
232
|
+
**Known consequence:** when text events straddle a non-text event, their relative order does not
|
|
233
|
+
survive a redaction. Given `text('tok ') , tool_call , text('sk-abc')` the client now receives
|
|
234
|
+
`tool_call , text('tok [R]')` — text that preceded the tool call follows it. Landing on the last
|
|
235
|
+
text-carrying event keeps a trailing terminator in place and keeps any completion claim after the
|
|
236
|
+
work that produced it; what it cannot keep is the interleaving, because the redaction is about the
|
|
237
|
+
whole string and the boundaries are gone by the time it exists. A test pins this so it is found
|
|
238
|
+
here rather than in a transcript that stopped making sense.
|
|
239
|
+
|
|
240
|
+
A guard that rewrites **unconditionally** — a disclaimer appender, a trim, an NFC normaliser —
|
|
241
|
+
takes this path on every stream that DOES carry text. The cost is not proportional to how much the
|
|
242
|
+
guard changed. It does NOT add a text event to a round that produced none — this entry claimed so,
|
|
243
|
+
and the absence check refuses it: a tool-only round yields its tool call and nothing else.
|
|
244
|
+
|
|
245
|
+
- dcb461f: The served handle offers a schema-validated, tool-using run. The capability existed and the door did not.
|
|
246
|
+
|
|
247
|
+
**The survey's conclusion did not survive measurement.** It said "structured output cannot be
|
|
248
|
+
combined with tools", on evidence that `outputFormat`, `structuredOutput`, `outputSchema` and
|
|
249
|
+
`responseFormat` returned 0 files here, and that `generateObject`'s options carry no `tools` field.
|
|
250
|
+
Both facts are true; the conclusion is not.
|
|
251
|
+
|
|
252
|
+
`generateObject` is the **toolless path by design** — it builds a transient agent whose only tool is
|
|
253
|
+
the synthetic output tool. The tool-using path is `agent.generate(input, { output })`, which runs the
|
|
254
|
+
agent's normal tool loop, the user's tools first, and then coerces the final answer into a Zod schema.
|
|
255
|
+
It exists on the published SDK's agent.
|
|
256
|
+
|
|
257
|
+
**The real gap was this layer's.** `SdkAgentHandle` — what is served to ACP, the delegation surfaces
|
|
258
|
+
and the autonomous loop — declared `send` and `dispose` and not `generate`, so a consumer of
|
|
259
|
+
`@theokit/agents` could reach it only by importing `@theokit/sdk` directly. Same shape as the session
|
|
260
|
+
store: the capability existed, the door did not.
|
|
261
|
+
|
|
262
|
+
It is optional on the interface, because a caller-injected handle (tests, a custom transport) need not
|
|
263
|
+
implement it, and requiring it would break every such double to add a method most never call.
|
|
264
|
+
|
|
265
|
+
**Both wrappers forward it**, and that is the half that actually breaks: `withStepCeiling` and
|
|
266
|
+
`withGuardrails` each construct a new object, so every method they do not name disappears — the
|
|
267
|
+
consumer meets the loss at runtime as "the handle has no `generate`", after the types said it had
|
|
268
|
+
one. Each forward is pinned by its own mutation.
|
|
269
|
+
|
|
270
|
+
`generate` is **not** put through the output guards, stated rather than assumed. It resolves to a
|
|
271
|
+
validated object and those guards moderate text; running them over a serialised object would moderate
|
|
272
|
+
a shape no guard was written against. Guarding the structured path needs its own decision about what
|
|
273
|
+
a redaction means to a schema, and inventing one here would be the gate that reports without gating
|
|
274
|
+
this module already refuses to build.
|
|
275
|
+
|
|
276
|
+
- dcb461f: A usage record can carry the cache split that justifies its cost, and usage can be narrowed by model.
|
|
277
|
+
|
|
278
|
+
**Two of the item's three claims did not survive measurement, and the correction is the point.**
|
|
279
|
+
|
|
280
|
+
The survey reported "cost is computed without cache-token accounting, so it is systematically wrong",
|
|
281
|
+
on evidence that `cacheCreation`, `cache_read`, `modelUsage` and `total_cost_usd` returned 0 files.
|
|
282
|
+
Those are the _wire_ spellings. The real field names are `cacheReadTokens` and `cacheWriteTokens`,
|
|
283
|
+
carried in five files of this layer — `DoneEvent.usage` has had them since V4-O.
|
|
284
|
+
|
|
285
|
+
Nothing in this layer _computes_ cost either: `costUsd` is supplied by the caller on the record, and
|
|
286
|
+
the storage only sums what it is given.
|
|
287
|
+
|
|
288
|
+
**What was genuinely missing is narrower and still worth closing.** `UsageRecord.tokens` was
|
|
289
|
+
`{ input, output }`, so a record stated a cost it could not explain: cached reads are billed at a
|
|
290
|
+
fraction of input tokens, and two runs with identical `input` totals and different cache ratios cost
|
|
291
|
+
different amounts. The audit trail showed the figure and not the reason. `cacheRead` and `cacheWrite`
|
|
292
|
+
are optional and **absent rather than `0`** when a provider does not report — "not reported" and
|
|
293
|
+
"reported as zero" are different facts, and defaulting would claim a measurement nobody made.
|
|
294
|
+
|
|
295
|
+
**The per-model breakdown was absent at the query surface**, not in the data: every record carries
|
|
296
|
+
`model`, and `UsageQuery` had no way to ask. It is a query rather than a second shape on
|
|
297
|
+
`UsageResult`, because a breakdown returned alongside a total is two numbers that can disagree; one
|
|
298
|
+
source asked a different question cannot.
|
|
299
|
+
|
|
300
|
+
One implementation note worth keeping. The model predicate first carried an explicit
|
|
301
|
+
`kind === 'tool'` exclusion, and a mutation proved it dead: `getUsage` already drops tool records
|
|
302
|
+
before summing, so inverting the condition left every assertion green. It was removed rather than
|
|
303
|
+
kept — a conjunct that cannot change an answer reads as a second condition somebody needed, which is
|
|
304
|
+
how a dead branch survives review.
|
|
305
|
+
|
|
306
|
+
- 1202e86: A failed injected command now aborts the expansion instead of building a prompt out of its own error message, and a fenced command block is refused instead of ignored.
|
|
307
|
+
|
|
308
|
+
**BREAKING:** `expandCommandTemplate` used to promise it never throws. It now throws in exactly two
|
|
309
|
+
cases, both of which produce NO prompt rather than a wrong one.
|
|
310
|
+
|
|
311
|
+
**The failure path.** The spec is explicit — "A failed command aborts the entire skill invocation…
|
|
312
|
+
Claude never sees the skill content for that invocation." What happened instead was substitution plus
|
|
313
|
+
a warning: a command whose `gh pr diff` failed produced a prompt containing
|
|
314
|
+
`fatal: not a git repository`, handed to a model that had been asked to review a diff, which answered
|
|
315
|
+
as though that _were_ the diff.
|
|
316
|
+
|
|
317
|
+
The previous behaviour was decided, and the reasoning it was decided against is worth keeping:
|
|
318
|
+
substituting SILENCE renders as a command that ran and returned nothing, which the model cannot
|
|
319
|
+
detect. That is correct, and it weighed the wrong two options. Substituting the ERROR is worse than
|
|
320
|
+
silence, not better — `fatal:` reads as prose, while silence at least leaves a gap. The third option
|
|
321
|
+
is the one the spec names, and the only one where the caller learns anything.
|
|
322
|
+
|
|
323
|
+
**A missing `@file` is still a warning**, deliberately. The line is what the template CAUSED versus
|
|
324
|
+
what it merely POINTED at: only the first can hand the model a plausible lie.
|
|
325
|
+
|
|
326
|
+
**The fenced form.** A ` ```! ` block containing real commands came back byte-identical — the
|
|
327
|
+
model received the command text as markdown, nothing ran, and nothing said so. It is now refused with
|
|
328
|
+
a message naming the inline form, rather than implemented: "run a multi-line block" has real
|
|
329
|
+
unanswered semantics (each line a command, or one script? which shell? what is the exit status of
|
|
330
|
+
four lines?), and inventing them would ship behaviour under a name that promises the spec's. The
|
|
331
|
+
detector is anchored to a line of its own, so an ordinary ` ```bash ` block is untouched.
|
|
332
|
+
|
|
333
|
+
Callers that relied on best-effort expansion should catch `ConfigurationError` with code
|
|
334
|
+
`command_template_segment_failed` or `command_template_fenced_block`.
|
|
335
|
+
|
|
336
|
+
- 1202e86: An operator can stop a skill body from running shell.
|
|
337
|
+
|
|
338
|
+
A skill is a markdown file a repository can carry, and `` !`command` `` in its body executes at
|
|
339
|
+
expansion time. Measured: `disableSkillShellExecution` returned 0 files here and 0 in the SDK dist,
|
|
340
|
+
against a control of 31 on the word `hooks`. The only way to decline was to stop reading skills at
|
|
341
|
+
all.
|
|
342
|
+
|
|
343
|
+
**The switch lives inside the expander, and that inverts an invariant on purpose.** The module's rule
|
|
344
|
+
was "never spawns anything and never opens a file — `shell` and `readFile` are injected, [because]
|
|
345
|
+
the trust decision is the caller's, not this module's." That _reason_ is what the operator-tier
|
|
346
|
+
decision overturned (README § "Who decides policy"): the caller decides everything the operator has
|
|
347
|
+
not spoken about. Leaving the check to the caller would have made it a constructor argument again,
|
|
348
|
+
which is the shape the decision replaced. The module still spawns nothing — it refuses _before_
|
|
349
|
+
calling the injected `shell`, and that ordering is pinned by a mutation test.
|
|
350
|
+
|
|
351
|
+
**This package reads the policy file itself**, rather than importing `@theokit/sdk`'s reader. It
|
|
352
|
+
ships from a separate repository against a published SDK (`^4.52.1 || ^5.0.0`), so a symbol added to
|
|
353
|
+
that package's source is not importable here until it is released — and a control that only works
|
|
354
|
+
after somebody else cuts a release is a control nobody can reach. One FORMAT (Claude Code's path and
|
|
355
|
+
key names) is the contract; two readers that release independently is a consequence of the repository
|
|
356
|
+
boundary, stated rather than hidden.
|
|
357
|
+
|
|
358
|
+
**What this does not pretend:** `expandCommandTemplate` has zero production callers, measured across
|
|
359
|
+
`theokit`, `theokit-sdk`, `theokit-tui`, `theokit-studio` and `theokit-hub`. The hazard has no live
|
|
360
|
+
path today. Enforcing at a call site that does not exist would have been the unreachable-control
|
|
361
|
+
failure; enforcing here means the switch bites the moment a caller appears rather than being
|
|
362
|
+
remembered then.
|
|
363
|
+
|
|
364
|
+
A value the reader cannot understand — `"disableSkillShellExecution": "true"`, a string — is reported
|
|
365
|
+
and ignored, never coerced. Accepting it by truthiness would make `"false"` forbid shell too.
|
|
366
|
+
|
|
367
|
+
- 1202e86: An operator can decide which MCP servers from a project `.mcp.json` may start. Nothing decided before.
|
|
368
|
+
|
|
369
|
+
Measured: `allowedMcpServers`, `deniedMcpServers`, `allowManagedMcpServersOnly`,
|
|
370
|
+
`enabledMcpjsonServers`, `disabledMcpjsonServers` and `enableAllProjectMcpServers` all returned 0
|
|
371
|
+
files, against a control of 31 on the word `hooks`, while `loadMcpJson` does read `<cwd>/.mcp.json`.
|
|
372
|
+
|
|
373
|
+
**The loader shipped and the gate did not, which is worse than having neither** — a consumer who
|
|
374
|
+
wanted the convenience of the file inherited the exposure without being offered the control.
|
|
375
|
+
|
|
376
|
+
**The default is now decided rather than inherited.** It stays "every declared server starts",
|
|
377
|
+
because the file is the project's own declaration and refusing it outright would break every existing
|
|
378
|
+
consumer to protect against something they wrote themselves. What changed is that an operator can
|
|
379
|
+
narrow it: `deniedMcpServers` removes named servers, and `allowedMcpServers` — once present — makes
|
|
380
|
+
the list exhaustive. An absent allow list means "no allow list", not "allow nothing"; an empty array
|
|
381
|
+
is a real decision and refuses everything.
|
|
382
|
+
|
|
383
|
+
**Deny wins over allow.** A server named in both is a contradiction, and the safe reading of a
|
|
384
|
+
contradiction is the restrictive one — resolving it the other way would let an allow entry re-enable
|
|
385
|
+
something an operator explicitly refused.
|
|
386
|
+
|
|
387
|
+
A refused server is **named** in the warning channel, like every other refusal in this loader: a
|
|
388
|
+
server that silently does not start is indistinguishable from one that started and has no tools.
|
|
389
|
+
|
|
390
|
+
One trust vocabulary, not two. This composes with the same `managed-settings.json` the hook and
|
|
391
|
+
skill-shell controls read, with the same "the project cannot switch it off" precedence and the same
|
|
392
|
+
report-never-carry rule. `TrustPosture` is unchanged and stays what it is — the gate over whether a
|
|
393
|
+
directory's config is read at all; this is the gate over which servers inside an admitted file may
|
|
394
|
+
start.
|
|
395
|
+
|
|
396
|
+
A list value of the wrong shape — `"deniedMcpServers": "postgres"` — is reported and ignored.
|
|
397
|
+
Coercing a bare string would make every character a server name; ignoring it silently would leave the
|
|
398
|
+
organisation believing a server is blocked.
|
|
399
|
+
|
|
400
|
+
- 98b2565: `resolveSettingSources` now returns `readonly GatedSettingSource[]`, and
|
|
401
|
+
`CompiledAgentOptions.settingSources` takes that type — so a setting root no `TrustPosture`
|
|
402
|
+
authorised no longer fits the field.
|
|
403
|
+
|
|
404
|
+
`define-agent.ts` claimed that field "can only ever hold roots that some posture authorized".
|
|
405
|
+
Measured against the emitted `.d.ts`: `setOnce(draft, 'settingSources', ['mdm','team','user','plugins'], 'cap')`
|
|
406
|
+
typechecked **cast-free**. Writing a `Capability` is the documented way to extend the builder, and a
|
|
407
|
+
capability writes the draft directly — so the gate was reachable around, for `project`, the root it
|
|
408
|
+
exists to protect.
|
|
409
|
+
|
|
410
|
+
A brand rather than a runtime check, because the obvious runtime check does not work: reading
|
|
411
|
+
`draft.provenance` to refuse a capability's write would also refuse the LEGITIMATE builder path,
|
|
412
|
+
which writes through `setOnce` too. What differs is where the value came from, and that is what a
|
|
413
|
+
brand carries.
|
|
414
|
+
|
|
415
|
+
**It refuses the accident, not the determined caller** — `as never` defeats it, like every brand.
|
|
416
|
+
Saying so is the point: the comment it replaces claimed an invariant nothing enforced.
|
|
417
|
+
|
|
418
|
+
**New: `settingSources.plugins`**, taking the same `ProjectSettingsGrant` as `project`.
|
|
419
|
+
`PluginsManager.refresh` loads executable bundles from the same cwd-controlled tree, usually
|
|
420
|
+
arriving with the clone, so it gets the same gate and not a weaker one. This is the root the SDK
|
|
421
|
+
genuinely reads and the facade withheld.
|
|
422
|
+
|
|
423
|
+
`team` and `mdm` stay absent, and that is the item's original premise dying under measurement: the
|
|
424
|
+
SDK never reads them — `includesSetting` is called with exactly `"project"` and `"plugins"` — so
|
|
425
|
+
forwarding them would be a capability in the type and nothing at runtime.
|
|
426
|
+
|
|
427
|
+
**Migration**: a consumer constructing `CompiledAgentOptions` by hand must build roots through
|
|
428
|
+
`resolveSettingSources` instead of a string array. That is the supported construction and always was.
|
|
429
|
+
|
|
430
|
+
**Also: a narrowed `claudeCode.import` is now REFUSED on an SDK that cannot read it.**
|
|
431
|
+
|
|
432
|
+
That field's docblock said the narrowed form was "refused at resolve time" below `@theokit/sdk`
|
|
433
|
+
5.4.0. Nothing read a version for it — the only checks in this layer are the hook gate (a different
|
|
434
|
+
option) and a `compatSources` warning that returns silently for any major ≥ 5. So on
|
|
435
|
+
5.0.0 ≤ SDK < 5.4.0, inside this package's declared `^4.52.1 || ^5.0.0`, a narrowed `import` was
|
|
436
|
+
forwarded, dropped by the runtime in silence, and the foreign root was **not read at all** — a
|
|
437
|
+
consumer asking for "the skills but not the hooks" got nothing, which is further from what they
|
|
438
|
+
asked for than the un-narrowed form. `compatSources` landed in 5.0.0 and the narrowing in 5.4.0;
|
|
439
|
+
treating the two versions as one was the defect.
|
|
440
|
+
|
|
441
|
+
`CompatImportUnsupportedError` now refuses, naming both versions and what would otherwise happen.
|
|
442
|
+
It refuses rather than warns because a silent nothing is discovered by wondering why a skill is
|
|
443
|
+
missing. An unreadable version is refused too: "cannot tell" and "is supported" must not collapse.
|
|
444
|
+
|
|
445
|
+
**And `commands` is subtracted before the compat sources reach the SDK.** The two vocabularies
|
|
446
|
+
diverge by one name on purpose — `.claude/commands/*.md` is read by this package and never by the
|
|
447
|
+
SDK — and `setting-sources-gate.ts` prescribed the subtraction as advice to consumers while the
|
|
448
|
+
projection that needed it did not do it. Measured: `import: ['commands']` forwarded a list
|
|
449
|
+
containing zero names the SDK defines, which is its own empty-list case — the exact ambiguity
|
|
450
|
+
`resolveCompatSources` refuses one layer up. A source whose surfaces all belong to this layer
|
|
451
|
+
is now dropped from the SDK's list rather than sent empty.
|
|
452
|
+
|
|
453
|
+
`CompatImportUnsupportedError` is exported from `@theokit/agents/bridge`, so a consumer can catch the
|
|
454
|
+
refusal by class rather than by matching its message.
|
|
455
|
+
|
|
456
|
+
- 59d6dcc: A transcript nobody can read is listed without an id, and keeps its protection
|
|
457
|
+
|
|
458
|
+
`listSessions` fell back to the filename stem whenever the first record could not be read. Under
|
|
459
|
+
`@theokit/sdk` 5.x that stem is a one-way hash of the id, so the fallback did not return a degraded
|
|
460
|
+
id — it returned an identifier belonging to no session. Session GC keyed protection on it, so a
|
|
461
|
+
session someone had DECLARED protected lost its protection and was planned for deletion, while the
|
|
462
|
+
registry removal was called with the hash and left the real entry behind.
|
|
463
|
+
|
|
464
|
+
Protection now runs in the direction that has a function. `transcriptPath(root, cwd, id)` is total on
|
|
465
|
+
both majors and its inverse is not, so protection is keyed by transcript PATH and caller-supplied ids
|
|
466
|
+
are mapped forward onto paths. Whether a transcript can be read stops mattering to whether it is
|
|
467
|
+
protected.
|
|
468
|
+
|
|
469
|
+
Breaking, inside the unreleased 13.0.0 line:
|
|
470
|
+
|
|
471
|
+
- `SessionSummary.id` is `string | undefined`, alongside a new `idSource: 'transcript' | 'unavailable'`.
|
|
472
|
+
It is never derived from the filename.
|
|
473
|
+
- **`protectedTranscripts` is RENAMED to `protectedTranscriptPaths`, and the rename is the fix.** Its
|
|
474
|
+
keys changed from session ids to transcript paths while the signature stayed `Map<string, string>`,
|
|
475
|
+
so a consumer that mapped the keys forward through `transcriptPath` — correct when they were ids —
|
|
476
|
+
kept compiling and started double-mapping, leaving its protection array matching nothing. Measured
|
|
477
|
+
on a real consumer against this build: `deleteSession` collected a session holding a LIVE writer
|
|
478
|
+
lease. The old name is gone rather than aliased; a silent break that loses data is worse than a
|
|
479
|
+
loud one, and an alias would have preserved the silence. `transcriptOf(id, cwd, root)` maps forward
|
|
480
|
+
for callers that hold an id.
|
|
481
|
+
- `GCCandidate.id`, `GCKept.id` and `GCError.id` admit `undefined` for the same reason.
|
|
482
|
+
- `RunTranscriptGCResult` gains `orphaned` — transcripts collected whose session id could not be
|
|
483
|
+
read, by path. A separate list rather than a second meaning inside `removed`, which answers "which
|
|
484
|
+
sessions did I collect"; `theo sessions gc` reports both, and never prints a filename where an id
|
|
485
|
+
belongs.
|
|
486
|
+
|
|
487
|
+
Closes usetheokit/theokit#668.
|
|
488
|
+
|
|
489
|
+
- feb5781: `AgentBuilder.create().hookApproval()` — the fluent twin of `defineAgent({ hookApproval })`
|
|
490
|
+
|
|
491
|
+
`13.0.0-next.7` shipped the hook approval gate reachable through `defineAgent` and capabilities, and
|
|
492
|
+
not through the fluent builder. A consumer that authors with `AgentBuilder.create()` had no way to
|
|
493
|
+
reach it: `.use()` composes presets rather than sinking capabilities, and the definition reaches
|
|
494
|
+
`streamAgentTurnInProcess` with `local` already assembled, so there was no downstream place to inject
|
|
495
|
+
`local.hooks` by hand either.
|
|
496
|
+
|
|
497
|
+
Fifth instance of one family and a NEW variant. The first four were "the symbol exists and the barrel
|
|
498
|
+
omits it", which the emitted-export guard now catches. This one is the opposite: the symbol is
|
|
499
|
+
exported, on the compiled waist, and reachable through one authoring door — the other door simply
|
|
500
|
+
does not offer it, and an export check is green on that.
|
|
501
|
+
|
|
502
|
+
So the guard for this variant builds the SAME agent through BOTH doors and asserts they arrive at the
|
|
503
|
+
same compiled waist. It catches the worst case too: an interface that declares the method while the
|
|
504
|
+
factory never wires it compiles, does nothing, and fails this test.
|
|
505
|
+
|
|
506
|
+
- e7a4d65: **A declared `.claude/` now reaches commands too** (theocode B-152).
|
|
507
|
+
|
|
508
|
+
`loadCustomCommands` takes `compatSources`, in the SDK's own vocabulary: `['claude-code']` adds
|
|
509
|
+
`<projectDir>/.claude/commands/` to what it reads.
|
|
510
|
+
|
|
511
|
+
Three surfaces already reached that directory when a consumer declared it — hooks, skills and
|
|
512
|
+
subagents, through the SDK's `compatSources`. Commands are loaded by this package instead, and were
|
|
513
|
+
the one surface that never learned about it. Measured against a real TUI: the same command file was
|
|
514
|
+
invocable under `.theokit/commands/` and silent under `.claude/commands/`, with no diagnostic
|
|
515
|
+
anywhere. A partial dialect is worse than none — whoever watched the other three work has no reason
|
|
516
|
+
to suspect the fourth.
|
|
517
|
+
|
|
518
|
+
**The trust gate does not move.** The foreign directory is read only when the caller declared it
|
|
519
|
+
AND the project is trusted, which is the same pair `resolveCompatSources` already requires. A
|
|
520
|
+
command is a prompt that runs on the operator's behalf, and this one usually arrives with the
|
|
521
|
+
repository, written for another product. An untrusted project now COUNTS the foreign commands it
|
|
522
|
+
refused, so the refusal is not silent either.
|
|
523
|
+
|
|
524
|
+
**The native root wins a name collision**, and the two frontmatter vocabularies stay separate: this
|
|
525
|
+
loader reads `description:` and nothing else, so the other product's `model` and `argument-hint`
|
|
526
|
+
are carried in the body rather than adopted.
|
|
527
|
+
|
|
528
|
+
- fe8a0c6: **`compatSources` reaches the SDK, and says so when the installed SDK cannot hear it** (#634).
|
|
529
|
+
|
|
530
|
+
`@theokit/sdk` stopped reading `<cwd>/.claude/` unconditionally (`theokit-sdk#524`) and put it
|
|
531
|
+
behind `local.compatSources`. This layer had no way to forward that, so an agent built here could
|
|
532
|
+
not opt into the foreign dialect at all.
|
|
533
|
+
|
|
534
|
+
The issue held the work back for a real reason: forwarding an option an older SDK does not know
|
|
535
|
+
would be **silently inert** — the operator declares it, nothing reads `.claude/`, and no message
|
|
536
|
+
explains why. The silence turns out to be removable from this side. `@theokit/sdk` declares
|
|
537
|
+
`"./package.json"` in `exports` — verified on **4.52.1**, the oldest version a consumer can have
|
|
538
|
+
today, not only on the 5.x prerelease — so the installed version is readable at runtime and a
|
|
539
|
+
mismatch warns once per process. The floor stays `^4.52.1`; nobody is pinned to a prerelease.
|
|
540
|
+
|
|
541
|
+
Declaring the dialect goes through `settingSources`, next to the roots it already gates:
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
settingSources: {
|
|
545
|
+
project: { trustedBy: posture },
|
|
546
|
+
claudeCode: { trustedBy: posture }, // reads <cwd>/.claude/
|
|
547
|
+
}
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
**Two questions, answered by two different halves of that field**, because the SDK's own docblock
|
|
551
|
+
separates them: _declaring_ the field answers "do I want another product's configuration imported?"
|
|
552
|
+
(omitting is not enabling), and the `TrustPosture` inside answers "do I trust this directory's code
|
|
553
|
+
to run?". The grant is `ProjectSettingsGrant` — the same type `project` takes — not because the
|
|
554
|
+
questions are the same, but because a separate `'foreignDialects'` capability could not carry the
|
|
555
|
+
distinction: `TrustPosture.allows` is `Record<K, boolean>` whose values all move with the trust
|
|
556
|
+
level, so a second name would promise an operator a choice `resolveTrustPosture` never gives them.
|
|
557
|
+
|
|
558
|
+
This is deliberately **stricter than the SDK**, where listing a dialect is sufficient: `.claude/`
|
|
559
|
+
holds a `hooks.json` that executes shell, in a directory that usually arrived with the clone.
|
|
560
|
+
|
|
561
|
+
- 2bc5d84: `delegate()` now applies the guardrails its spec declares. It accepted them and never consulted them.
|
|
562
|
+
|
|
563
|
+
Measured against the built artifact with a guard declaring both halves: the input reached the model
|
|
564
|
+
with its injection intact and the caller received `sk-abc123`. `bridge/agent-orchestrator.ts`
|
|
565
|
+
contained **zero** occurrences of `guardrail` — control on the same sweep: `loop/agent-runner.ts`
|
|
566
|
+
has 9 — and called `runReflectiveLoop` bare. The operator had declared guardrails and the run was
|
|
567
|
+
green.
|
|
568
|
+
|
|
569
|
+
This is the same defect class as the streamed-redaction fix in this release, on a sibling public
|
|
570
|
+
API, and it is model-reachable: `tools/delegate-tool.ts` wraps `delegate()`, so an agent can invoke
|
|
571
|
+
a sub-agent whose declared guards do nothing.
|
|
572
|
+
|
|
573
|
+
`checkInput` runs after `onDelegationStart` and `checkOutput` after `onDelegationComplete` — each
|
|
574
|
+
moderating what actually crosses the boundary rather than a string a hook may then rewrite. A
|
|
575
|
+
blocking input guard throws before the model is called at all, so a refused delegation costs
|
|
576
|
+
nothing.
|
|
577
|
+
|
|
578
|
+
`response` only. `toolCalls[].output` is tool output rather than model text, and `agent-runner.ts`
|
|
579
|
+
excludes it from `extractText` on the same reasoning; the docblock says so, because leaving it alone
|
|
580
|
+
should be a decision somebody reads rather than an omission somebody discovers.
|
|
581
|
+
|
|
582
|
+
A spec declaring no guardrails behaves byte-identically.
|
|
583
|
+
|
|
584
|
+
**A guardrail block now crosses the delegate tool as a refusal, not a crash.** `errorCodeOf` mapped
|
|
585
|
+
only the three delegation errors, so `GuardrailViolationError` — reachable from `delegate()` for the
|
|
586
|
+
first time because of this change — hit the "not a delegation outcome, a defect" arm and was
|
|
587
|
+
rethrown, ending the parent's turn. The tool's own description, shipped to the model, promises
|
|
588
|
+
`{ ok: false, error, message }` on a refusal.
|
|
589
|
+
|
|
590
|
+
It crosses as `guardrail_violation` with a FIXED message: `the delegated task was refused by a
|
|
591
|
+
policy guard`. The message travels only for codes on an explicit ALLOWLIST — a budget or a timeout
|
|
592
|
+
is a fact about the work, and the number in it is what the model acts on. A code nobody lists
|
|
593
|
+
withholds, so forgetting is safe in the direction that matters. A guardrail message is not — it reads
|
|
594
|
+
`Guardrail "pii-detector" blocked output: ssn found`, naming the guard and its exact trigger, and a
|
|
595
|
+
model given that learns which words to avoid rather than that it should stop. The operator keeps the
|
|
596
|
+
full typed error, which carries `guardName`, `phase` and `reason`.
|
|
597
|
+
|
|
598
|
+
- dcb461f: A declared `canUseTool` gate reaches the runtime, so a tool call the earlier steps did not resolve has somebody to ask.
|
|
599
|
+
|
|
600
|
+
Measured: `canUseTool` returned 0 files in this layer, against controls of `hooks` 31 and `session` 37. The SDK has the seam — a permission plugin invoked on an `ask` verdict — and this layer offered
|
|
601
|
+
no way to reach it.
|
|
602
|
+
|
|
603
|
+
**The default was never the problem, and saying so matters.** The SDK's engine is fail-closed: an
|
|
604
|
+
unmatched call resolves to `ask`, and an _absent_ gate blocks it. A tool added after the approvals
|
|
605
|
+
were written already defaulted to asking. What was missing is that the ask reached nobody, so it
|
|
606
|
+
resolved to a refusal with no way to decide otherwise.
|
|
607
|
+
|
|
608
|
+
`CanUseToolCapability` writes the gate and the adapter projects it as a permission plugin over an
|
|
609
|
+
engine with **no rules** — so every call resolves to `ask` and every call reaches the gate. That is
|
|
610
|
+
what "sees every tool call the earlier steps did not resolve" means when there are no earlier steps.
|
|
611
|
+
A consumer who also wants rules composes them through the SDK directly; accepting both here would
|
|
612
|
+
mean inventing a precedence between a rule set and a gate that nobody stated.
|
|
613
|
+
|
|
614
|
+
The plugin is **appended**, never replacing: a consumer that already registers lifecycle plugins must
|
|
615
|
+
not have them dropped by declaring a gate. And only when a gate was declared — installing an empty
|
|
616
|
+
permission plugin would gate every `ask` verdict on a callback that does not exist, which the SDK
|
|
617
|
+
resolves by blocking. That is strictly worse than the absence it would replace.
|
|
618
|
+
|
|
619
|
+
**`updatedInput` is decided upstream, not by silence here.** The spec lets a gate _correct_ a call,
|
|
620
|
+
and the SDK states its position: the `pre_tool_call` seam is veto-only, and arg rewrite is
|
|
621
|
+
intentionally unsupported. Surfacing a gate that accepted an `updatedInput` this runtime would
|
|
622
|
+
discard is the fabricated mechanism this backlog keeps finding; the narrower contract is carried as
|
|
623
|
+
it is.
|
|
624
|
+
|
|
625
|
+
- 019f828: Output guards now moderate `thinking` events, not only `text_delta`.
|
|
626
|
+
|
|
627
|
+
`AgentRunner` handed `moderateOutputStream` an extractor matching `text_delta` and nothing else,
|
|
628
|
+
while `thinking` is a public `AgentStreamEvent` that reaches the client like any other. Measured: a
|
|
629
|
+
guard declared over the agent's output delivered `thinking "the key is sk-abc123"` verbatim.
|
|
630
|
+
|
|
631
|
+
**This closes one channel and does not close all of them.** `DoneEvent.result` carries the model's
|
|
632
|
+
whole answer and is still unmoderated — measured on the same turn, `text_delta` came out
|
|
633
|
+
`"here: [R]"` while `done.result` came out `"here: sk-abc123"`. `task_progress.text` is a fourth and
|
|
634
|
+
reaches the web wire. A third pass does not extend to them: there is one `done` per round, so a pass
|
|
635
|
+
keyed on it would collapse every round's into one, and they need a different mechanism. Tracked
|
|
636
|
+
separately; stated here because a security note that overstates its coverage is worse than one that
|
|
637
|
+
does not exist.
|
|
638
|
+
|
|
639
|
+
The third channel of a shape fixed twice already in this release — a streamed redaction that was
|
|
640
|
+
computed and discarded, and `delegate()` consulting no guards at all.
|
|
641
|
+
|
|
642
|
+
**Two passes, not one wider extractor.** Widening `extractText` to match both kinds is the obvious
|
|
643
|
+
move and the wrong one: two kinds under one extractor COLLAPSE into a single event, so the reasoning
|
|
644
|
+
would be promoted into a visible one — the moderation creating the disclosure it exists to close.
|
|
645
|
+
Composing two passes is what `moderateOutputStream`'s own docblock prescribes, and each pass seeing
|
|
646
|
+
one kind is what keeps them apart.
|
|
647
|
+
|
|
648
|
+
The visible pass owns the aggregate: `DelegationResult.response` accumulates from `text_delta`
|
|
649
|
+
upstream, so the reasoning pass passes the result through rather than replacing it.
|
|
650
|
+
|
|
651
|
+
A blocking guard on either channel still throws before any event is emitted. An agent with no output
|
|
652
|
+
guard is byte-identical.
|
|
653
|
+
|
|
654
|
+
- 6ca2f50: Names the settings precedence stack: `SettingsLayer`, `SETTINGS_LAYERS`, `layerPrecedence` and
|
|
655
|
+
`settingsLayerChain` in `@theokit/agents/config`.
|
|
656
|
+
|
|
657
|
+
The SDK ships the mechanism — `foldLayers` folds `{ layer, precedence?, values }` and
|
|
658
|
+
`verifyLayerOrdering` refuses a self-contradicting chain — and deliberately not the vocabulary:
|
|
659
|
+
`DeclaredLayer.layer` is a free-form string and `precedence` is optional. That is the right shape
|
|
660
|
+
for a library, and it left one thing undecided that two consumers must agree on: which layers exist
|
|
661
|
+
and in what order. Each could invent their own names and numbers, fold in opposite orders, and both
|
|
662
|
+
pass `verifyLayerOrdering`, because a chain is only ever checked against itself.
|
|
663
|
+
|
|
664
|
+
The order is the format's, measured against <https://code.claude.com/docs/en/settings>: managed
|
|
665
|
+
settings, command line, project local, shared project, user. `code` — what `defineAgent()` was
|
|
666
|
+
passed — sits below all five, because every file above it is editable by a human who did not write
|
|
667
|
+
the code and is answerable for what the agent does on their machine. That single line is the whole
|
|
668
|
+
operator tier, and it reconciles the three levels B-026 named for four policy keys with the full
|
|
669
|
+
stack instead of leaving them beside it.
|
|
670
|
+
|
|
671
|
+
A layer added to the union without a declared position now fails to COMPILE, the same gate this
|
|
672
|
+
package puts on the compiled-options waist.
|
|
673
|
+
|
|
674
|
+
- a7f4d3d: `deleteSession` stops reporting a registry removal it cannot confirm
|
|
675
|
+
|
|
676
|
+
`registryRemoved` was computed as `outcome !== false`, so a remover that resolved saying **nothing**
|
|
677
|
+
was reported as a removal. That is the shape `Agent.delete` has — `Promise<void>` — and below
|
|
678
|
+
`@theokit/sdk@5.3.1` it is what a no-op returns: measured in `4.52.1`, `removeRegisteredAgent`
|
|
679
|
+
mutates an in-memory map and schedules a save only when the entry was in it, while `delete` — unlike
|
|
680
|
+
`list` — never hydrates from disk. In any freshly started process the entry is not in memory, so the
|
|
681
|
+
call resolves having left `registry.json` untouched and throws nothing.
|
|
682
|
+
|
|
683
|
+
Breaking, inside the unreleased 13.0.0 line:
|
|
684
|
+
|
|
685
|
+
- `DeleteSessionResult.registryRemoved: boolean` is replaced by
|
|
686
|
+
`registryOutcome: 'removed' | 'nothing-to-remove' | 'unconfirmed' | 'failed' | 'not-attempted'`.
|
|
687
|
+
Renamed rather than retyped: `if (result.registryRemoved)` would have kept compiling while
|
|
688
|
+
silently changing which branch it took.
|
|
689
|
+
- `SessionInUseError.registryRemoved` becomes `registryOutcome`, and the refusal no longer advertises
|
|
690
|
+
"the registry entry was already removed" for a half it cannot confirm — it tells the caller to
|
|
691
|
+
verify instead.
|
|
692
|
+
|
|
693
|
+
`not-attempted` and `unconfirmed` are distinct on purpose: "nobody asked" and "we asked and got
|
|
694
|
+
silence" lead a caller to opposite actions, and the old boolean said `false` to both.
|
|
695
|
+
|
|
696
|
+
The declared SDK range is unchanged. `5.3.1` fixes the behaviour and not the signature — `delete`
|
|
697
|
+
still returns `Promise<void>` — so `unconfirmed` remains the honest answer on every admitted version,
|
|
698
|
+
and raising the floor is a separate decision that would make the removal happen without making it
|
|
699
|
+
reportable.
|
|
700
|
+
|
|
701
|
+
Closes usetheokit/theokit#675.
|
|
702
|
+
|
|
703
|
+
- 0ed0d96: Telemetry is reachable from the authoring surface: `defineAgent({ telemetry })`,
|
|
704
|
+
`AgentBuilder.create().telemetry(...)`, and `TelemetryCapability` all forward the SDK's
|
|
705
|
+
`TelemetrySettings` to `Agent.create({ telemetry })`, so a run emits OpenTelemetry spans for
|
|
706
|
+
`agent.send`, `llm.call`, `tool.call` and `memory.search`.
|
|
707
|
+
|
|
708
|
+
The SDK has emitted these spans since 4.52.1 — with an exporter selector, a service name, and
|
|
709
|
+
auto-detection of Langfuse / Sentry / PostHog. This layer never passed the field through, so an
|
|
710
|
+
operator could not turn any of it on and had no way to see a run as a trace alongside the rest of
|
|
711
|
+
their system. Measured before the change: four occurrences of the word "telemetry" in the package
|
|
712
|
+
source, all four in prose, zero assignments.
|
|
713
|
+
|
|
714
|
+
`@opentelemetry/api` is an OPTIONAL peer of the SDK. Without it, telemetry is a silent no-op even
|
|
715
|
+
with `enabled: true` — which is the usual explanation for a run that reports no spans, rather than a
|
|
716
|
+
misconfigured collector. Content (prompts, responses, tool args) is omitted unless you set
|
|
717
|
+
`includeContent: true`.
|
|
718
|
+
|
|
719
|
+
- 462bb62: The pre-spawn hook approval gate crosses the layer, or refuses to pretend it did
|
|
720
|
+
|
|
721
|
+
`@theokit/sdk@5.4.0` added `local.hooks.approve` — a consumer's decision point before the runtime
|
|
722
|
+
spawns a hook. This layer never forwarded it, so a hook declared in a config root, including a
|
|
723
|
+
foreign dialect imported through `compatSources`, ran shell without passing the consumer's approval.
|
|
724
|
+
|
|
725
|
+
Measured in a consumer against 5.4.0, with a control arm proving the zero was not an empty turn:
|
|
726
|
+
|
|
727
|
+
```
|
|
728
|
+
control_nothing fires=0 tool_ran=yes
|
|
729
|
+
claude_project_unapproved fires=1 tool_ran=yes
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
`defineAgent({ hookApproval })` and the new `HookApprovalCapability` now carry it to
|
|
733
|
+
`Agent.create({ local: { hooks } })`. Named `hookApproval` rather than `hooks` because
|
|
734
|
+
`defineAgent({ hooks })` is already the LIFECYCLE seam, and two security-relevant things under one
|
|
735
|
+
name is how a consumer configures the wrong one.
|
|
736
|
+
|
|
737
|
+
**Declaring it against an SDK older than 5.4.0 is REFUSED, not forwarded.** The option is 5.4.0-only
|
|
738
|
+
while this package's floor is `^4.52.1`, so a pass-through would compile and do nothing on most
|
|
739
|
+
admitted versions — a gate that silently does not gate, which is worse than offering none, because
|
|
740
|
+
whoever configured it stops looking. An unreadable SDK version is refused for the same reason:
|
|
741
|
+
"cannot tell" and "is gated" must not collapse.
|
|
742
|
+
|
|
743
|
+
The floor is unchanged, so no consumer is pinned to a newer SDK for a feature they did not ask for.
|
|
744
|
+
|
|
745
|
+
Closes usetheokit/theokit#686 (the `hooks` half; `resolveCompatSources` widening is tracked there).
|
|
746
|
+
|
|
747
|
+
- ecda4ae: The agent-module parameter names what it accepts, so a wrong shape fails at compile time
|
|
748
|
+
|
|
749
|
+
`streamAgentTurnInProcess` and `compileAgentModule` took `mod: unknown`, so the contract lived only
|
|
750
|
+
in the runtime guard. A consumer whose producer became `async` handed a `Promise` straight through
|
|
751
|
+
and shipped two releases in which no turn could run — with typecheck, 1213 tests, lint and twelve CI
|
|
752
|
+
checks green. The runtime was never wrong: it refused the Promise and threw a typed error. What
|
|
753
|
+
failed was the moment.
|
|
754
|
+
|
|
755
|
+
Both now take `AgentModule`, exported alongside them and re-exported from `theokit/server/agent`.
|
|
756
|
+
The type mirrors the runtime guard rather than the fuller `CompiledAgentOptions` — an array under
|
|
757
|
+
`tools`, an object under `agents` — so it refuses nothing that compiled before.
|
|
758
|
+
|
|
759
|
+
`@theokit/tauri/sidecar`'s `runTurnToJsonl` is narrowed too: a desktop sidecar imports its agent
|
|
760
|
+
module statically, which is exactly where a type catches the mistake.
|
|
761
|
+
|
|
762
|
+
Entry points that receive a module from a path discovered at runtime keep taking `unknown`, and now
|
|
763
|
+
say so by calling the new `compileLoadedAgentModule`. Their `unknown` has a reason; before this, the
|
|
764
|
+
boundary that had one was indistinguishable from the one that did not.
|
|
765
|
+
|
|
766
|
+
Closes usetheokit/theokit#663.
|
|
767
|
+
|
|
768
|
+
- cfe7f4c: **`@theokit/sdk@5.x` is now supported, alongside 4.x**
|
|
769
|
+
([#654](https://github.com/usetheokit/theokit/issues/654)).
|
|
770
|
+
|
|
771
|
+
The declared range becomes `^4.52.1 || ^5.0.0` (`^4.49.0 || ^5.0.0` for `@theokit/presenter`), and
|
|
772
|
+
the full suite passes on both halves: 7533 tests against `4.52.1` and 7533 against `5.0.1`.
|
|
773
|
+
|
|
774
|
+
This unblocks plugins whose open-ended `@theokit/sdk` peer resolves to 5.x. Until now `theokit` was
|
|
775
|
+
the package that **refused** that resolution — the ERESOLVE named the plugin, but the bound that
|
|
776
|
+
could not be satisfied was this one.
|
|
777
|
+
|
|
778
|
+
**What had to change, and why it was not a version bump.** SDK 5.x writes a transcript to
|
|
779
|
+
`${sessionUuidFor(sessionId)}.jsonl` where 4.x wrote `${safeSessionId(sessionId)}.jsonl` — a
|
|
780
|
+
SHA-256 over a namespace, so the filename stopped being the session id and the mapping does not
|
|
781
|
+
invert. `listSessions` derived ids from filenames, so listing, protection, GC and deletion all
|
|
782
|
+
returned UUIDs where callers passed ids. One defect, twenty-nine failing tests.
|
|
783
|
+
|
|
784
|
+
The id is now read from the transcript **record**, which the SDK writes on both majors and which is
|
|
785
|
+
authoritative where the name was only a convention. The filename stem remains the fallback, so a
|
|
786
|
+
truncated transcript still appears in a listing rather than dropping out of GC's sight.
|
|
787
|
+
|
|
788
|
+
`LiveTranscriptError` — 5.x's new name for `LiveSessionError` — deliberately does not cross the
|
|
789
|
+
`@theokit/agents` layer: it does not exist on the 4.x half, and 5.x keeps the old name working and
|
|
790
|
+
deprecated, so the name that crosses is the one both majors have.
|
|
791
|
+
|
|
792
|
+
- 0731584: `resolveCompatSources` now returns `readonly GatedCompatSource[]`, and
|
|
793
|
+
`CompiledAgentOptions.compatSources` takes that type — so a compat source no `TrustPosture`
|
|
794
|
+
authorised no longer fits the field.
|
|
795
|
+
|
|
796
|
+
**BREAKING for hand-built compiled options**, exactly as its sibling was. Build compat sources
|
|
797
|
+
through `resolveCompatSources`.
|
|
798
|
+
|
|
799
|
+
The twin of the `settingSources` brand, and it exists because that fix closed one of the two fields
|
|
800
|
+
one `SettingSourcesSelection` feeds and left the other bare. Measured, with the `settingSources`
|
|
801
|
+
route as the control: the control errored, and `setOnce(draft, 'compatSources', ['claude-code'],
|
|
802
|
+
'cap')` compiled cast-free — while `agent-compiler.ts` told the reader that field "can only hold a
|
|
803
|
+
source some posture granted".
|
|
804
|
+
|
|
805
|
+
It carries more authority than its twin, not less. `applyLocalSources` forwards it to
|
|
806
|
+
`Agent.create({ local: { compatSources } })`, which reads `<cwd>/.claude/` — `hooks.json` included,
|
|
807
|
+
and that executes shell.
|
|
808
|
+
|
|
809
|
+
**Signature narrowing**: `moderateOutputStream`'s `rebuildText` is now
|
|
810
|
+
`(text: string, replaced: E) => E`. It was typed `E | undefined` for a case that cannot happen — a
|
|
811
|
+
stream where no event carried text returns from the absence check before the guards run, so
|
|
812
|
+
`rebuildText` is never reached. The branch handling that case was dead code, and three shipping
|
|
813
|
+
artifacts described it as live.
|
|
814
|
+
|
|
815
|
+
**Fixed**: `isPort` discriminated on the presence of `run`, so an object carrying both `compiled`
|
|
816
|
+
and a `run` — reachable through a spread, which is how targets are built in practice — took the port
|
|
817
|
+
branch and skipped `delegate()` entirely: no declared guardrails, no inherited parent veto, no
|
|
818
|
+
budget clamp. A tie now goes to the spec, because the spec branch is the guarded one.
|
|
819
|
+
|
|
820
|
+
### Patch Changes
|
|
821
|
+
|
|
822
|
+
- 1202e86: A backslash escapes a `$N` placeholder: `\$1` now renders as the literal `$1` with the backslash
|
|
823
|
+
dropped, instead of being substituted with the first argument.
|
|
824
|
+
|
|
825
|
+
Measured before the fix: `price \$1.00 here` with argument `alpha` produced `price \alpha.00 here`.
|
|
826
|
+
A template describing a price produced a template describing an argument, and the backslash the
|
|
827
|
+
author typed to prevent that survived into the output as stray punctuation.
|
|
828
|
+
|
|
829
|
+
**Two neighbouring behaviours are deliberately unchanged**, because a parity survey flagged all three
|
|
830
|
+
together and only one of them is a defect:
|
|
831
|
+
|
|
832
|
+
- `$1` is the FIRST argument here. That convention is stated in the module docblock, fixed by its
|
|
833
|
+
existing tests, and depended on by its own `` !`git diff $1` `` example. Changing it would silently
|
|
834
|
+
rebind every argument of every command already written — a compatibility decision, not a fix.
|
|
835
|
+
- An unmatched `$3` expands to empty rather than to the literal, which the call site documents:
|
|
836
|
+
"Empty, never the literal. A leaked `$3` reads to the model as text the user wrote."
|
|
837
|
+
|
|
838
|
+
The escape was the one of the three with no decision behind it.
|
|
839
|
+
|
|
840
|
+
- bb0f451: A guardrail refusal thrown inside a round is no longer renamed into a delegation failure.
|
|
841
|
+
|
|
842
|
+
`runReflectiveLoop` wrapped any error that was not already a delegation error into
|
|
843
|
+
`DelegationError`, whose message reads `Delegation to agent "X" failed: ${cause.message}`. That code
|
|
844
|
+
is on the delegate tool's message allowlist — a delegation failure's text is a fact about the work —
|
|
845
|
+
so the wrapper carried the guard's own words to the model: `Guardrail "pii-detector" blocked output:
|
|
846
|
+
ssn found`, naming the guard and its exact trigger.
|
|
847
|
+
|
|
848
|
+
Measured through `createDelegateTool` with a consumer-supplied `streamFactory` that throws
|
|
849
|
+
mid-round. `streamFactory` is a public option, so this was reachable rather than theoretical.
|
|
850
|
+
|
|
851
|
+
`GuardrailViolationError` now passes through as itself, alongside the two delegation errors that
|
|
852
|
+
already did, so the tool classifies it `guardrail_violation` and withholds the message.
|
|
853
|
+
|
|
854
|
+
Fixed by classification rather than by suppressing text downstream: a guard refusal is not a
|
|
855
|
+
delegation failure, and a layer that renames an error cannot be expected to maintain a list of what
|
|
856
|
+
the new name must hide.
|
|
857
|
+
|
|
858
|
+
- 9725bf1: A malformed guardrail result no longer hands the model the name of the guard that failed, and a third guardrail error class is covered by construction.
|
|
859
|
+
|
|
860
|
+
B-015 made `GuardrailViolationError` pass through `run-reflective-loop.ts` unwrapped, because
|
|
861
|
+
`DelegationError` interpolates its cause and `delegation_failed` is on the delegate tool's message
|
|
862
|
+
allowlist. Its sibling in the same file was not added. `MalformedGuardrailResultError` takes the same
|
|
863
|
+
wrapping, and its message also names the guard: `Guardrail "X" returned action 'redact' for output
|
|
864
|
+
with no replacement text.`
|
|
865
|
+
|
|
866
|
+
Smaller payload than a violation — the guard's name and phase, not its trigger text — and the same
|
|
867
|
+
leak through the same allowlist. It additionally **mislabelled a guard defect as a delegation
|
|
868
|
+
failure**, which is a fact about the work the model would act on.
|
|
869
|
+
|
|
870
|
+
**The passthrough is now structural.** `GuardrailError` is the abstract base every guardrail error
|
|
871
|
+
shares, and `run-reflective-loop.ts` and `errorCodeOf` both key on it — so a new class is covered by
|
|
872
|
+
its own declaration rather than by someone remembering to extend a list of two. A list of two is how
|
|
873
|
+
this defect existed.
|
|
874
|
+
|
|
875
|
+
A base alone is not airtight: a class can still extend `TheokitAgentError` directly. The test walks
|
|
876
|
+
the guardrails barrel and fails on any exported error class that skipped the base. The two together
|
|
877
|
+
are the construction; either alone is a convention.
|
|
878
|
+
|
|
879
|
+
A malformed result crosses as `guardrail_error`, not `guardrail_violation`: a guard that is written
|
|
880
|
+
wrong did not refuse anything, and telling the model it was refused would be wrong in the other
|
|
881
|
+
direction. Neither code is on the message allowlist, so both withhold. `CostBudgetExceededError` now
|
|
882
|
+
descends from the same base and crosses as a refusal instead of being rethrown as a defect.
|
|
883
|
+
|
|
884
|
+
- dcb461f: A hook's `matcher: "*"` now fires on every tool, as the format defines it.
|
|
885
|
+
|
|
886
|
+
`new RegExp("*")` throws — "nothing to repeat" — and the catch in `matches()` reads a throw as
|
|
887
|
+
no-match. So the spelling an author is most likely to write for "always" was the one spelling that
|
|
888
|
+
meant "never", while the two synonyms worked.
|
|
889
|
+
|
|
890
|
+
Measured end to end before the fix, a vetoing `pre_tool_call` against tool `Bash`:
|
|
891
|
+
|
|
892
|
+
```
|
|
893
|
+
matcher "*" -> allowed through <- documented match-all, guard never ran
|
|
894
|
+
matcher "" -> VETOED
|
|
895
|
+
matcher "Bash" -> VETOED
|
|
896
|
+
matcher omitted -> VETOED
|
|
897
|
+
matcher "Edit, Write" -> allowed through <- documented exact-list form, matched nothing
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
The comma-separated list is the second half: as a regex it required the space to be part of a tool
|
|
901
|
+
name, so it matched nothing at all. It is now read as the list it is.
|
|
902
|
+
|
|
903
|
+
Both shapes are recognised **before** the regex engine sees them, because neither is valid regex and
|
|
904
|
+
one of them throws.
|
|
905
|
+
|
|
906
|
+
**Unchanged and deliberate:** a matcher that cannot compile still does not match. A broken matcher
|
|
907
|
+
must not take down the turn — that trade is the reason the `catch` exists, and this fix does not
|
|
908
|
+
touch it.
|
|
909
|
+
|
|
910
|
+
- 9725bf1: An agent served over HTTP or the terminal now applies its declared guardrails. It applied none of them.
|
|
911
|
+
|
|
912
|
+
`streamAgentUIMessages` called `createSdkAgentStream` directly on both of its branches. Guardrails
|
|
913
|
+
were applied in exactly two other places — `AgentRunner.stream()` and `withGuardrails`, the latter
|
|
914
|
+
reached only from `toAgentFactory` — and neither is on this path. Measured:
|
|
915
|
+
`grep -c guardrail agent-endpoint.ts` → 0, against 23 files in `packages/agents/src`.
|
|
916
|
+
|
|
917
|
+
Its reachable callers are the HTTP mount, the terminal runner and the streamer builder: every surface
|
|
918
|
+
a deployed agent is actually reached through. So a `defineAgent({ guardrails: [...] })` served to a
|
|
919
|
+
browser ran no input guard, applied no `redact`, and a `block` never threw.
|
|
920
|
+
|
|
921
|
+
This is the same shape as three defects already closed in this release, one layer up. B-014 and B-018
|
|
922
|
+
measured _channels_ inside a stream that was already being moderated; this is the surface where the
|
|
923
|
+
moderation never started.
|
|
924
|
+
|
|
925
|
+
**The composition is copied from `AgentRunner.stream()`, not reinvented** — two passes over the two
|
|
926
|
+
text-carrying kinds, visible inner and reasoning outer. NOT one wider extractor: two kinds under one
|
|
927
|
+
`extractText` collapse into a single event, so the model's private reasoning would be promoted into
|
|
928
|
+
the visible answer, and the moderation would create the disclosure it exists to close. That mutation
|
|
929
|
+
survived the first version of the test, which asserted over the flattened stream where every word is
|
|
930
|
+
still present; the assertions are per channel now.
|
|
931
|
+
|
|
932
|
+
Moderation runs on the wire chunks rather than upstream events, because the translator sits between
|
|
933
|
+
them — moderating upstream and letting the translator re-derive text would moderate one channel and
|
|
934
|
+
deliver another. `done.result` and `task_progress.text` remain uncovered here, exactly as in
|
|
935
|
+
`AgentRunner`: there is one `done` per round, so a pass keyed on it would collapse every round's into
|
|
936
|
+
one. They need a different mechanism and are tracked separately.
|
|
937
|
+
|
|
938
|
+
An agent that declared no guardrail takes an untouched pass-through — wrapping unconditionally would
|
|
939
|
+
buffer every served stream to enforce an empty list.
|
|
940
|
+
|
|
941
|
+
- ac29eff: A permission-gate veto now emits a debug line.
|
|
942
|
+
|
|
943
|
+
`grantGate` refused and emitted nothing — no log, no counter, no debug line. An operator could
|
|
944
|
+
observe the refusal only through the tool result the model received, and the two causes the veto
|
|
945
|
+
message distinguishes ("no standing grant matches" versus "the permission store could not be read,
|
|
946
|
+
so no grant applies") are indistinguishable from there.
|
|
947
|
+
|
|
948
|
+
That is pillar 3 of the wiring triad missing on a refusal seam. `bridge/approval-posture.ts`, the
|
|
949
|
+
sibling gate, already logged through this exact seam.
|
|
950
|
+
|
|
951
|
+
The QUERY is logged — tool, scope, and which of the two causes fired — and the grant is not: a query
|
|
952
|
+
names what an operator needs to diagnose, while the store's contents are the thing being protected.
|
|
953
|
+
A classifier that threw logs as its own event rather than as an ordinary veto, so a consumer-code
|
|
954
|
+
defect is not read as a denied tool.
|
|
955
|
+
|
|
956
|
+
- 1202e86: An inline `` !`command` `` is now recognised only at a boundary: the start of a line, or after
|
|
957
|
+
whitespace. When `!` follows another character the placeholder stays literal and the command does
|
|
958
|
+
not run, which is what the contract specifies.
|
|
959
|
+
|
|
960
|
+
`REFERENCE_REGEX` applied its leading-boundary guard `(?<!\S)` to the `@file` branch only, so the
|
|
961
|
+
shell branch matched anywhere. Measured before the fix: `` inline !`echo hi` and KEY=!`echo boom` ``
|
|
962
|
+
produced `inline <ran:echo hi> and KEY=<ran:echo boom>` — both ran.
|
|
963
|
+
|
|
964
|
+
This was the one place this module did **more** than its contract allows. Every other divergence
|
|
965
|
+
found in the same survey is something that fails to happen; this was something that happened, from a
|
|
966
|
+
markdown file loaded out of a working directory.
|
|
967
|
+
|
|
968
|
+
The test keeps three positive cases beside the negative one — a change that stopped recognising
|
|
969
|
+
inline commands altogether would satisfy the negative and destroy the feature.
|
|
970
|
+
|
|
971
|
+
- 1202e86: `${VAR}` in a `.mcp.json` `env` or `headers` block now resolves against the host environment.
|
|
972
|
+
|
|
973
|
+
`.mcp.json` is committed to the repository, so a named reference is the specification's only way to
|
|
974
|
+
keep a credential out of it. Nothing expanded the placeholder, and the shape check accepts it as a
|
|
975
|
+
perfectly valid string — so the entry validated, the server started, and it authenticated with the
|
|
976
|
+
literal text `${API_KEY}`. The failure surfaced as a remote auth error with no path back to the
|
|
977
|
+
config line.
|
|
978
|
+
|
|
979
|
+
An unset reference is **reported and left as written**. Substituting empty would start the server
|
|
980
|
+
with a blank credential and fail somewhere further away; dropping the key would look like the author
|
|
981
|
+
never wrote it.
|
|
982
|
+
|
|
983
|
+
The environment is injected (`loadMcpJson(cwd, { env })`, defaulting to `process.env`), matching how
|
|
984
|
+
the rest of the package reads env — so a test proves the expansion without mutating the process it
|
|
985
|
+
runs in.
|
|
986
|
+
|
|
987
|
+
**This does not loosen the posture this loader already takes.** `buildEntry` refuses `envPolicy`
|
|
988
|
+
deliberately: "A file committed to the repository is no place to loosen a process-level defence."
|
|
989
|
+
That refusal is about a committed file handing a server the _whole_ environment. A named reference
|
|
990
|
+
resolves _one_ variable the host already chose to set — and refusing to expand it protects nothing,
|
|
991
|
+
since it pushes the author to paste the literal secret into the file instead.
|
|
992
|
+
|
|
993
|
+
- 1202e86: The two permission vocabularies meet, and the mirrored `permissionMode` field says what it mirrors.
|
|
994
|
+
|
|
995
|
+
Measured: `permissionMode` appeared in exactly one file, as a mirrored field nothing branches on;
|
|
996
|
+
`dontAsk`, `autoMode`, `useAutoModeDuringPlan` and `classifyAllShell` all returned 0 files. This
|
|
997
|
+
layer offers `suggest | auto-edit | full-auto` — the values a real consumer put in front of users —
|
|
998
|
+
while the runtime resolves `default | plan | acceptEdits | bypass`. A reader of either had no way to
|
|
999
|
+
the other.
|
|
1000
|
+
|
|
1001
|
+
`approvalModeToPermissionMode` translates them. `suggest` maps to `default`, **not** to something
|
|
1002
|
+
that asks unconditionally: `default` means the rules decide and an unmatched call asks, so mapping it
|
|
1003
|
+
otherwise would discard every allow rule the operator shipped. `full-auto` maps to `bypass`, which
|
|
1004
|
+
allows everything **except an explicit deny** — mapping the most permissive local mode onto something
|
|
1005
|
+
that also cleared denies would turn a UI convenience into a policy override.
|
|
1006
|
+
|
|
1007
|
+
The signature takes `ApprovalMode`, so a fourth local mode is a compile error rather than a silent
|
|
1008
|
+
fallthrough to a posture nobody chose.
|
|
1009
|
+
|
|
1010
|
+
**`plan` has no local counterpart, deliberately.** It is an explore-only posture an _operator_
|
|
1011
|
+
imposes, not something this surface offers, and inventing a fourth local name would put a decision
|
|
1012
|
+
that belongs to the operator into the user's mode picker. The absence is named rather than filled.
|
|
1013
|
+
|
|
1014
|
+
`PermissionGate`'s mirrored field is now the SDK's own `PermissionMode` instead of a bare `string`. A
|
|
1015
|
+
mirror that accepts any word mirrors nothing in particular — `"readonly"`, what somebody writes when
|
|
1016
|
+
they mean `plan`, would have sat there looking applied. Restating the union by hand was the first
|
|
1017
|
+
attempt and the package's own type test refused it: the shape must fit `PreToolCallContext`
|
|
1018
|
+
structurally, and a copy that drifts by one member stops fitting. Importing the source makes it a
|
|
1019
|
+
mirror by construction.
|
|
1020
|
+
|
|
1021
|
+
Unchanged, and still the documented decision: the gate ignores the mode. `bypass` does not disable
|
|
1022
|
+
it, because a standing grant is the operator's and not the run's.
|
|
1023
|
+
|
|
1024
|
+
- 9725bf1: A text event whose `content` is not a string is refused instead of delivered unexamined.
|
|
1025
|
+
|
|
1026
|
+
Both extractors tested `typeof e.content === 'string'` and returned `undefined` otherwise.
|
|
1027
|
+
`moderateOutputStream` reads `undefined` as "this event carries no text", so the payload was never
|
|
1028
|
+
accumulated, never shown to a guard, and yielded **verbatim**.
|
|
1029
|
+
|
|
1030
|
+
The failure direction is DELIVER, not block: a guard declared to stop that payload silently never saw
|
|
1031
|
+
it, and the run reported green. Reachable in practice — `StreamEvent` is
|
|
1032
|
+
`{ type: string; [key: string]: unknown }`, `event-translator.ts` casts an unvalidated provider
|
|
1033
|
+
content block to `{ text?: string }`, and a consumer-supplied `streamFactory` is a public option.
|
|
1034
|
+
|
|
1035
|
+
**Two different questions had been collapsed into one answer.** "Not this event kind" and "this kind,
|
|
1036
|
+
content unreadable" both returned `undefined`, and they need opposite handling. The new
|
|
1037
|
+
`textPayloadExtractor` keeps `undefined` for the first and throws `UnreadableTextPayloadError` for
|
|
1038
|
+
the second.
|
|
1039
|
+
|
|
1040
|
+
**Refused, not coerced.** Coercing would moderate `"[object Object]"` — a guard consulted about a
|
|
1041
|
+
string the model never produced, returning a verdict about nothing, while the real payload rides
|
|
1042
|
+
along underneath. That is the redaction-computed-and-discarded shape with an extra step.
|
|
1043
|
+
|
|
1044
|
+
One implementation, used by both seams. The defect existed as two hand-written copies of the same
|
|
1045
|
+
predicate in `AgentRunner.stream()` and the served endpoint; fixing one and leaving the other is the
|
|
1046
|
+
half-fix this release keeps finding.
|
|
1047
|
+
|
|
1048
|
+
Only runs that declared an output guard can meet the error: `moderateOutputStream` is a transparent
|
|
1049
|
+
pass-through when no guard defines `checkOutput`, so the extractor is never consulted otherwise.
|
|
1050
|
+
|
|
1051
|
+
- 2bc27d3: `inheritHooks` no longer lets a member's `transform_tool_result` or `pre_user_send` handler replace
|
|
1052
|
+
its parent's — both now chain parent-first, matching the six events that already composed.
|
|
1053
|
+
|
|
1054
|
+
`inheritHooks` documents its security property as "the parent's refusal is evaluated first, and a
|
|
1055
|
+
member can only ever ADD a reason to refuse". For these two events the plain object spread did the
|
|
1056
|
+
opposite: a member declaring either handler silently discarded the parent's. The reachable surface is the EXPORTED `inheritHooks`, called with two handler maps.
|
|
1057
|
+
`delegate()` passes `undefined` for the member (`agent-orchestrator.ts:175`), so that path composed
|
|
1058
|
+
nothing and was never affected — a distinction the first version of this note got wrong.
|
|
1059
|
+
|
|
1060
|
+
`pre_user_send` composes additively — both contributions reach the model, parent first — because
|
|
1061
|
+
`PreUserSendResult` carries only `recalledContext` and the seam exposes no prompt mutation.
|
|
1062
|
+
|
|
1063
|
+
- dcb461f: Thirteen types named in exported signatures now cross a barrel, and the guard that finds them derives the requirement instead of listing it.
|
|
1064
|
+
|
|
1065
|
+
`bridge/index.ts` enumerates this shape four times by issue number — #663, #668, #675, #686 — and
|
|
1066
|
+
B-004 was the fifth. Each was found by _installing_ the published package, because the source is
|
|
1067
|
+
correct every time: the type is exported from its own module and only the barrel omits it.
|
|
1068
|
+
|
|
1069
|
+
The guard that followed reads the emitted barrel, which was the right move, and it is a **hand-written
|
|
1070
|
+
list** — so it catches the entries somebody remembered to add. B-004 was added to it _after_ a review
|
|
1071
|
+
found the miss, which is precisely what the list existed to prevent.
|
|
1072
|
+
|
|
1073
|
+
**This one derives it.** It parses every built `.d.ts`, collects the names each chunk imports and uses
|
|
1074
|
+
in an exported signature, and flags any that no public barrel re-exports. Measured on the built
|
|
1075
|
+
output: thirteen, including `BudgetTracker`, `InlineSkill`, `PluginsSettings`, `SkillsOptions`,
|
|
1076
|
+
`ContextWindowOptions`, `Plugin`, `ProviderRoutingSettings`, `RetryOptions`,
|
|
1077
|
+
`DiscoverSubagentsOptions`, and four reached through subpath entries.
|
|
1078
|
+
|
|
1079
|
+
Two things the first version of the guard got wrong are worth recording, because both produced a
|
|
1080
|
+
green over nothing:
|
|
1081
|
+
|
|
1082
|
+
- **A rollup does not put `export` on its declarations.** It emits them bare and lists every public
|
|
1083
|
+
name in one `export { … }` clause at the end. A single forward pass keyed on the `export` modifier
|
|
1084
|
+
visited zero exported declarations and reported no problem — while `index.d.ts` imported
|
|
1085
|
+
`PluginsSettings` on line 1, used it in a signature on line 359, and did not carry it in the clause
|
|
1086
|
+
on line 1445. It takes two passes.
|
|
1087
|
+
- **Reachable means every barrel a consumer can import from**, not just the root. A type exported from
|
|
1088
|
+
`auth.d.ts` is nameable through `@theokit/agents/auth`; the hashed internal chunks are not
|
|
1089
|
+
importable at all, so what they export is not reachable and what they import is still a finding.
|
|
1090
|
+
|
|
1091
|
+
The guard caught one of the changes made in this same release — `PermissionMode`, added to
|
|
1092
|
+
`PermissionGate` hours earlier — which is the difference between a list and a derivation.
|
|
1093
|
+
|
|
1094
|
+
- eaac7b0: The "will NOT fire" warning for a declared-but-unwired hook event now names where the capability
|
|
1095
|
+
already lives, instead of ending "the handler does not exist yet".
|
|
1096
|
+
|
|
1097
|
+
Two of the three unwired events are served today by purpose-built seams — `Guardrail.checkOutput`
|
|
1098
|
+
for `transform_llm_output`, and `createToolHooksPlugin({ processInput })` for `pre_user_send` — so
|
|
1099
|
+
the old message told consumers to wait for work that will not come. The third, `on_session_end`, is
|
|
1100
|
+
named as genuinely uncovered, with the reason: its handler returns `void` and cannot refuse an
|
|
1101
|
+
ending, so wiring it would produce a hook that runs and cannot decide.
|
|
1102
|
+
|
|
1103
|
+
Nothing is wired. `HOOK_EVENTS`, `WIRED_EVENTS` and `OBSERVATIONAL_EVENTS` keep the same members.
|
|
1104
|
+
|
|
1105
|
+
- 1202e86: `$ARGUMENTS` works, and the substitutions this module does and does not perform are written down.
|
|
1106
|
+
|
|
1107
|
+
The template understood `$1`, `$2`, … and nothing else. A command that wanted everything the user
|
|
1108
|
+
typed had to guess how many positions to concatenate, and stopped being correct at the first
|
|
1109
|
+
invocation that passed one more. Measured: `$ARGUMENTS` returned 0 hits against a control of 31.
|
|
1110
|
+
|
|
1111
|
+
It expands to the **raw** string, not the split tokens rejoined. Splitting strips the quotes that
|
|
1112
|
+
decided the grouping — `"two words" solo` is two arguments and five words — so rejoining would hand
|
|
1113
|
+
the model `two words solo` and lose the only mark saying which three belonged together. A placeholder
|
|
1114
|
+
whose whole job is "what the user typed" must not quietly retype it. The backslash escape works the
|
|
1115
|
+
same way `$N`'s does.
|
|
1116
|
+
|
|
1117
|
+
**`templateHints` no longer asks for escaped placeholders.** `\$1` is prose about a placeholder, not
|
|
1118
|
+
a request for one, and listing it asked the user to supply an argument the template would never
|
|
1119
|
+
substitute. It also broke the sort outright: the match carries the backslash, so `slice(1)` produced
|
|
1120
|
+
`"$1"`, `Number` produced `NaN`, and a comparator returning `NaN` leaves the order unspecified.
|
|
1121
|
+
`$ARGUMENTS` sorts first — reading it after `$3` suggests it is a fourth position.
|
|
1122
|
+
|
|
1123
|
+
A table above the regex now states the supported set where an author will meet it, including two
|
|
1124
|
+
deliberate absences: `${CLAUDE_SKILL_DIR}` belongs to a skill body rather than a command template
|
|
1125
|
+
(and is substituted by `@theokit/sdk`'s `skill_read`, the only place that knows which directory the
|
|
1126
|
+
skill came from), and `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}` are **out of scope rather
|
|
1127
|
+
than pending** — the plugin format is not implemented on either side, so substituting a root for a
|
|
1128
|
+
plugin that cannot be loaded would have to invent the layout it points into.
|
|
1129
|
+
|
|
1130
|
+
- ec899f4: An observational hook handler is now assigned to its own key rather than chosen by comparison.
|
|
1131
|
+
|
|
1132
|
+
The dispatch loop used a two-branch conditional over a list of two event names, so a third
|
|
1133
|
+
observational event would have landed on `post_assistant_reply` — silently, with no test objecting.
|
|
1134
|
+
No behaviour changes for the events wired today; the fix removes the trap for the next one added.
|
|
1135
|
+
|
|
1136
|
+
- dcb461f: `@Checkpoint` says, at the name, that it is run-state checkpointing and not file undo.
|
|
1137
|
+
|
|
1138
|
+
Two different things share the word and only one of them exists here. What this package has is
|
|
1139
|
+
run-state checkpointing — enough to resume a conversation. What it does not have is file
|
|
1140
|
+
checkpointing: snapshot and restore of the files an agent edited, the thing an interactive coding
|
|
1141
|
+
agent needs to offer an undo.
|
|
1142
|
+
|
|
1143
|
+
Measured: `rewindFiles`, `rewind_files`, `restoreFile` and `backup` returned 0 files here, 0 in the
|
|
1144
|
+
SDK `.d.ts` and 0 in `@theokit/sdk-tools`, while `checkpoint` returned six — all of them run state.
|
|
1145
|
+
|
|
1146
|
+
**The harm is not the missing feature, it is the collision.** Any parity checklist that greps for
|
|
1147
|
+
`checkpoint` is satisfied by the wrong one and reports a capability that does not exist. A consumer
|
|
1148
|
+
reads the checklist, believes undo is available, and finds out when a user asks for it.
|
|
1149
|
+
|
|
1150
|
+
The absence is stated **at the colliding name**, which is the only place a grep will reach, and a
|
|
1151
|
+
test pins both the statement and the reason it matters — a docblock nothing checks is a docblock that
|
|
1152
|
+
gets tidied away. It fails if a file-restore surface ever appears under the run-state name without
|
|
1153
|
+
the statement being updated with it.
|
|
1154
|
+
|
|
1155
|
+
Not implemented here, and the reason is stated rather than implied: there is no pre-write seam on the
|
|
1156
|
+
edit tools to hang snapshot and restore on, which makes it a feature with its own design questions
|
|
1157
|
+
rather than a wiring job. Half-building it under a name that already means something else would make
|
|
1158
|
+
the collision worse.
|
|
1159
|
+
|
|
1160
|
+
- e4f2e78: Documents that `sandbox` here is not the Claude Code CLI's `sandbox.*` settings block, at the door a
|
|
1161
|
+
reader of the local vocabulary meets (`@theokit/agents/sandbox`) and in a new measured surface note,
|
|
1162
|
+
`docs/surfaces/sandbox-vocabulary.md`.
|
|
1163
|
+
|
|
1164
|
+
They share an enforcement mechanism — bubblewrap and seccomp on Linux — and differ in what they
|
|
1165
|
+
expose above it. This package offers an execution BACKEND (`SandboxBackend`, `LocalSandbox` /
|
|
1166
|
+
`LinuxSandbox`) whose policy is three modes and four `SandboxConfig` fields. The CLI's is a SETTINGS
|
|
1167
|
+
POLICY of 38 keys spanning per-path filesystem rules, a network allowlist with proxies and TLS
|
|
1168
|
+
termination, and per-variable credential masking.
|
|
1169
|
+
|
|
1170
|
+
The gap that matters is network: this package has no network policy at any level, so a checklist
|
|
1171
|
+
that greps for `sandbox`, finds this module and stops reports a domain allowlist that does not
|
|
1172
|
+
exist. Every one of the 38 keys is enumerated with a verdict — absent, coarser, or not applicable to
|
|
1173
|
+
a library — rather than dismissed in aggregate.
|
|
1174
|
+
|
|
1175
|
+
Two corrections fell out of doing it. The count is 38, not the 39 previously recorded: two
|
|
1176
|
+
independent fetches of the reference returned the same 38 keys while the summarising step attached a
|
|
1177
|
+
different total to each. And `sandbox.failIfUnavailable` has no equivalent here — `createSandboxBackend`
|
|
1178
|
+
degrades to an unconfined `LocalSandbox` after one warning when bubblewrap is missing, and an
|
|
1179
|
+
operator who believes they are sandboxed cannot make that a hard failure.
|
|
1180
|
+
|
|
1181
|
+
- 1202e86: A `.mcp.json` written against the current MCP spec is accepted.
|
|
1182
|
+
|
|
1183
|
+
The specification renamed the HTTP transport to "Streamable HTTP". `validateRemote` accepted
|
|
1184
|
+
`"http"` and `"sse"` and refused anything else, so a server declared with the spec own current
|
|
1185
|
+
name was dropped — with a message about a field the author had written correctly.
|
|
1186
|
+
|
|
1187
|
+
It is an ALIAS, normalised at the boundary, not a third transport. They are one transport under two
|
|
1188
|
+
names, and forwarding the synonym downstream would ask every consumer of the parsed config to learn
|
|
1189
|
+
it too; the SDK own `McpServerConfig` does not carry it. The parser accepts what the author wrote
|
|
1190
|
+
and hands on what the runtime speaks.
|
|
1191
|
+
|
|
1192
|
+
An invented transport is still refused. Accepting the alias must not turn the check into a
|
|
1193
|
+
pass-through, and a silent acceptance would be worse than the refusal this started as.
|
|
1194
|
+
|
|
1195
|
+
- dcb461f: Sub-agents declared through the authoring chain now spawn, and the per-run door is nameable.
|
|
1196
|
+
|
|
1197
|
+
Three halves that did not connect: `SubAgentsCapability` wrote `draft.agents`,
|
|
1198
|
+
`CompiledAgentOptions.agents` held it, and `assembleM8CreateOptions` had no `agents` field to
|
|
1199
|
+
project it into. Declaring a sub-agent compiled cleanly and spawned nothing.
|
|
1200
|
+
|
|
1201
|
+
`agent-compiler.ts` recorded the gap as ADR D3 — "a resolver here is carried, not invoked" — and a
|
|
1202
|
+
test pinned the boundary with an instruction attached: if someone wires the projection, go red and
|
|
1203
|
+
record that the deferral ended. **That deferral has ended.** What could not be defended was the shape
|
|
1204
|
+
a consumer meets: `SubAgentsCapability`, `SubagentDefinition`, `discoverSubagents`,
|
|
1205
|
+
`loadSubagentDefinition` and `listSubagentNames` all cross the public barrel, so the authoring chain
|
|
1206
|
+
reads as complete. This package already names that failure four times by issue number (#663, #668,
|
|
1207
|
+
#675, #686): the type crosses, the capability does not.
|
|
1208
|
+
|
|
1209
|
+
**Why projecting rather than un-exporting.** The shapes already agreed everywhere except in the one
|
|
1210
|
+
type nothing consumed. `RuntimeOverrides.agents` — the per-run door that always worked — is
|
|
1211
|
+
`Record<string, AgentDefinition>`, the SDK's own shape, and that same shape already crossed the
|
|
1212
|
+
barrel as `SubagentDefinition`. The odd one out was `CompiledSubAgent` (`{ model?, systemPrompt? }`),
|
|
1213
|
+
referenced in exactly two places, both of them its own declaration and the field that held it. It
|
|
1214
|
+
could not have been projected as it stood: `AgentDefinition` requires `description` and `prompt`, and
|
|
1215
|
+
a sub-agent with no description is one the parent model has no basis to delegate to.
|
|
1216
|
+
`CompiledSubAgent` is now an alias of the SDK type, so `model` is `ModelSelection | "inherit"` rather
|
|
1217
|
+
than a bare string.
|
|
1218
|
+
|
|
1219
|
+
**`RuntimeOverrides` is exported, type-only.** It was declared by the adapter and exported by zero
|
|
1220
|
+
barrels, so a consumer could pass the value and could not name the type — no helper, no wrapper, no
|
|
1221
|
+
typed variable to hold one.
|
|
1222
|
+
|
|
1223
|
+
Per-run overrides still win over the compiled set: `sdk-adapter.ts` spreads `...m8, ...extra`, and a
|
|
1224
|
+
per-run value that lost to a compile-time one would be the opposite of what "override" promises. The
|
|
1225
|
+
key is written only when something was declared — an unconditional `agents: {}` would hand
|
|
1226
|
+
`Agent.create` a claim ("this agent has children") that no author made.
|
|
1227
|
+
|
|
1228
|
+
- dcb461f: `@ContextWindow` now documents that declaring it is also the on-switch for instruction discovery.
|
|
1229
|
+
|
|
1230
|
+
The SDK constructs its `FileContextManager` only under `if (options.context !== undefined)`, and
|
|
1231
|
+
`ContextWindowCapability` is the only place this layer sets that field. The consequence was
|
|
1232
|
+
undocumented and load-bearing:
|
|
1233
|
+
|
|
1234
|
+
- declare `@ContextWindow(...)` → `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursor/rules` and
|
|
1235
|
+
`.theokit/rules` are discovered;
|
|
1236
|
+
- omit it → every one of those files is inert, with no warning.
|
|
1237
|
+
|
|
1238
|
+
The option's entire surface is one key whose doc comment reads "Maximum tokens before compaction
|
|
1239
|
+
triggers", and the module docblock above it is about compaction and strategy knobs. A consumer
|
|
1240
|
+
debugging "why is my CLAUDE.md ignored" had no path from that symptom back to a decorator named after
|
|
1241
|
+
a token budget.
|
|
1242
|
+
|
|
1243
|
+
**No behaviour changes.** The coupling is stated where the author decides whether to declare the
|
|
1244
|
+
decorator, and pinned by a test that goes red if discovery ever gains a switch of its own — at which
|
|
1245
|
+
point the documentation should be deleted rather than left quietly false.
|
|
1246
|
+
|
|
1247
|
+
`ContextSettings.maxBytesPerFile` and `maxBytesTotal` are now reachable from this surface too.
|
|
1248
|
+
They were not, and the gap bit hardest exactly here: the option that enables `CLAUDE.md` discovery
|
|
1249
|
+
is the same option that decides at which size a `CLAUDE.md` is truncated (40 000 characters by
|
|
1250
|
+
default, head/tail with a marker) or dropped (120 000 aggregate, lower-priority sources first). A
|
|
1251
|
+
60 000-character instruction file was silently cut, and the only knob on offer was named after
|
|
1252
|
+
tokens. Keys are written only when declared, so an agent that never asks about bytes keeps the SDK's
|
|
1253
|
+
own defaults.
|
|
1254
|
+
|
|
1255
|
+
- a2b0a59: The hook gate's vocabulary crosses with the capability that takes it
|
|
1256
|
+
|
|
1257
|
+
`13.0.0-next.6` exported `HookApprovalCapability` and withheld `HookApprovalGate`,
|
|
1258
|
+
`HookApprovalRequest` and `HookGateUnsupportedError`. A consumer could build the gate, and could
|
|
1259
|
+
neither type the object it takes nor catch its refusal by class — which matters more here than
|
|
1260
|
+
usual, because refusing loudly is the whole design.
|
|
1261
|
+
|
|
1262
|
+
Fourth instance of the same shape (#663 `AgentModule`, #668 `transcriptOf`, #675 `RegistryOutcome`),
|
|
1263
|
+
and the first three were each found by installing the published package. The source is correct every
|
|
1264
|
+
time: the type IS exported from its own module, and only the barrel omits it.
|
|
1265
|
+
|
|
1266
|
+
A guard now reads the EMITTED `dist/index.d.ts` export list rather than the source, and it was
|
|
1267
|
+
proved able to fail by removing one export and watching it name that one.
|
|
1268
|
+
|
|
1269
|
+
- 9915c19: `RegistryOutcome` is exported alongside the field that uses it
|
|
1270
|
+
|
|
1271
|
+
`registryOutcome` shipped in `13.0.0-next.4` and its type did not, so a consumer could read the
|
|
1272
|
+
value and not name it — `function handle(o: RegistryOutcome)` did not compile. A union whose members
|
|
1273
|
+
cannot be named is read as `string`, which loses every distinction it exists to make.
|
|
1274
|
+
|
|
1275
|
+
Third instance of the same omission (`AgentModule` in #663, `transcriptOf` in #668), and the third
|
|
1276
|
+
found by installing the published package into an empty project rather than by reading the source.
|
|
1277
|
+
|
|
1278
|
+
- 5451233: Say, where a consumer can read it, why `CompatSurface` has five names and the SDK's has four
|
|
1279
|
+
|
|
1280
|
+
`CompatSurface` gained `'commands'` in #704 because this package reads
|
|
1281
|
+
`<projectDir>/.claude/commands/*.md` itself. The SDK's own union still has four. A caller who
|
|
1282
|
+
builds SDK `local` options directly therefore cannot pass a value of this type, and `TS2345` names
|
|
1283
|
+
the mismatch without naming the reason.
|
|
1284
|
+
|
|
1285
|
+
The explanation existed only in `//` comments, which do not survive into the emitted `.d.ts` — so
|
|
1286
|
+
it was invisible to exactly the audience that hits the error. It now lives in the JSDoc block that
|
|
1287
|
+
does ship, together with the guidance to derive the SDK's four from this five rather than writing a
|
|
1288
|
+
second list by hand: a hand-copied list goes stale the day a surface is added, which is the
|
|
1289
|
+
divergence #704 existed to remove.
|
|
1290
|
+
|
|
1291
|
+
Documentation only; no behaviour changes. Reported by a consumer who hit the compiler error while
|
|
1292
|
+
converging four call sites onto one declaration, and who supplied the sentence that was missing.
|
|
1293
|
+
|
|
1294
|
+
- Updated dependencies [cfe7f4c]
|
|
1295
|
+
- @theokit/presenter@0.9.0
|
|
1296
|
+
|
|
1297
|
+
## 13.0.0-next.13
|
|
1298
|
+
|
|
1299
|
+
### Minor Changes
|
|
1300
|
+
|
|
1301
|
+
- 6b07960: New `grantGate(store, classify)` in `@theokit/agents/auth` — the supported way to put a
|
|
1302
|
+
`PermissionStore` in force.
|
|
1303
|
+
|
|
1304
|
+
The store shipped with a careful grant key, a "deny by default, always" docblock and no reader.
|
|
1305
|
+
Measured: `isGranted` had zero callers outside its own unit test, and `PermissionStore` appeared in
|
|
1306
|
+
zero files across six sibling repositories. An operator reading `.theokit/tool-permissions.json` to
|
|
1307
|
+
learn what an agent may run was reading a control that was not in force — a grant and its revocation
|
|
1308
|
+
produced identical behaviour.
|
|
1309
|
+
|
|
1310
|
+
`grantGate` adapts the store to `pre_tool_call`, which is documented as the only hook with veto
|
|
1311
|
+
power and runs before the tool by construction, so a refusal is a refusal before the side effect. No
|
|
1312
|
+
new gate and no framework wiring: nothing is enforced unless a consumer attaches the handler, and an
|
|
1313
|
+
agent that does not is unaffected.
|
|
1314
|
+
|
|
1315
|
+
`classify` returns `{ governed: true, query }` **or** `{ governed: false }` — a tagged union whose
|
|
1316
|
+
BOTH arms carry the discriminant. A bare `undefined` would say "this tool needs no permission" and
|
|
1317
|
+
"I forgot this tool" in the same word, and on a security gate the second must not silently pass.
|
|
1318
|
+
|
|
1319
|
+
The discriminant is on both arms because the first shape — discriminated by whether a `governed` KEY
|
|
1320
|
+
was present — **failed open**: a consumer writing a policy record `{ governed: true, scope }` and
|
|
1321
|
+
spreading it into the query got the tool waved through, and so did `governed: undefined`. TypeScript
|
|
1322
|
+
does not stop that; excess properties pass freely through a variable or a spread. Fail-closed is now
|
|
1323
|
+
measured across `true` / `undefined` / `false` / `null`, and only `false` passes.
|
|
1324
|
+
|
|
1325
|
+
Named `grantGate`, not `permissionGate`, because `@theokit/sdk` already exports `PermissionGate`,
|
|
1326
|
+
`PermissionGateContext`, `PermissionGateDecision`, `PermissionEngine` and `PermissionPlugin`. The
|
|
1327
|
+
rename also says something true: this gates on a standing grant the operator made, and the SDK's
|
|
1328
|
+
permission engine is a separate system — neither satisfies the other.
|
|
1329
|
+
|
|
1330
|
+
A classifier that throws DENIES, naming the throw, rather than ending the turn. A corrupt store
|
|
1331
|
+
denies with a message that says so — "the permission store could not be read, so no grant applies" —
|
|
1332
|
+
distinct from "no standing grant matches", so an operator can tell the two apart.
|
|
1333
|
+
|
|
1334
|
+
The read error's own text is deliberately NOT in that message: the veto travels to the model, and
|
|
1335
|
+
`lastReadError.message` carries the absolute store path and its file mode. It stays on
|
|
1336
|
+
`store.lastReadError` for the operator, which is where the actionable remedy (`chmod 600 …`) lives.
|
|
1337
|
+
The scope IS still interpolated, so this narrows the exposure rather than eliminating it.
|
|
1338
|
+
|
|
1339
|
+
**Composing with a `pre_tool_call` you already have**: the field is singular, so assigning the gate
|
|
1340
|
+
over an existing handler loses one of the two silently. Compose explicitly —
|
|
1341
|
+
`async (ctx) => (await gate(ctx)) ?? (await mine(ctx))`; first veto wins.
|
|
1342
|
+
|
|
1343
|
+
`PermissionStore`'s docblock now states that the class enforces nothing on its own, and records the
|
|
1344
|
+
precedence among the surfaces IN THIS PACKAGE that can refuse a tool — saying plainly that the list
|
|
1345
|
+
is not exhaustive, because `@theokit/sdk` has its own permission system that neither knows about
|
|
1346
|
+
these nor is known by them.
|
|
1347
|
+
|
|
1348
|
+
- 5211721: `moderateOutputStream` now delivers the redacted text to the client instead of computing it and
|
|
1349
|
+
replaying the original events.
|
|
1350
|
+
|
|
1351
|
+
It called `runOutputGuards` and discarded the return value, so a guard that redacted correctly had
|
|
1352
|
+
its work thrown away: measured against the built artifact, a guard returning `[REDACTED]` delivered
|
|
1353
|
+
`sk-abc123`. Only `block` reached the client honestly.
|
|
1354
|
+
|
|
1355
|
+
**Signature change**: `moderateOutputStream` takes a fourth argument,
|
|
1356
|
+
`rebuildText: (text: string, replaced: E) => E`, which builds one event carrying the
|
|
1357
|
+
moderated text, given the text-carrying event it replaces. It is required rather than optional —
|
|
1358
|
+
optional would let the function compute a redaction it cannot apply, which is the defect being
|
|
1359
|
+
removed. Only the caller knows how to construct its own events.
|
|
1360
|
+
|
|
1361
|
+
**`extractText` MUST match exactly one event kind.** When it matches several, they COLLAPSE INTO
|
|
1362
|
+
ONE — measured: `[thinking('CoT: the key is sk-abc'), message(' Here you go.')]` yields a single
|
|
1363
|
+
`message` reading `"CoT: the key is [R] Here you go."`, with no `thinking` event surviving. A
|
|
1364
|
+
consumer who wants reasoning moderated runs a SECOND pass over that kind rather than widening one
|
|
1365
|
+
extractor.
|
|
1366
|
+
|
|
1367
|
+
`replaced` does not prevent that collapse, and an earlier draft of this entry said it did. What it
|
|
1368
|
+
buys is narrower: the surviving event keeps the KIND and metadata of the text-carrying event it
|
|
1369
|
+
replaces, instead of being rebuilt from the text alone. `replaced` is ALWAYS the event being replaced.
|
|
1370
|
+
It was typed `E | undefined` for a case that cannot happen — a stream where no event carried text
|
|
1371
|
+
returns from the absence check before the guards run, so `rebuildText` is not reached at all. It is
|
|
1372
|
+
also never a non-text event, because a caller spreading one would emit a duplicate of it.
|
|
1373
|
+
|
|
1374
|
+
**Second signature change**: a fifth argument, `rebuildResult: (text, result) => R`, applies the
|
|
1375
|
+
moderated text to the generator's RETURN value. A stream has two channels and the first release of
|
|
1376
|
+
this fix moderated one: the events were redacted while `step.value` — the aggregate `run()` returns
|
|
1377
|
+
— still carried the original text. Measured: the guard computed `"the key is [R]"` and
|
|
1378
|
+
`run().response` was `"the key is sk-abc123"`, so **`run()`, the primary non-streaming API, kept
|
|
1379
|
+
delivering the secret**. That was this fix's own defect one channel over. Required for the same
|
|
1380
|
+
reason `rebuildText` is; passed rather than re-derived, because re-running the guards on the
|
|
1381
|
+
aggregate would apply a non-idempotent guard twice.
|
|
1382
|
+
|
|
1383
|
+
When the text is unchanged, the buffered events are replayed verbatim as before. When it changed,
|
|
1384
|
+
the **last** text-carrying event is REPLACED by a newly built event carrying the whole moderated
|
|
1385
|
+
string, and the earlier text events are dropped. Events carrying no text are never dropped. Note
|
|
1386
|
+
"replaced", not "modified": any non-text payload the surviving event carried is lost, as is that of
|
|
1387
|
+
the dropped ones — a consumer whose text events carry per-event metadata should moderate one kind
|
|
1388
|
+
only, or rebuild from `replaced`. An event whose extracted text is the EMPTY STRING is still
|
|
1389
|
+
text-carrying and can be the one replaced.
|
|
1390
|
+
|
|
1391
|
+
**Known consequence:** when text events straddle a non-text event, their relative order does not
|
|
1392
|
+
survive a redaction. Given `text('tok ') , tool_call , text('sk-abc')` the client now receives
|
|
1393
|
+
`tool_call , text('tok [R]')` — text that preceded the tool call follows it. Landing on the last
|
|
1394
|
+
text-carrying event keeps a trailing terminator in place and keeps any completion claim after the
|
|
1395
|
+
work that produced it; what it cannot keep is the interleaving, because the redaction is about the
|
|
1396
|
+
whole string and the boundaries are gone by the time it exists. A test pins this so it is found
|
|
1397
|
+
here rather than in a transcript that stopped making sense.
|
|
1398
|
+
|
|
1399
|
+
A guard that rewrites **unconditionally** — a disclaimer appender, a trim, an NFC normaliser —
|
|
1400
|
+
takes this path on every stream that DOES carry text. The cost is not proportional to how much the
|
|
1401
|
+
guard changed. It does NOT add a text event to a round that produced none — this entry claimed so,
|
|
1402
|
+
and the absence check refuses it: a tool-only round yields its tool call and nothing else.
|
|
1403
|
+
|
|
1404
|
+
- 98b2565: `resolveSettingSources` now returns `readonly GatedSettingSource[]`, and
|
|
1405
|
+
`CompiledAgentOptions.settingSources` takes that type — so a setting root no `TrustPosture`
|
|
1406
|
+
authorised no longer fits the field.
|
|
1407
|
+
|
|
1408
|
+
`define-agent.ts` claimed that field "can only ever hold roots that some posture authorized".
|
|
1409
|
+
Measured against the emitted `.d.ts`: `setOnce(draft, 'settingSources', ['mdm','team','user','plugins'], 'cap')`
|
|
1410
|
+
typechecked **cast-free**. Writing a `Capability` is the documented way to extend the builder, and a
|
|
1411
|
+
capability writes the draft directly — so the gate was reachable around, for `project`, the root it
|
|
1412
|
+
exists to protect.
|
|
1413
|
+
|
|
1414
|
+
A brand rather than a runtime check, because the obvious runtime check does not work: reading
|
|
1415
|
+
`draft.provenance` to refuse a capability's write would also refuse the LEGITIMATE builder path,
|
|
1416
|
+
which writes through `setOnce` too. What differs is where the value came from, and that is what a
|
|
1417
|
+
brand carries.
|
|
1418
|
+
|
|
1419
|
+
**It refuses the accident, not the determined caller** — `as never` defeats it, like every brand.
|
|
1420
|
+
Saying so is the point: the comment it replaces claimed an invariant nothing enforced.
|
|
1421
|
+
|
|
1422
|
+
**New: `settingSources.plugins`**, taking the same `ProjectSettingsGrant` as `project`.
|
|
1423
|
+
`PluginsManager.refresh` loads executable bundles from the same cwd-controlled tree, usually
|
|
1424
|
+
arriving with the clone, so it gets the same gate and not a weaker one. This is the root the SDK
|
|
1425
|
+
genuinely reads and the facade withheld.
|
|
1426
|
+
|
|
1427
|
+
`team` and `mdm` stay absent, and that is the item's original premise dying under measurement: the
|
|
1428
|
+
SDK never reads them — `includesSetting` is called with exactly `"project"` and `"plugins"` — so
|
|
1429
|
+
forwarding them would be a capability in the type and nothing at runtime.
|
|
1430
|
+
|
|
1431
|
+
**Migration**: a consumer constructing `CompiledAgentOptions` by hand must build roots through
|
|
1432
|
+
`resolveSettingSources` instead of a string array. That is the supported construction and always was.
|
|
1433
|
+
|
|
1434
|
+
**Also: a narrowed `claudeCode.import` is now REFUSED on an SDK that cannot read it.**
|
|
1435
|
+
|
|
1436
|
+
That field's docblock said the narrowed form was "refused at resolve time" below `@theokit/sdk`
|
|
1437
|
+
5.4.0. Nothing read a version for it — the only checks in this layer are the hook gate (a different
|
|
1438
|
+
option) and a `compatSources` warning that returns silently for any major ≥ 5. So on
|
|
1439
|
+
5.0.0 ≤ SDK < 5.4.0, inside this package's declared `^4.52.1 || ^5.0.0`, a narrowed `import` was
|
|
1440
|
+
forwarded, dropped by the runtime in silence, and the foreign root was **not read at all** — a
|
|
1441
|
+
consumer asking for "the skills but not the hooks" got nothing, which is further from what they
|
|
1442
|
+
asked for than the un-narrowed form. `compatSources` landed in 5.0.0 and the narrowing in 5.4.0;
|
|
1443
|
+
treating the two versions as one was the defect.
|
|
1444
|
+
|
|
1445
|
+
`CompatImportUnsupportedError` now refuses, naming both versions and what would otherwise happen.
|
|
1446
|
+
It refuses rather than warns because a silent nothing is discovered by wondering why a skill is
|
|
1447
|
+
missing. An unreadable version is refused too: "cannot tell" and "is supported" must not collapse.
|
|
1448
|
+
|
|
1449
|
+
**And `commands` is subtracted before the compat sources reach the SDK.** The two vocabularies
|
|
1450
|
+
diverge by one name on purpose — `.claude/commands/*.md` is read by this package and never by the
|
|
1451
|
+
SDK — and `setting-sources-gate.ts` prescribed the subtraction as advice to consumers while the
|
|
1452
|
+
projection that needed it did not do it. Measured: `import: ['commands']` forwarded a list
|
|
1453
|
+
containing zero names the SDK defines, which is its own empty-list case — the exact ambiguity
|
|
1454
|
+
`resolveCompatSources` refuses one layer up. A source whose surfaces all belong to this layer
|
|
1455
|
+
is now dropped from the SDK's list rather than sent empty.
|
|
1456
|
+
|
|
1457
|
+
`CompatImportUnsupportedError` is exported from `@theokit/agents/bridge`, so a consumer can catch the
|
|
1458
|
+
refusal by class rather than by matching its message.
|
|
1459
|
+
|
|
1460
|
+
- 2bc5d84: `delegate()` now applies the guardrails its spec declares. It accepted them and never consulted them.
|
|
1461
|
+
|
|
1462
|
+
Measured against the built artifact with a guard declaring both halves: the input reached the model
|
|
1463
|
+
with its injection intact and the caller received `sk-abc123`. `bridge/agent-orchestrator.ts`
|
|
1464
|
+
contained **zero** occurrences of `guardrail` — control on the same sweep: `loop/agent-runner.ts`
|
|
1465
|
+
has 9 — and called `runReflectiveLoop` bare. The operator had declared guardrails and the run was
|
|
1466
|
+
green.
|
|
1467
|
+
|
|
1468
|
+
This is the same defect class as the streamed-redaction fix in this release, on a sibling public
|
|
1469
|
+
API, and it is model-reachable: `tools/delegate-tool.ts` wraps `delegate()`, so an agent can invoke
|
|
1470
|
+
a sub-agent whose declared guards do nothing.
|
|
1471
|
+
|
|
1472
|
+
`checkInput` runs after `onDelegationStart` and `checkOutput` after `onDelegationComplete` — each
|
|
1473
|
+
moderating what actually crosses the boundary rather than a string a hook may then rewrite. A
|
|
1474
|
+
blocking input guard throws before the model is called at all, so a refused delegation costs
|
|
1475
|
+
nothing.
|
|
1476
|
+
|
|
1477
|
+
`response` only. `toolCalls[].output` is tool output rather than model text, and `agent-runner.ts`
|
|
1478
|
+
excludes it from `extractText` on the same reasoning; the docblock says so, because leaving it alone
|
|
1479
|
+
should be a decision somebody reads rather than an omission somebody discovers.
|
|
1480
|
+
|
|
1481
|
+
A spec declaring no guardrails behaves byte-identically.
|
|
1482
|
+
|
|
1483
|
+
**A guardrail block now crosses the delegate tool as a refusal, not a crash.** `errorCodeOf` mapped
|
|
1484
|
+
only the three delegation errors, so `GuardrailViolationError` — reachable from `delegate()` for the
|
|
1485
|
+
first time because of this change — hit the "not a delegation outcome, a defect" arm and was
|
|
1486
|
+
rethrown, ending the parent's turn. The tool's own description, shipped to the model, promises
|
|
1487
|
+
`{ ok: false, error, message }` on a refusal.
|
|
1488
|
+
|
|
1489
|
+
It crosses as `guardrail_violation` with a FIXED message: `the delegated task was refused by a
|
|
1490
|
+
policy guard`. The message travels only for codes on an explicit ALLOWLIST — a budget or a timeout
|
|
1491
|
+
is a fact about the work, and the number in it is what the model acts on. A code nobody lists
|
|
1492
|
+
withholds, so forgetting is safe in the direction that matters. A guardrail message is not — it reads
|
|
1493
|
+
`Guardrail "pii-detector" blocked output: ssn found`, naming the guard and its exact trigger, and a
|
|
1494
|
+
model given that learns which words to avoid rather than that it should stop. The operator keeps the
|
|
1495
|
+
full typed error, which carries `guardName`, `phase` and `reason`.
|
|
1496
|
+
|
|
1497
|
+
- 019f828: Output guards now moderate `thinking` events, not only `text_delta`.
|
|
1498
|
+
|
|
1499
|
+
`AgentRunner` handed `moderateOutputStream` an extractor matching `text_delta` and nothing else,
|
|
1500
|
+
while `thinking` is a public `AgentStreamEvent` that reaches the client like any other. Measured: a
|
|
1501
|
+
guard declared over the agent's output delivered `thinking "the key is sk-abc123"` verbatim.
|
|
1502
|
+
|
|
1503
|
+
**This closes one channel and does not close all of them.** `DoneEvent.result` carries the model's
|
|
1504
|
+
whole answer and is still unmoderated — measured on the same turn, `text_delta` came out
|
|
1505
|
+
`"here: [R]"` while `done.result` came out `"here: sk-abc123"`. `task_progress.text` is a fourth and
|
|
1506
|
+
reaches the web wire. A third pass does not extend to them: there is one `done` per round, so a pass
|
|
1507
|
+
keyed on it would collapse every round's into one, and they need a different mechanism. Tracked
|
|
1508
|
+
separately; stated here because a security note that overstates its coverage is worse than one that
|
|
1509
|
+
does not exist.
|
|
1510
|
+
|
|
1511
|
+
The third channel of a shape fixed twice already in this release — a streamed redaction that was
|
|
1512
|
+
computed and discarded, and `delegate()` consulting no guards at all.
|
|
1513
|
+
|
|
1514
|
+
**Two passes, not one wider extractor.** Widening `extractText` to match both kinds is the obvious
|
|
1515
|
+
move and the wrong one: two kinds under one extractor COLLAPSE into a single event, so the reasoning
|
|
1516
|
+
would be promoted into a visible one — the moderation creating the disclosure it exists to close.
|
|
1517
|
+
Composing two passes is what `moderateOutputStream`'s own docblock prescribes, and each pass seeing
|
|
1518
|
+
one kind is what keeps them apart.
|
|
1519
|
+
|
|
1520
|
+
The visible pass owns the aggregate: `DelegationResult.response` accumulates from `text_delta`
|
|
1521
|
+
upstream, so the reasoning pass passes the result through rather than replacing it.
|
|
1522
|
+
|
|
1523
|
+
A blocking guard on either channel still throws before any event is emitted. An agent with no output
|
|
1524
|
+
guard is byte-identical.
|
|
1525
|
+
|
|
1526
|
+
- 0731584: `resolveCompatSources` now returns `readonly GatedCompatSource[]`, and
|
|
1527
|
+
`CompiledAgentOptions.compatSources` takes that type — so a compat source no `TrustPosture`
|
|
1528
|
+
authorised no longer fits the field.
|
|
1529
|
+
|
|
1530
|
+
**BREAKING for hand-built compiled options**, exactly as its sibling was. Build compat sources
|
|
1531
|
+
through `resolveCompatSources`.
|
|
1532
|
+
|
|
1533
|
+
The twin of the `settingSources` brand, and it exists because that fix closed one of the two fields
|
|
1534
|
+
one `SettingSourcesSelection` feeds and left the other bare. Measured, with the `settingSources`
|
|
1535
|
+
route as the control: the control errored, and `setOnce(draft, 'compatSources', ['claude-code'],
|
|
1536
|
+
'cap')` compiled cast-free — while `agent-compiler.ts` told the reader that field "can only hold a
|
|
1537
|
+
source some posture granted".
|
|
1538
|
+
|
|
1539
|
+
It carries more authority than its twin, not less. `applyLocalSources` forwards it to
|
|
1540
|
+
`Agent.create({ local: { compatSources } })`, which reads `<cwd>/.claude/` — `hooks.json` included,
|
|
1541
|
+
and that executes shell.
|
|
1542
|
+
|
|
1543
|
+
**Signature narrowing**: `moderateOutputStream`'s `rebuildText` is now
|
|
1544
|
+
`(text: string, replaced: E) => E`. It was typed `E | undefined` for a case that cannot happen — a
|
|
1545
|
+
stream where no event carried text returns from the absence check before the guards run, so
|
|
1546
|
+
`rebuildText` is never reached. The branch handling that case was dead code, and three shipping
|
|
1547
|
+
artifacts described it as live.
|
|
1548
|
+
|
|
1549
|
+
**Fixed**: `isPort` discriminated on the presence of `run`, so an object carrying both `compiled`
|
|
1550
|
+
and a `run` — reachable through a spread, which is how targets are built in practice — took the port
|
|
1551
|
+
branch and skipped `delegate()` entirely: no declared guardrails, no inherited parent veto, no
|
|
1552
|
+
budget clamp. A tie now goes to the spec, because the spec branch is the guarded one.
|
|
1553
|
+
|
|
1554
|
+
### Patch Changes
|
|
1555
|
+
|
|
1556
|
+
- bb0f451: A guardrail refusal thrown inside a round is no longer renamed into a delegation failure.
|
|
1557
|
+
|
|
1558
|
+
`runReflectiveLoop` wrapped any error that was not already a delegation error into
|
|
1559
|
+
`DelegationError`, whose message reads `Delegation to agent "X" failed: ${cause.message}`. That code
|
|
1560
|
+
is on the delegate tool's message allowlist — a delegation failure's text is a fact about the work —
|
|
1561
|
+
so the wrapper carried the guard's own words to the model: `Guardrail "pii-detector" blocked output:
|
|
1562
|
+
ssn found`, naming the guard and its exact trigger.
|
|
1563
|
+
|
|
1564
|
+
Measured through `createDelegateTool` with a consumer-supplied `streamFactory` that throws
|
|
1565
|
+
mid-round. `streamFactory` is a public option, so this was reachable rather than theoretical.
|
|
1566
|
+
|
|
1567
|
+
`GuardrailViolationError` now passes through as itself, alongside the two delegation errors that
|
|
1568
|
+
already did, so the tool classifies it `guardrail_violation` and withholds the message.
|
|
1569
|
+
|
|
1570
|
+
Fixed by classification rather than by suppressing text downstream: a guard refusal is not a
|
|
1571
|
+
delegation failure, and a layer that renames an error cannot be expected to maintain a list of what
|
|
1572
|
+
the new name must hide.
|
|
1573
|
+
|
|
1574
|
+
- ac29eff: A permission-gate veto now emits a debug line.
|
|
1575
|
+
|
|
1576
|
+
`grantGate` refused and emitted nothing — no log, no counter, no debug line. An operator could
|
|
1577
|
+
observe the refusal only through the tool result the model received, and the two causes the veto
|
|
1578
|
+
message distinguishes ("no standing grant matches" versus "the permission store could not be read,
|
|
1579
|
+
so no grant applies") are indistinguishable from there.
|
|
1580
|
+
|
|
1581
|
+
That is pillar 3 of the wiring triad missing on a refusal seam. `bridge/approval-posture.ts`, the
|
|
1582
|
+
sibling gate, already logged through this exact seam.
|
|
1583
|
+
|
|
1584
|
+
The QUERY is logged — tool, scope, and which of the two causes fired — and the grant is not: a query
|
|
1585
|
+
names what an operator needs to diagnose, while the store's contents are the thing being protected.
|
|
1586
|
+
A classifier that threw logs as its own event rather than as an ordinary veto, so a consumer-code
|
|
1587
|
+
defect is not read as a denied tool.
|
|
1588
|
+
|
|
1589
|
+
## 13.0.0-next.12
|
|
1590
|
+
|
|
1591
|
+
### Minor Changes
|
|
1592
|
+
|
|
1593
|
+
- a7e35bb: A guardrail returning `action: 'redact'` with no replacement `text` now throws
|
|
1594
|
+
`MalformedGuardrailResultError` instead of silently redacting nothing.
|
|
1595
|
+
|
|
1596
|
+
`GuardrailResult.text` is optional, so such a guard compiles and reads like a working one. The
|
|
1597
|
+
pipeline tested `r.text !== undefined` and moved on, so the caller received the original text and
|
|
1598
|
+
believed a guard had run on it — the operator believing a protection is in place when none is.
|
|
1599
|
+
|
|
1600
|
+
**This is a behaviour change.** A guard relying on the previous no-op will now throw. That is
|
|
1601
|
+
deliberate: the alternative is unredacted output reaching a model because a guard was written wrong.
|
|
1602
|
+
`text: ''` is unaffected and always was a real redaction — a guard choosing to erase everything.
|
|
1603
|
+
|
|
1604
|
+
`MalformedGuardrailResultError` is exported from `@theokit/agents`, carries the guard's name and the
|
|
1605
|
+
phase, and is not retryable.
|
|
1606
|
+
|
|
1607
|
+
This also reaches the streaming path (`moderateOutputStream`), which shares the same pipeline: a
|
|
1608
|
+
malformed guard there now throws where it previously continued.
|
|
1609
|
+
|
|
1610
|
+
### Patch Changes
|
|
1611
|
+
|
|
1612
|
+
- 2bc27d3: `inheritHooks` no longer lets a member's `transform_tool_result` or `pre_user_send` handler replace
|
|
1613
|
+
its parent's — both now chain parent-first, matching the six events that already composed.
|
|
1614
|
+
|
|
1615
|
+
`inheritHooks` documents its security property as "the parent's refusal is evaluated first, and a
|
|
1616
|
+
member can only ever ADD a reason to refuse". For these two events the plain object spread did the
|
|
1617
|
+
opposite: a member declaring either handler silently discarded the parent's. The reachable surface is the EXPORTED `inheritHooks`, called with two handler maps.
|
|
1618
|
+
`delegate()` passes `undefined` for the member (`agent-orchestrator.ts:175`), so that path composed
|
|
1619
|
+
nothing and was never affected — a distinction the first version of this note got wrong.
|
|
1620
|
+
|
|
1621
|
+
`pre_user_send` composes additively — both contributions reach the model, parent first — because
|
|
1622
|
+
`PreUserSendResult` carries only `recalledContext` and the seam exposes no prompt mutation.
|
|
1623
|
+
|
|
1624
|
+
- eaac7b0: The "will NOT fire" warning for a declared-but-unwired hook event now names where the capability
|
|
1625
|
+
already lives, instead of ending "the handler does not exist yet".
|
|
1626
|
+
|
|
1627
|
+
Two of the three unwired events are served today by purpose-built seams — `Guardrail.checkOutput`
|
|
1628
|
+
for `transform_llm_output`, and `createToolHooksPlugin({ processInput })` for `pre_user_send` — so
|
|
1629
|
+
the old message told consumers to wait for work that will not come. The third, `on_session_end`, is
|
|
1630
|
+
named as genuinely uncovered, with the reason: its handler returns `void` and cannot refuse an
|
|
1631
|
+
ending, so wiring it would produce a hook that runs and cannot decide.
|
|
1632
|
+
|
|
1633
|
+
Nothing is wired. `HOOK_EVENTS`, `WIRED_EVENTS` and `OBSERVATIONAL_EVENTS` keep the same members.
|
|
1634
|
+
|
|
1635
|
+
- ec899f4: An observational hook handler is now assigned to its own key rather than chosen by comparison.
|
|
1636
|
+
|
|
1637
|
+
The dispatch loop used a two-branch conditional over a list of two event names, so a third
|
|
1638
|
+
observational event would have landed on `post_assistant_reply` — silently, with no test objecting.
|
|
1639
|
+
No behaviour changes for the events wired today; the fix removes the trap for the next one added.
|
|
1640
|
+
|
|
1641
|
+
## 13.0.0-next.11
|
|
1642
|
+
|
|
1643
|
+
### Patch Changes
|
|
1644
|
+
|
|
1645
|
+
- 5451233: Say, where a consumer can read it, why `CompatSurface` has five names and the SDK's has four
|
|
1646
|
+
|
|
1647
|
+
`CompatSurface` gained `'commands'` in #704 because this package reads
|
|
1648
|
+
`<projectDir>/.claude/commands/*.md` itself. The SDK's own union still has four. A caller who
|
|
1649
|
+
builds SDK `local` options directly therefore cannot pass a value of this type, and `TS2345` names
|
|
1650
|
+
the mismatch without naming the reason.
|
|
1651
|
+
|
|
1652
|
+
The explanation existed only in `//` comments, which do not survive into the emitted `.d.ts` — so
|
|
1653
|
+
it was invisible to exactly the audience that hits the error. It now lives in the JSDoc block that
|
|
1654
|
+
does ship, together with the guidance to derive the SDK's four from this five rather than writing a
|
|
1655
|
+
second list by hand: a hand-copied list goes stale the day a surface is added, which is the
|
|
1656
|
+
divergence #704 existed to remove.
|
|
1657
|
+
|
|
1658
|
+
Documentation only; no behaviour changes. Reported by a consumer who hit the compiler error while
|
|
1659
|
+
converging four call sites onto one declaration, and who supplied the sentence that was missing.
|
|
1660
|
+
|
|
1661
|
+
## 13.0.0-next.10
|
|
1662
|
+
|
|
1663
|
+
### Minor Changes
|
|
1664
|
+
|
|
1665
|
+
- ca44ee7: A narrowed foreign root can name `commands`
|
|
1666
|
+
|
|
1667
|
+
`settingSources.claudeCode.import` narrows the foreign root to named surfaces, and `CompatSurface`
|
|
1668
|
+
listed four of them — `hooks`, `plugins`, `skills`, `subagents`. It fed a fifth: this package loads
|
|
1669
|
+
`<projectDir>/.claude/commands/*.md` itself, outside the SDK's `compatSources` path. The name was
|
|
1670
|
+
missing from the vocabulary, so a consumer could neither ask for that directory nor be told it had
|
|
1671
|
+
gone unread, and `loadCustomCommands` tested `sources.includes('claude-code')` — string equality
|
|
1672
|
+
against a union whose narrowed member is an object, so every narrowed list read as _not declared_.
|
|
1673
|
+
|
|
1674
|
+
`CompatSurface` now includes `'commands'`, and the loader understands both shapes: the bare source
|
|
1675
|
+
name grants every surface the root feeds, the narrowed form grants the ones it names.
|
|
1676
|
+
|
|
1677
|
+
Purely additive — before this, no narrowed list reached the directory at all, so nothing that works
|
|
1678
|
+
today stops working. A narrowed list that wants foreign commands adds `'commands'` to `import`.
|
|
1679
|
+
|
|
1680
|
+
An enumeration used to narrow a root must cover every surface that root feeds; otherwise it is not
|
|
1681
|
+
a narrowing but an undeclared drop. Reported as usetheokit/theokit#704, found by measuring a claim
|
|
1682
|
+
in a consumer's adoption report rather than by any test here — the fifth time a gap in this
|
|
1683
|
+
package's public surface was invisible from inside it.
|
|
1684
|
+
|
|
3
1685
|
## 13.0.0-next.9
|
|
4
1686
|
|
|
5
1687
|
### Minor Changes
|