Skip to content

v0.75.0: plugin helpers (tw), in-place edits that count, per-block streaming - #341

Merged
fylorn merged 4 commits into
mainfrom
feat/plugin-helpers
Oct 16, 2026
Merged

fylorn merged 4 commits into
mainfrom
feat/plugin-helpers

Conversation

@fylorn

@fylorn fylorn commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Script plugins get a built-in helper object and no longer have to return the object they edited.

tw

A frozen global next to console and reject. It is plain JavaScript (crates/tw-plugin/src/helpers.js), evaluated together with the bridge at build time and baked into the snapshot. No new host imports, hooks or permissions; the host checks whatever it produces exactly like hand-written plugin output.

function does
appendSystem(req, text) appends a paragraph to the system prompt once
texts(req, fn) visits the system prompt, text parts and tool results (inputs views too); a returned string replaces
toolCalls(req, fn) hands over each tool-call part in the history; editing input takes effect
mapStrings(value, fn) deep copy with every string value mapped; __proto__ stays an ordinary key
replace(text, rules) rule-based replacement (below)
streamReplace(rules, options) { onReplyText, onReplyTextEnd } for stream-mode plugins, same result as replace on the whole block
lines(text), pairs(text, sep) parse multi-line settings; pairs throws on a malformed line and names its number

Rules are [find, replacement] pairs (array, Map or any iterable): one left-to-right scan, the first listed rule wins at a position, replaced text is never rescanned, string finds are literal, regular expressions work like String.prototype.replace (no g needed; empty-matching and y patterns are rejected). Secret placeholders such as <<TW_SECRET_1>> are never matched or split, and a match cannot span one.

streamReplace holds back only what a later piece could still change: for string rules, a tail that could start a find string; with a regular expression, the last maxMatch characters (default 256); and a trailing partial placeholder. Text blocks that are open at the same time are buffered separately: the host now passes the block number to the sandbox on every onReplyText / onReplyTextEnd call (guest ABI 2). Plugin code cannot see it. With reply: "block" each call is a whole block and is replaced at once.

In-place edits count

When onRequest or onToolCall returns undefined, the bridge serializes the object it received. If that differs from the input text, it goes to the host exactly as if the hook had returned it; otherwise the result is "unchanged" without involving the host. Returned objects, null, arrays and reject() behave as before. A verdict of "unchanged" can only drop an edit; permission and key checks on the host side are untouched.

Default plugins

reply-language uses tw.appendSystem, wsl-paths uses tw.toolCalls + tw.mapStrings with in-place edits. Manifests are byte-identical, behaviour is unchanged (existing tests pass), manifests.json is regenerated.

Run record: text changes are counted per block

A stream-mode plugin that holds text back hands out pieces that differ from what it was given, so each such call used to count as a text change, and tw.streamReplace with a regular expression made nearly every answer "changed". The gateway now compares, per text block, everything the plugin was given with everything it handed back (placeholder form). Matched parts are dropped as they arrive and only the unmatched stretch is kept, so a block is never stored twice; the block counts as one change if the two differ at its end. A plugin skipped after an error counts only if what it already handed out differs. Block mode keeps its per-block comparison.

Cleanup

A stale comment in the guest's Cargo.toml that described files the repository no longer has is replaced.

Cost

Measured with cargo run -p tw-plugin --example latency (host in debug; the sandbox code is the same optimized build either way), median of n runs:

case before after
hook reads a 1 MB view and returns nothing (sandbox CPU) 7.1 ms 13.3 ms
hook edits a 1 MB view and returns it (sandbox CPU) 17.3 ms 16.9 ms
hook reads a 100 KB view and returns nothing 0.76 ms 1.4 ms
reply() (fresh instance + ctx) 103 µs 109 µs
on_request, small view (fresh instance) 121 µs 125 µs

Dense rule-based replacement (1 MB, about 108,000 matches, sandbox CPU, before / after the last commit): string rules 132 / 20 ms, rules with a regular expression 147 / 112 ms, streamReplace in 64-character pieces 477 / 181 ms (strings) and 502 / 210 ms (with a regular expression). String rules whose find strings cannot overlap are applied with native split/join (a private-use character holds a replacement's place when it could otherwise be rescanned); everything else uses the scan, which now calls bound builtins instead of Reflect.apply wrappers.

The extra time for a hook that returns nothing is one JSON.stringify of the view plus a string comparison. The snapshot grows by about 190 KiB (guest.cwasm 3.01 MB to 3.21 MB, compared with the same bridge without tw); stripping function sources and debug info, or zeroing freed memory during initialization, saved under 5 KB, so neither is included.

Tests

  • crates/tw-plugin/tests/helpers.rs: every helper in the sandbox, including misuse, missing permissions and tampered builtins; interleaved blocks against block-mode results.
  • crates/tw-plugin/tests/corpus/helpers-split.js: randomized property test run inside the sandbox. Whole-text replace equals a naive per-position reference, and any split streamed through streamReplace equals the whole-text result without cutting a placeholder or a surrogate pair (3 seeds x 1500 cases x 4 splits; about a third of the cases use all-string rules on long texts with coarse splits to cover the split/join path; deliberate bugs in the holding logic and in the split/join path were each caught). Dense replacement is tested against the CPU limit without measuring time: 1 MB with string rules, 100 KB with a regular expression.
  • crates/tw-plugin/tests/in_place.rs: the return cases for both hooks (no return with and without edits, edited then restored, returned self, returned other object, async, reject, unserializable edits).
  • Run record: a stream-mode tw.streamReplace whose rules never match, fed one character per frame, is recorded as unchanged (this failed before the fix); holding a whole block and releasing it unchanged is unchanged, any difference is a change, in both modes; unit tests for the per-block comparison, including the skip-after-error case.
  • Gateway: the block number reaches the plugin for interleaved Anthropic blocks in both modes; the sandbox adapter passes it through; end-to-end runs for in-place request edits, a read-only hook leaving the request byte-identical, streamReplace over a one-character-per-frame stream, and secrets that survive replacement rules aimed at placeholder text.

cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings and cargo test --workspace pass locally.

Release

This PR also carries v0.75.0: the workspace version and release-notes/0.75.0.md, which cover #339, #340, #342, #343 and this change. Protocol 48 and the request store schema are unchanged.

🤖 Generated with Claude Code

@fylorn
fylorn force-pushed the feat/plugin-helpers branch from 65ce34d to 8251d90 Compare October 16, 2026 09:53
fylorn and others added 3 commits October 16, 2026 18:30
…treaming

Plugins get a built-in helper object and no longer have to return the
object they edited.

- `tw` (crates/tw-plugin/src/helpers.js) is plain JavaScript evaluated
  together with the bridge at build time and baked into the snapshot:
  appendSystem, texts, toolCalls, mapStrings, replace, streamReplace,
  lines and pairs. It captures every builtin it uses before plugin code
  runs, throws English errors that name a missing permission, and the
  host trusts its output no more than hand-written plugin output. `tw` is
  added to the sandbox's global allow-list.
- Replacement rules: one left-to-right scan, the first listed rule wins
  at a position, replaced text is never rescanned, string finds are
  literal, regular expressions behave like String.prototype.replace, and
  secret placeholders are never matched or split.
- tw.streamReplace holds back only what a later piece could still
  change and buffers each text block separately. The host now passes the
  block number to the sandbox on every onReplyText / onReplyTextEnd call
  (guest ABI 2); plugin code cannot see it.
- onRequest and onToolCall: when the hook returns undefined, the bridge
  serializes the object it was given; if that differs from the input it
  is handed to the host exactly as if the hook had returned it.
  Otherwise the result stays "unchanged" without involving the host.
- The two default plugins use the helpers. Their behaviour is unchanged;
  manifests.json is regenerated.

Tests cover every helper (normal use, misuse, missing permissions,
tampered builtins), a randomized split property test that runs inside
the sandbox (whole-text replace equals a naive reference, and any split
streamed through streamReplace equals the whole-text result without
cutting a placeholder or a surrogate pair), interleaved blocks, the
in-place rules for both hooks, and end-to-end runs through the gateway.
The latency example gains a "read a view, no return" case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…piece

A stream-mode reply plugin that holds text back hands out pieces that
differ from what it was given, so every such call counted as "changed"
in the run record. tw.streamReplace with a regular expression holds the
last characters of every piece, so nearly every answer was recorded as
changed even when no rule matched.

The gateway now compares, per text block, everything the plugin was
given with everything it handed back (in placeholder form). Matched
parts are dropped as they come; only the stretch that has not matched
yet is kept, so a block is not stored twice. The block counts as one
change if the two differ at the end. A plugin skipped after an error
counts only if what it already handed out differs; text it was still
holding goes on unchanged. Block mode keeps its per-block comparison.

Also drop a stale comment in the guest's Cargo.toml that described files
the repository no longer has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 1 MB request with about 108,000 matches took about 130 ms of sandbox
CPU in tw.replace, close to the 200 ms request-hook limit and over it on
slower machines.

- All-string rules on a longer text: when no two find strings can
  overlap (neither contains the other and no end of one starts another),
  the single left-to-right scan picks exactly each rule's own leftmost
  non-overlapping occurrences, so every rule is applied with one native
  split/join. A replacement that shares no UTF-16 code unit with the
  find strings still to come is written directly; otherwise a private-use
  character absent from the text and the rules holds its place until all
  rules are done, so replaced text is never rescanned and a removal
  cannot join its neighbours into a new match. Duplicate find strings
  keep the first rule. Other rule sets fall back to the scan.
- tw.streamReplace uses the same path when a section ends (at a
  placeholder or the end of the block) and, when no find string overlaps
  itself, for the part of a piece before the hold-back point.
- The general scan calls captured builtins through bound functions
  instead of Reflect.apply wrappers, keeps each rule's next match on the
  rule itself and writes plain replacements inline. The check for a
  trailing partial placeholder looks for "<" natively.

Sandbox CPU on a 1 MB text with about 108,000 matches, before / after:
string rules 132 / 20 ms, mixed with a regular expression 147 / 112 ms,
streamed in 64-character pieces 477 / 181 ms (strings) and 502 / 210 ms
(mixed). The randomized split property test now also generates
all-string rule sets on long texts with coarse splits, which covers the
new path; the dense test no longer depends on machine speed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fylorn
fylorn force-pushed the feat/plugin-helpers branch from 8251d90 to 17723fa Compare October 16, 2026 10:40
Plugin helpers (tw) and in-place edits; every choice of an answer is
checked by the dangerous-command rules and restored per choice; reply
plugin fixes for split placeholders, skipped plugins and answers with
several choices. Protocol 48 and the request store schema unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fylorn fylorn changed the title Plugin helpers: a global tw, in-place edits that count, per-block streaming v0.75.0: plugin helpers (tw), in-place edits that count, per-block streaming Oct 16, 2026
@fylorn
fylorn merged commit f040a92 into main Oct 16, 2026
7 checks passed
@fylorn
fylorn deleted the feat/plugin-helpers branch October 16, 2026 11:32
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 16, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant