You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[FEATURE]: Plugin API: hook before the host stores synthetic text #57214
ctx.session.synthetic(...) stores text without the session.prompt hook. A filtering plugin uses that hook for usual prompts. The plugin cannot filter synthetic text before storage.
Source (v2.0.31)
packages/core/src/session/session.ts:273 gives Session.synthetic. At line 298 it calls admission.admit directly:
const admitted = yield* admission .admit({
packages/core/src/session/prompt.ts:40 shows that a usual prompt goes through the hook first:
The synthetic path does not call SessionPrompt.prepare.
Purpose
A plugin removes secrets and unwanted text from user input. The plugin filters at the prompt hook. Synthetic text from the host or from a different plugin goes around the filter.
Proposed minimal API
Start the session.prompt hook before the host stores the synthetic text, or add a session.synthetic hook with the same shape.
What the plugin does today without it
The plugin cannot filter synthetic text before storage. The plugin can filter then, at the session.context hook, but only after the host stores the text.
Thanks for the detailed write-up and the source pointers!
To restate the request: you'd like a plugin hook that runs before the host stores synthetic text, either by also triggering session.prompt for synthetic input or through a new session.synthetic hook with the same shape. That way a filtering plugin can remove secrets and unwanted text from synthetic input before it's saved, not only afterwards at session.context.
I checked the code and it matches your description: Session.synthetic passes the item straight to admission.admit without calling SessionPrompt.prepare, and no plugin hook currently runs before synthetic text is stored. One more detail: some synthetic text, such as instruction messages, is published without going through Session.synthetic at all, so a complete fix would need to cover those routes too.
Maintainers will consider this. Related issues on redacting text before it's stored:
There are two storage boundaries to distinguish here. Session.synthetic reaches the durable InboxEnqueued event before delivery, so filtering at promotion would already be too late for pre-storage filtering. A hook for this path needs to run before a new admission, while retaining the existing first-admission-wins behavior for repeated IDs.
For the internal routes mentioned above, SessionInstructions.load explains why it publishes directly: routing through Session creates a dependency cycle. Its metadata.instruction.paths is also the lasting dedup ledger. Giving a filtering hook unrestricted replacement of that metadata could cause instructions to be re-injected or incorrectly treated as loaded.
Would a dedicated synthetic hook with host-owned origin information and text mutation be the preferred starting point? I'd keep instruction bookkeeping host-owned and make its inclusion an explicit design decision; wiring only ctx.session.synthetic would not establish coverage of internal instruction/restart/continuation events.
Yes. A dedicated synthetic hook with host-owned origin information and text mutation is the right starting point for us. What we need from it:
Timing: it runs before a new admission (before InboxEnqueued), so the stored inbox payload is already filtered.
New IDs only: a retry with an existing ID keeps first-admission-wins and does not run the hook again.
Read-only origin: at least the calling plugin ID (or the host) and the route (plugin synthetic, prompt, restart, continuation).
Text-only mutation: metadata stays as admitted, so a filter cannot change dedup ledgers such as metadata.instruction.paths.
Fail closed: if the hook throws, the admission fails, the same as an execute.before throw today.
Instruction text: agreed, keep the bookkeeping host-owned. Instruction files are operator-controlled, and we already filter their text before each request in the context hook. So it is acceptable for us if the first change covers only ctx.session.synthetic, provided the docs name the routes the hook does not cover. The case we hit in tests is plugin synthetic text: one plugin admitted tool output with ctx.session.synthetic, and the raw value was in session_message before any hook ran.
That gives the first change a clear scope: ctx.session.synthetic, with the other synthetic writers explicitly excluded.
Two implementation details matter for those guarantees:
Origin needs a host binding. The inspected host.ts forwards synthetic: sessions.synthetic directly. The host needs to supply its bound plugin identity and a plugin-synthetic route, rather than trusting caller metadata. The hook should receive only replaceable text plus read-only context; handing it the original mutable metadata object would defeat the text-only restriction.
New-ID filtering needs concurrency coordination.admit reconciles an existing ID before publishing. An unlocked lookup followed by a hook would let two concurrent callers both run that hook before either admission exists. Lookup, preparation and admission need coordination that preserves existing lifecycle conflicts. That design also needs to handle re-entrant admission from a hook without introducing a same-session deadlock.
For fail-closed behavior, a preparation failure should produce no InboxEnqueued, pending row or wake for that admission, leaving a previously unused ID available for a later successful attempt.
I'd use these acceptance cases: filtered text in the durable inbox and delivered message; unchanged metadata; an existing-ID retry without another hook call; concurrent same-ID submissions; and a throwing hook leaving no admitted input. These are proposed checks, not results I've run.
The docs should explicitly exclude normal prompts, internal instruction/restart/continuation writes, and instruction-state storage. Listing those names in an origin enum must not imply this first hook intercepts them.
Agreed on both details: a host-bound plugin identity with a plugin-synthetic route, and the hook gets only replaceable text plus read-only context. The fail-closed rule (no InboxEnqueued, no pending row, no wake, ID still unused) is exactly what we need.
One case to add to the acceptance list: a hook that itself calls ctx.session.synthetic for the same session, for example to admit a warning notice about what it filtered. It must not deadlock, and it must not run the hook again on its own notice in a loop. Whether that inner call is refused or admitted after the outer admission, please document the rule.
When a branch exists, we can run it against our secret-filter tests, which plant credentials in synthetic text and then scan the inbox, messages and logs.
Agreed — same-session reentrancy should be a separate acceptance case, with a defined outcome rather than just a timeout check.
For v1, I propose rejecting a nested ctx.session.synthetic call targeting the session whose hook is currently executing. That check must happen before waiting on an in-flight ID or acquiring admission coordination, including when the nested call reuses the outer ID. It should return a specific reentrancy error without invoking another hook, admitting an input, or emitting a wake. Normal callers outside that hook invocation keep the existing retry/concurrency behavior.
The hook execution context needs to carry this restriction through the host boundary; plugin-supplied metadata must not be able to suppress it. Simply releasing a session lock around the hook would avoid one deadlock while still permitting recursive hook calls.
I'd add these explicit acceptance assertions:
A hook awaits a same-session synthetic call with a fresh ID: the nested call promptly rejects, the hook is invoked once, and no nested input is persisted or woken.
Repeat with the outer input's ID: the nested call rejects before waiting on its own in-flight admission.
If the hook catches that error and returns replacement text, only the outer input is admitted with that text. If it lets the error escape, the outer admission also fails closed, with no InboxEnqueued, pending row or wake for either call.
After the hook finishes, a normal call using a previously unused rejected ID can succeed; the rejection must not leave an admission reservation behind.
These are proposed contract and regression assertions, not executed test results.
Rejecting a nested same-session synthetic call works for us. If our hook must report what it filtered, we can admit that warning after the outer admission completes.
Two requests for the contract:
Make the reentrancy rejection a tagged error, like SessionBusyError, so that a plugin can tell it apart from busy and not-found errors and defer the warning.
Please state what happens when the hook calls ctx.session.prompt (not synthetic) for the same session. That call does not run the hook, so it cannot recurse, but it can wait on the same admission coordination. It should either succeed without a deadlock or get the same typed rejection.
Your four assertions match the cases we plan to run against a branch.
Activity
opencode-agent commentedon Oct 19, 2026
Thanks for the detailed write-up and the source pointers!
To restate the request: you'd like a plugin hook that runs before the host stores synthetic text, either by also triggering
session.promptfor synthetic input or through a newsession.synthetichook with the same shape. That way a filtering plugin can remove secrets and unwanted text from synthetic input before it's saved, not only afterwards atsession.context.I checked the code and it matches your description:
Session.syntheticpasses the item straight toadmission.admitwithout callingSessionPrompt.prepare, and no plugin hook currently runs before synthetic text is stored. One more detail: some synthetic text, such as instruction messages, is published without going throughSession.syntheticat all, so a complete fix would need to cover those routes too.Maintainers will consider this. Related issues on redacting text before it's stored:
tool.execute.aftercan redact it)Dante-dan commentedon Oct 20, 2026
There are two storage boundaries to distinguish here.
Session.syntheticreaches the durableInboxEnqueuedevent before delivery, so filtering at promotion would already be too late for pre-storage filtering. A hook for this path needs to run before a new admission, while retaining the existing first-admission-wins behavior for repeated IDs.For the internal routes mentioned above,
SessionInstructions.loadexplains why it publishes directly: routing throughSessioncreates a dependency cycle. Itsmetadata.instruction.pathsis also the lasting dedup ledger. Giving a filtering hook unrestricted replacement of that metadata could cause instructions to be re-injected or incorrectly treated as loaded.Would a dedicated synthetic hook with host-owned origin information and text mutation be the preferred starting point? I'd keep instruction bookkeeping host-owned and make its inclusion an explicit design decision; wiring only
ctx.session.syntheticwould not establish coverage of internal instruction/restart/continuation events.iceteaSA commentedon Oct 20, 2026
Yes. A dedicated synthetic hook with host-owned origin information and text mutation is the right starting point for us. What we need from it:
InboxEnqueued), so the stored inbox payload is already filtered.metadata.instruction.paths.execute.beforethrow today.Instruction text: agreed, keep the bookkeeping host-owned. Instruction files are operator-controlled, and we already filter their text before each request in the
contexthook. So it is acceptable for us if the first change covers onlyctx.session.synthetic, provided the docs name the routes the hook does not cover. The case we hit in tests is plugin synthetic text: one plugin admitted tool output withctx.session.synthetic, and the raw value was insession_messagebefore any hook ran.Dante-dan commentedon Oct 20, 2026
That gives the first change a clear scope:
ctx.session.synthetic, with the other synthetic writers explicitly excluded.Two implementation details matter for those guarantees:
host.tsforwardssynthetic: sessions.syntheticdirectly. The host needs to supply its bound plugin identity and aplugin-syntheticroute, rather than trusting caller metadata. The hook should receive only replaceable text plus read-only context; handing it the original mutable metadata object would defeat the text-only restriction.admitreconciles an existing ID before publishing. An unlocked lookup followed by a hook would let two concurrent callers both run that hook before either admission exists. Lookup, preparation and admission need coordination that preserves existing lifecycle conflicts. That design also needs to handle re-entrant admission from a hook without introducing a same-session deadlock.For fail-closed behavior, a preparation failure should produce no
InboxEnqueued, pending row or wake for that admission, leaving a previously unused ID available for a later successful attempt.I'd use these acceptance cases: filtered text in the durable inbox and delivered message; unchanged metadata; an existing-ID retry without another hook call; concurrent same-ID submissions; and a throwing hook leaving no admitted input. These are proposed checks, not results I've run.
The docs should explicitly exclude normal prompts, internal instruction/restart/continuation writes, and instruction-state storage. Listing those names in an origin enum must not imply this first hook intercepts them.
iceteaSA commentedon Oct 20, 2026
Agreed on both details: a host-bound plugin identity with a
plugin-syntheticroute, and the hook gets only replaceable text plus read-only context. The fail-closed rule (noInboxEnqueued, no pending row, no wake, ID still unused) is exactly what we need.One case to add to the acceptance list: a hook that itself calls
ctx.session.syntheticfor the same session, for example to admit a warning notice about what it filtered. It must not deadlock, and it must not run the hook again on its own notice in a loop. Whether that inner call is refused or admitted after the outer admission, please document the rule.When a branch exists, we can run it against our secret-filter tests, which plant credentials in synthetic text and then scan the inbox, messages and logs.
Dante-dan commentedon Oct 21, 2026
Agreed — same-session reentrancy should be a separate acceptance case, with a defined outcome rather than just a timeout check.
For v1, I propose rejecting a nested
ctx.session.syntheticcall targeting the session whose hook is currently executing. That check must happen before waiting on an in-flight ID or acquiring admission coordination, including when the nested call reuses the outer ID. It should return a specific reentrancy error without invoking another hook, admitting an input, or emitting a wake. Normal callers outside that hook invocation keep the existing retry/concurrency behavior.The hook execution context needs to carry this restriction through the host boundary; plugin-supplied metadata must not be able to suppress it. Simply releasing a session lock around the hook would avoid one deadlock while still permitting recursive hook calls.
I'd add these explicit acceptance assertions:
InboxEnqueued, pending row or wake for either call.These are proposed contract and regression assertions, not executed test results.
iceteaSA commentedon Oct 21, 2026
Rejecting a nested same-session
syntheticcall works for us. If our hook must report what it filtered, we can admit that warning after the outer admission completes.Two requests for the contract:
SessionBusyError, so that a plugin can tell it apart from busy and not-found errors and defer the warning.ctx.session.prompt(notsynthetic) for the same session. That call does not run the hook, so it cannot recurse, but it can wait on the same admission coordination. It should either succeed without a deadlock or get the same typed rejection.Your four assertions match the cases we plan to run against a branch.