TTSR Injection Lifecycle
This document covers the current Time Traveling Stream Rules (TTSR) runtime path from rule discovery to stream interruption, retry injection, extension notifications, and session-state handling.
Implementation files
../src/sdk.ts../src/export/ttsr.ts../src/session/agent-session.ts../src/session/session-manager.ts../src/prompts/system/ttsr-interrupt.md../src/capability/index.ts../src/extensibility/extensions/types.ts../src/extensibility/hooks/types.ts../src/extensibility/custom-tools/types.ts../src/modes/controllers/event-controller.ts
1. Discovery feed and rule registration
At session creation, createAgentSession() loads discovered rules, constructs a TtsrManager, and buckets rules through bucketRules(...):
const ttsrSettings = settings.getGroup("ttsr");
const ttsrManager = new TtsrManager(ttsrSettings);
const rulesResult = await loadCapability<Rule>(ruleCapability.id, { cwd });
const { rulebookRules, alwaysApplyRules } = bucketRules(
rulesResult.items,
ttsrManager,
{
builtinRules: ttsrSettings.builtinRules,
disabledRules: ttsrSettings.disabledRules,
},
);
bucketRules(...) drops names listed in ttsr.disabledRules, drops embedded builtin-defaults rules when ttsr.builtinRules === false, registers accepted TTSR rules, and then routes the remaining rules to always-apply/rulebook buckets.
Pre-registration dedupe behavior
loadCapability("rules") deduplicates by rule.name with first-wins semantics (higher provider priority first). Shadowed duplicates are removed before TTSR registration.
TtsrManager.addRule() behavior
Registration is skipped when:
rule.conditionis absent or all condition regexes fail to compile- a rule with the same
rule.namewas already registered in this manager - the rule scope excludes all monitored streams
Invalid regex conditions and unreachable scopes are logged as warnings and ignored; session startup continues. If a TTSR rule defines globs, those globs are compiled as a global file-path gate for matching.
Setting caveat
TtsrSettings.enabled is loaded into the manager but is not currently checked in runtime gating. If TTSR rules exist, matching still runs.
2. Streaming monitor lifecycle
TTSR detection runs inside AgentSession.#handleAgentEvent.
Turn start
On turn_start, the stream buffer is reset:
ttsrManager.resetBuffer()
During stream (message_update)
When assistant updates arrive and rules exist:
- monitor
text_delta,thinking_delta, andtoolcall_delta - append delta into a source/tool scoped manager buffer
- call
checkDelta(delta, matchContext)
checkDelta() iterates registered rules and returns all matching rules that pass scope, global path-glob, condition, and repeat policy checks.
3. Trigger decision and immediate abort path
When one or more rules match and at least one matched rule allows interruption:
- Matched rules are deduplicated into
#pendingTtsrInjections. #ttsrAbortPending = trueand a TTSR resume gate is created.agent.abort()is called immediately.ttsr_triggeredevent is emitted asynchronously (fire-and-forget).- retry work is scheduled via the post-prompt task scheduler with a 50ms delay.
Abort is not blocked on extension callbacks.
4. Retry scheduling, context mode, and reminder injection
After the 50ms timeout:
#ttsrAbortPending = false- read
ttsrManager.getSettings().contextMode - if
contextMode === "discard", drop the targeted partial assistant output withagent.replaceMessages(...slice(0, targetAssistantIndex)) - build injection content from pending rules using
ttsr-interrupt.mdtemplate - append and persist a hidden
custom_message/runtime custom message withcustomType: "ttsr-injection"anddetails.rules - mark those rule names injected, persist a
ttsr_injectionentry, and callagent.continue()to retry generation
Template payload is:
<system-interrupt reason="rule_violation" rule="{{name}}" path="{{path}}">
...
{{content}}
</system-interrupt>
Pending injections are cleared after content generation.
contextMode behavior on partial output
discard: partial/aborted assistant message is removed before retry.keep: partial assistant output remains in conversation state; reminder is appended after it.
Non-interrupting matches
Non-interrupting matches split by matchContext.source:
-
source === "tool"(tool-source match). The rule is bucketed into#perToolTtsrInjections, keyed by the matched tool call'sid. There is no deferred follow-up turn and the stream is not aborted. When the tool actually produces a result, theafterToolCallhook prepends a renderedttsr-tool-reminder.mdblock toctx.result.content(a singletextblock inserted ahead of the tool's own content), and persists attsr_injectionentry with the consumed rule names. The template payload is:<system-reminder reason="rule_violation" rule="{{name}}" path="{{path}}"> ... {{content}} </system-reminder> -
source === "text"/"thinking"(prose-source match). Behavior is unchanged: the rule is queued in#pendingTtsrInjectionsand, after a successful non-error, non-aborted assistant message,AgentSessioninjects the hiddenttsr-injectioncustom message as a follow-up and schedules continuation.
Within a single matching batch, each rule is attached to exactly one sibling tool call — if multiple sibling tool calls would satisfy the same rule, deduplication picks one and the others are left untouched. Multiple distinct rules can still fold onto the same tool call.
Implications for tool authors and transcript readers
- The tool's own
toolResultcontent is preserved verbatim; the reminder is prepended as an additional leading text block. Renderers that assumecontent[0]is the tool's primary output must scan past any block whose text begins with<system-reminder reason="rule_violation"(or filter on the wrapper tag) to find the real payload. - The reminder is in-band on the tool result, not a separate
custom_message/ttsr-injectionentry. Transcript readers looking for non-interrupting TTSR activity on tool-source rules MUST inspect tool results (and the persistedttsr_injectionentry list), not just synthetic injection entries. - A single tool result may carry reminders for several rules concatenated with a blank line between rendered templates.
- If the assistant message ends with
stopReason === "aborted"or"error"before the matched tools run, the pending per-tool buckets are cleared — those rules are not persisted as injected and remain eligible to re-trigger on a future turn (subject to repeat policy).
5. Repeat policy and gap logic
TtsrManager tracks #messageCount and per-rule lastInjectedAt.
repeatMode: "once"
A rule can trigger only once after it has an injection record.
repeatMode: "after-gap"
A rule can re-trigger only when:
messageCount - lastInjectedAt >= repeatGap
messageCount increments on turn_end, so gap is measured in completed turns, not stream chunks.
6. Event emission and extension/hook surfaces
Session event
AgentSessionEvent includes:
{ type: "ttsr_triggered"; rules: Rule[] }
Extension runner
#emitSessionEvent() routes the event to:
- extension listeners (
ExtensionRunner.emit({ type: "ttsr_triggered", rules })) - local session subscribers
Hook and custom-tool typing
- extension API exposes
on("ttsr_triggered", ...) - hook API exposes
on("ttsr_triggered", ...) - custom tools receive
onSession({ reason: "ttsr_triggered", rules })
Interactive-mode rendering difference
Interactive mode uses session.isTtsrAbortPending to suppress showing the aborted assistant stop reason as a visible failure during TTSR interruption, and renders a TtsrNotificationComponent when the event arrives.
7. Persistence and resume state (current implementation)
SessionManager persists injected-rule state:
- entry type:
ttsr_injection - append API:
appendTtsrInjection(ruleNames) - query API:
getInjectedTtsrRules() - context reconstruction includes
SessionContext.injectedTtsrRules
TtsrManager supports restoration via restoreInjected(ruleNames).
Current wiring status
In the current runtime path:
- interrupted injections append a hidden
custom_messagewithcustomType: "ttsr-injection"and append attsr_injectionentry viaappendTtsrInjection(...) - deferred non-interrupting prose-source injections are marked/persisted when their queued custom message reaches
message_end - non-interrupting tool-source injections are marked at match time and persisted via
appendTtsrInjection(...)from theafterToolCallhook when the matched tool's result is produced createAgentSession()restoresexistingSession.injectedTtsrRulesintottsrManager
Net effect: injected-rule suppression is persisted/restored across session reload/resume for the current branch path.
8. Race boundaries and ordering guarantees
Abort vs retry callback
- abort is synchronous from TTSR handler perspective (
agent.abort()called immediately) - retry is deferred by timer (
50ms) - extension notification is asynchronous and intentionally not awaited before abort/retry scheduling
Multiple matches in same stream window
checkDelta() returns all currently matching eligible rules for that scoped buffer. Pending injections are deduplicated by rule name before injection.
Between abort and continue
During the timer window, state can change (user interruption, mode actions, additional events). The retry call is best-effort: agent.continue().catch(() => {}) swallows follow-up errors.
9. Edge cases summary
- Invalid
conditionregex: skipped with warning; other conditions/rules continue. - Duplicate rule names at capability layer: lower-priority duplicates are shadowed before registration.
- Duplicate names at manager layer: second registration is ignored.
ttsr.disabledRules: listed names are dropped before TTSR registration and are not surfaced through always-apply/rulebook buckets.ttsr.builtinRules: false: embeddedbuiltin-defaultsrules are dropped before TTSR registration; user/project rules still load.globson a TTSR rule require the stream match context to include at least one matching file path.contextMode: "keep": partial violating output can remain in context before reminder retry.interruptMode: "never": prose-source matches queue a deferred hidden injection after a successful assistant message; tool-source matches fold an in-band<system-reminder>into the matched tool call'stoolResultcontent via theafterToolCallhook (no mid-stream abort, no separate follow-up turn).- Tool-source non-interrupting buckets are cleared when the parent assistant message ends with
stopReason === "aborted"or"error", so rules whose target tool never produced a result remain eligible to re-trigger. - Repeat-after-gap depends on turn count increments at
turn_end; mid-turn chunks do not advance gap counters.