ThinkWatch Core 0.58.0
This release adds script plugins: JavaScript that changes requests before they go upstream and answers before they reach the client, run in a WebAssembly sandbox inside core. It also reads a session back as a conversation, stores request and answer bodies with recognized secrets already replaced, and records an error that an upstream answered as a failed request. The five security guards become three. Hidden characters are now content-filter rules, the output limit is gone, and tool-call inspection gains two rules against sending credentials and files to other hosts.
Upgrade notes
- The control-plane protocol version (
CONTROL_API_VERSION) goes from 31 to 34. ThinkWatch Lite connects only to a core with the same protocol version. ThinkWatch Lite 2026.10.0 includes 0.57.1 (protocol 31) and does not connect to 0.58.0. A server used with it stays on 0.57.1 until the app is updated to a release that includes 0.58.0;sudo twcore upgrade --version 0.57.1 --restartswitches a server back. - The request store's schema goes from 23 to 25: the security log has two new columns, and plugin runs have a table of their own. The request history is cleared when the store is rebuilt on first start.
security.hidden_textandsecurity.output_limitare removed. A configuration that still has either key does not load: the app starts in safe mode, andtwcore serveexits with the error.- Only configurations where one of these settings was changed are affected. A new configuration has no
securitysection, and the app writes only values that differ from the factory ones. In ThinkWatch Lite 2026.10.0, that means:- Hidden characters set to Off or Enforce (factory: Observe), or one of its two rules (Unicode tag characters, bidirectional controls) turned off.
- Output limit set to Observe or Enforce (factory: Off), or its limit changed from 100,000 characters.
- Before upgrading, do one of the following:
- set these back to their factory values in the app;
- delete
hidden_text:andoutput_limit:, with the lines indented under them, from thesecurity:section ofconfig.yaml.
- Hidden characters are now checked by content-filter rules that follow the content filter's mode (see Three guards).
- Only configurations where one of these settings was changed are affected. A new configuration has no
- Changed in the protocol:
- Removed:
PUT /security/{guard}/limit(SetSecurityLimit,LimitSave);OutputLimitDetail,HiddenItem,HiddenKind;- the events
hidden_text_foundandoutput_limited; hidden_textandoutput_limitinGuard,SecurityDetail,SecurityViewandSecurityCounts.
GET /sessions/{id}/transcript(SessionTranscript→Transcript, withTranscriptTurn,TranscriptMessage,TranscriptRole,TranscriptPart,TranscriptGap).- The plugin endpoints:
GET /plugins(Plugins→PluginView),POST /plugins/inspect(PluginInspect:PluginSource→PluginInspection),POST /plugins(CreatePlugin,PluginCreate),PUT /plugins/order(ReorderPlugins,PluginOrder);PUT /plugins/{id}(UpdatePlugin,PluginUpdate),PUT /plugins/{id}/confirmed(UpdatePluginConfirmed),DELETE /plugins/{id}(DeletePlugin);PUT /plugins/{id}/source(ReplacePluginSource,PluginSourceReplace),GET /plugins/{id}/source(PluginSourceDiff→PluginSourceView),POST /plugins/{id}/approve(ApprovePluginFile,PluginApprove);POST /plugins/{id}/trial(TrialPlugin:PluginTrial→PluginTrialResult,TrialSide),GET /plugins/{id}/logs(PluginLogs→PluginLogEntry).- Supporting types:
ManifestView,PluginStatus,PluginStats,PluginLastError,PluginLoadError,PluginScope,PluginHooks,PluginRunView,SettingSpecView,SettingValue,Permission,RequestKind,OnError,ReplyMode,SettingKind,PluginHook,PluginOutcome,PluginLogLevel.
Event::PluginFailed.RequestDetailhaspluginsandrequest_after_plugins,HistoryRowhasplugin_changed, andConfigOriginhasdefaults.TurnViewhasstatus.- The security types are now defined in tw-guard and keep their names. Changes:
SecurityRuleViewandCustomRuleSavehavelabel.Matcherhasemail,cn-mobile-phoneandbuiltin;RuleActionhasstrip;ContentMatchhascodepoints.SecurityTestRequesthaslabelandaction;SecurityTestResulthasoutputandrefused.SecurityOutcomeandSecurityOutcomeCountshavestripped;SecurityEventViewhasmatchandrevealed.- The security counts in
/summaryaresecrets,secrets_replaced,tool_calls,tool_calls_cut,content,content_blockedandcontent_stripped.
content_matchedhasmatch,action,outcome(ContentOutcome:recorded,stripped,blocked),countandrevealed, and no longerblocked.
- Removed:
- Message codes, compared with 0.57.1:
- New:
- 52 for plugins (
config.plugin.*,control.plugin.*,gw.plugin.*).gw.plugin.reasononly passes on what the plugin wrote. gw.upstream.status_message.- Nine for the guards:
config.rule_codepoints_bad,config.rule_label_bad,gw.content.refused_invisible_message,gw.content.refused_invisible_tool_result,security.bad_codepoints,security.bad_label,security.content_action_unknown,security.pattern_empty,security.unknown_guard. - Two test-only codes, which never reach a UI.
- 52 for plugins (
- Renamed:
gw.toolcall.cut→gw.toolcall.response_cut,gw.toolcall.blocked→gw.toolcall.response_withheld,gw.ws.toolcall_cut→gw.toolcall.connection_cut(itsdetailargument is nowwhy). The sentences now say that the answer contained the call, not that an upstream returned it. - Removed:
config.output_limit_range,gw.hidden_text.refused_message,gw.hidden_text.refused_tool_result,gw.output_limit.cut,gw.output_limit.withheld,security.guard_unknown,security.limit_range,security.no_custom_rules,security.no_limit,security.nothing_to_test,security.unknown_content_action.security.unknown_guardandsecurity.content_action_unknowntake the place ofsecurity.guard_unknownandsecurity.unknown_content_action, with sentences that list the new guards and actions.
- New:
- Building from source needs an LLVM
clangthat can compile C to WebAssembly, and itsllvm-ar. The plugin sandbox compiles QuickJS while core builds. Apple's clang cannot target WebAssembly; on macOS,brew install llvm. The README lists the other systems. The released binaries need nothing new. - Behavior worth knowing:
- Compaction requests (
/v1/responses/compact,/backend-api/codex/responses/compact) are screened by the content filter like generation requests. Token counts are still not screened. - Tool-call excerpts are masked before they reach the
tool_call_flaggedevent, the security log and system notifications. A secret restored from a placeholder used to appear there in the clear. - Stored bodies no longer contain the secrets and personal numbers that the redaction rules recognize, whatever the redaction mode (see Stored bodies).
- The retention settings now take effect. tw-store ran its own hourly cleanup with fixed limits (7 days of bodies, 90 days of rows, 2 GiB of bodies) beside the one that reads
retention, so any setting above those limits was cut back every hour. That cleanup is removed.retention.body_max_bytesnow defaults to 5 GiB (it was 2 GiB). - On first start, core adds its two default plugins to
config.yaml, turned off, and writes their files toplugins/beside it. That is one configuration version, recorded in the history asdefaults.
- Compaction requests (
Session transcripts. GET /sessions/{id}/transcript reads a session's stored bodies back as a conversation. For every turn it gives the messages that are new in that request, the answer, and what could not be shown.
- Clients resend the whole history on every turn, so a turn shows only what its request added after the previous readable one.
- Messages are compared by a fingerprint that ignores cache markers, reasoning signatures, key order and reasoning text. When the history was edited or compacted, the turn is marked
restartand carries the whole history. - It reads Anthropic Messages, OpenAI Chat Completions and Responses, Gemini and Bedrock, streamed and whole, including answers converted from another format.
- Tool-call ids in converted answers are matched to the ones the client recorded, so results pair with their calls.
- A message holds text, reasoning, tool calls, tool results, images (media type and size only), and other blocks by their type name. System and developer messages in the middle of a conversation appear as
systemmessages. - Gaps say what is missing:
request_missing,request_truncated,response_missing,response_truncated,response_unreadable. - Every string is masked after decoding. Image data and reasoning signatures are never returned.
- A sub-agent's work is in its own session.
Stored bodies. Bodies used to be stored as the client sent them and masked only when read. They are now redacted when twcore hands them to the store, off the forwarding path.
- Every value the redaction rules recognize is taken out, whatever the mode,
offincluded.- Under
enforce, a request is stored with the placeholders the upstream received. - In the other modes, the values are masked.
- The whole text then goes through the same masking the read side applies.
- Under
- A value gets the same placeholder in every hop, in the stored request and in the stored answer.
- Up to 4 MiB of each answer is kept (it was 256 KiB), the same as for requests. A request over 4 MiB is stored cut, with its original length recorded, so a replay refuses it instead of sending half the JSON.
- Bodies waiting to be written are capped at 32 MiB as well as at 64 entries.
- Where the stored copy shows:
- The request detail shows the stored copy.
- Whole-history search no longer finds text that occurs only inside a secret.
- A replay sends the stored request, with placeholders or masked values where the secrets were.
- The fixture export redacts again with every built-in rule.
Failed upstream answers. When an upstream answered 4xx (or 3xx) and the gateway passed that answer on to the client, the request ended as finished with an empty error. It counted as successful everywhere. It now ends with RequestFailed, from source upstream. A request failed if and only if its error is set, in the traffic list, history search, the overview, sessions and the upstream check-up.
- The recorded reason is what the upstream said:
gw.upstream.status_message(upstream,status,message), read in the upstream's format, masked, and capped at 500 characters. An empty body or an HTML page givesgw.upstream.status. TurnView.statuscarries the upstream's status code, so a failed turn can say what the upstream answered.- The client still gets the upstream's answer byte for byte, and a client error still does not count against the upstream.
Three guards. Outbound redaction, tool-call inspection and the content filter now share one model in tw-guard: policy shape, built-in catalogs, validation, rule views, and the logic behind "Test…". The enterprise edition uses the same model.
- Hidden characters are built-in content rules matched by code point.
unicode-tagsandbidi-controlsare on;zero-widthandprivate-useare off. - Actions. Content rules act with
block,strip(new) orrecordwhen the filter is inenforce. The hidden-character rules strip, where the old guard refused the request.- Stripped text is removed from user messages and tool results.
- The stripped body is what is redacted, recorded and sent on every hop.
- For tag characters, events and the security log also show the decoded text (
revealed).
- Custom rules. Custom content rules can match code points (
U+200B, U+E0000–U+E007F). Custom redaction rules can name their placeholder:label: PROJECTgives<<TW_PROJECT_1>>. - New redaction rules.
emailandcn-mobile-phone(Chinese mainland mobile numbers) are built in, off by default. - What is screened.
- Compaction requests are screened, and so are
response.createframes on the Responses WebSocket. Other WebSocket frames go through the code-point rules only. - Token counts, embeddings and legacy completions are not screened.
- A refused request still leaves a failed row.
- Compaction requests are screened, and so are
- Output limit. It is removed.
- Testing. "Test…" takes an action, and shows what would be sent and whether the request would be refused.
Two tool-call rules. Placeholders are restored in answers, so an upstream could write a tool call that sends a restored credential somewhere. Tool calls are judged as the client will execute them, after restoration. Two built-in rules cover this:
secret-to-unknown-host(high: cut inenforce). It fires when a tool call makes an http(s) request with an API key or private key in its arguments that the redaction rules recognize, and the destination is neither local nor that credential's own provider (an Anthropic key going to anthropic.com is fine). JWTs and connection strings do not count here.upload-file-to-host(medium: recorded only). It fires when a tool call uploads a local file to a host that is not local:curl -T,--data @file,-F field=@file,--upload-file,--post-file.- The excerpt shows only
scheme://host, so it never carries the credential. - Tool-call inspection starts in
observe, where both rules only record. - The cut messages name the rule, not who produced the call, since a plugin can produce one too.
Script plugins. Plugins are JavaScript modules that change requests before they go upstream and answers before they reach the client.
-
Sandbox. They run only inside core, in QuickJS compiled to WebAssembly and run by Wasmtime. They have no file system, network, environment, timers or module imports. A request hook gets a fresh instance for every call. An answer gets one instance, shared by its hooks and discarded when the answer ends.
-
Permissions.
system,messages,tools,params,reply.textandreply.tool_calls. A plugin sees only the parts it was granted. Every edit is checked: keys, read-only fields and permissions. A request no plugin changed reaches the upstream byte for byte. -
Placeholders. Plugins never see a real secret. Before a hook runs, values the redaction rules recognize are replaced with placeholders, in every redaction mode and with the same numbering as outbound redaction. Placeholders are put back afterwards, whoever wrote them. Tool-call inspection judges the call after restoration.
-
Request hooks.
- They run after routing, once per upstream attempt, on the client's request as the content filter left it.
- Failover to another upstream starts again from that request, so an edit made for one upstream never reaches the next. Resends to the same upstream reuse the result.
- A changed request is screened again. Only what the plugins added is reported, and only that can refuse the request.
reject(), an error underon_error: reject, or a refusal by the content filter refuses the whole request without failing over.- A plugin's
params.modelrenames what that upstream gets and never re-routes. The gateway key's model list still applies (gw.plugin.model_not_allowed).
-
Request kinds. A plugin handles the kinds of request its manifest lists in
requests, and conversations only when it lists none.- Conversations are Anthropic Messages, OpenAI Chat Completions and Responses, and Gemini, with their token counts and compaction.
- Embeddings and legacy completions go only through plugins that declare them, and only their input text can change.
- Other endpoints pass without any plugin.
-
Reply hooks. They run after format conversion and before tool-call inspection, so inspection sees what the plugin produced. They cover streamed, whole and converted answers, and the Responses WebSocket. Text comes in blocks or as a stream; tool calls come whole.
-
Management. The
plugins:section ofconfig.yamllists plugins in the order they run.- A plugin runs only while the SHA-256 of
plugins/<id>.jsmatches its approvedsha256. A changed file stops it within seconds, until it is approved again. - Each plugin has
on_error(rejectorskip), a scope (clients, models sent, upstreams) and settings.
- A plugin runs only while the SHA-256 of
-
Confirmations. These need the user's confirmation outside the web page:
- installing a plugin, replacing its source, or approving a changed file;
- turning on a plugin that can rewrite tool calls, or changing its settings or scope.
UpdatePluginrefuses the second kind with 403control.plugin.needs_confirmation. A client callsCreatePlugin,ReplacePluginSource,ApprovePluginFileandUpdatePluginConfirmedfrom native code after the user confirms, never from its web view. ThinkWatch Lite asks in a system dialog. -
Default plugins. Two ship with core and are added turned off:
reply-languageasks the model to answer in a chosen language.wsl-pathsconverts drive paths in tool-call arguments between their WSL and Windows forms.
A default that was deleted is not added back, and one that was changed is left alone. A new version that asks for more permissions or request kinds comes back turned off. The sandbox (about 7 MB of memory) starts only once a plugin is turned on.
-
Limits.
- A request hook gets 200 ms of CPU and 128 MiB of memory. An answer gets 20 ms per call, 2 s in total and 64 MiB.
- Output is capped at twice the input plus 1 MiB, and logs at 100 lines per call (longer lines are cut at 4 KiB). The source is capped at 1 MiB.
- At most 32 plugin instances run on answers at once. Past that, a plugin follows its
on_error.
-
Recording. Every run is recorded with its hook, outcome, CPU time and upstream attempt.
- The request detail shows the runs and the body sent after plugins, which is stored with secrets replaced. History rows say when a plugin changed the request.
- Failures raise
plugin_failed. - Each plugin keeps its last 500 log lines in memory.
- A plugin can be tried on a recorded request without contacting an upstream.
Security fixes.
- rustls 0.23.45 fixes RUSTSEC-2026-0285: TLS 1.3 handshake messages were accepted across encryption-level boundaries. Every upstream request goes through rustls.
- The plugin sandbox ships with Wasmtime 49.0.2, which fixes RUSTSEC-2026-0325, -0326 and -0327. Those advisories were published against 49.0.1, which no release contained.
- CI now checks the dependencies against RustSec advisories.
Downloads
| Platform | Binary | Archive for server installation |
|---|---|---|
| Linux, x86_64 | twcore-x86_64-unknown-linux-gnu |
twcore-x86_64-unknown-linux-gnu.tar.gz |
| Linux, aarch64 | twcore-aarch64-unknown-linux-gnu |
twcore-aarch64-unknown-linux-gnu.tar.gz |
| macOS, Apple silicon | twcore-aarch64-apple-darwin |
— |
| Windows, x64 | twcore-x86_64-pc-windows-msvc.exe |
— |
| Windows, ARM64 | twcore-aarch64-pc-windows-msvc.exe |
— |
Each file is published with a .sha256 file beside it. A Linux archive contains twcore, the systemd unit twcore.service and LICENSE. ThinkWatch Lite includes its own copy of twcore; the files here are for running core separately, such as on a server.
Server installation
On Linux (x86_64 or aarch64), the install script sets up twcore as a systemd service. This installs 0.58.0:
curl -fsSL https://raw.githubusercontent.com/ThinkWatchProject/ThinkWatch-Core/main/scripts/install.sh | sudo sh -s -- --version 0.58.0An installation made with the script switches to 0.58.0 with:
sudo twcore upgrade --version 0.58.0 --restartConfiguration, the remote control port and connecting ThinkWatch Lite are described in docs/server.md.
Verifying a download
A .sha256 file holds the SHA-256 of the file followed by its name. With both files in the current directory, on Linux:
sha256sum -c twcore-x86_64-unknown-linux-gnu.tar.gz.sha256On macOS:
shasum -a 256 -c twcore-aarch64-apple-darwin.sha256On Windows, in PowerShell, the following prints True when the binary matches:
(Get-FileHash .\twcore-x86_64-pc-windows-msvc.exe).Hash -eq (Get-Content .\twcore-x86_64-pc-windows-msvc.exe.sha256).Split()[0]The install script and twcore upgrade check the SHA-256 themselves.
What's Changed
- Read a session as a conversation: GET /sessions/{id}/transcript by @fylorn in #251
- Store bodies with secrets replaced; keep 4 MiB of each answer; 5 GiB of bodies by default by @fylorn in #252
- Record an error the upstream answered as a failed request; TurnView carries the status by @fylorn in #263
- Bump rustls to 0.23.45 (RUSTSEC-2026-0285) by @fylorn in #265
- Unify the three guards on one shared model (protocol 33, SCHEMA 24) by @fylorn in #268
- Script plugins by @fylorn in #272
- Pre-release fixes for 0.58.0 by @fylorn in #273
- chore: v0.58.0 by @fylorn in #274
- Remove the deepseek-flags default plugin by @fylorn in #275
Full Changelog: v0.57.1...v0.58.0