one-code-extension 0.5.0 → 0.6.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.
Files changed (95) hide show
  1. package/README.md +1 -1
  2. package/docs/guide/differences-from-claude-code.md +9 -6
  3. package/docs/guide/installation.md +3 -4
  4. package/docs/guide/troubleshooting.md +1 -1
  5. package/extensions/auto-mode/denials.ts +3 -2
  6. package/extensions/background/cron.ts +127 -0
  7. package/extensions/background/index.ts +135 -47
  8. package/extensions/background/loop-fire.ts +39 -2
  9. package/extensions/background/switch-guard.ts +10 -6
  10. package/extensions/background/wakeup.ts +21 -2
  11. package/extensions/bash/index.ts +8 -30
  12. package/extensions/btw/index.ts +32 -18
  13. package/extensions/cache-marks/index.ts +42 -0
  14. package/extensions/claude-context/index.ts +157 -172
  15. package/extensions/compaction/index.ts +11 -8
  16. package/extensions/context-budget/budget.ts +16 -2
  17. package/extensions/context-budget/index.ts +15 -14
  18. package/extensions/context-management/index.ts +11 -28
  19. package/extensions/effort/index.ts +70 -7
  20. package/extensions/file-tracker/index.ts +111 -44
  21. package/extensions/file-tracker/replay.ts +76 -49
  22. package/extensions/file-tracker/restore.ts +12 -9
  23. package/extensions/file-tracker/tracker.ts +14 -1
  24. package/extensions/hooks/index.ts +60 -76
  25. package/extensions/hooks/prompt-context.ts +59 -0
  26. package/extensions/hooks/trust.ts +13 -7
  27. package/extensions/ledger/index.ts +46 -0
  28. package/extensions/ledger/wire.ts +154 -0
  29. package/extensions/lib/agent-cron.ts +17 -1
  30. package/extensions/lib/agent-loader.ts +3 -2
  31. package/extensions/lib/anthropic-auth.ts +98 -0
  32. package/extensions/lib/child-extensions.ts +14 -4
  33. package/extensions/lib/context-facts.ts +75 -56
  34. package/extensions/lib/covered-messages.ts +23 -0
  35. package/extensions/lib/deferred.ts +152 -12
  36. package/extensions/lib/ledger/fold-memo.ts +49 -0
  37. package/extensions/lib/ledger/fold.ts +265 -0
  38. package/extensions/lib/ledger/layout.ts +72 -0
  39. package/extensions/lib/ledger/plan.ts +102 -0
  40. package/extensions/lib/ledger/records.ts +276 -0
  41. package/extensions/lib/ledger/state.ts +47 -0
  42. package/extensions/lib/ledger/validate.ts +141 -0
  43. package/extensions/lib/ledger/wire.ts +632 -0
  44. package/extensions/lib/local-command.ts +24 -6
  45. package/extensions/lib/model-catalog/huggingface.json +5 -1
  46. package/extensions/lib/model-catalog/models-dev.json +10535 -10299
  47. package/extensions/lib/model-catalog/openrouter.json +191 -393
  48. package/extensions/lib/notifications.ts +57 -17
  49. package/extensions/lib/pi-version.ts +25 -8
  50. package/extensions/lib/plan-mode-channels.ts +29 -7
  51. package/extensions/lib/previous-mark.ts +101 -0
  52. package/extensions/lib/process-state.ts +48 -0
  53. package/extensions/lib/process-tree.ts +3 -2
  54. package/extensions/lib/reminders.ts +327 -621
  55. package/extensions/lib/request-replay.ts +186 -78
  56. package/extensions/lib/side-call.ts +11 -0
  57. package/extensions/lib/subagent-handback.ts +0 -1
  58. package/extensions/lib/tool-additions.ts +2 -22
  59. package/extensions/lib/turn-budget-layout.ts +18 -2
  60. package/extensions/lsp/index.ts +6 -8
  61. package/extensions/mcp/announce.ts +63 -98
  62. package/extensions/mcp/index.ts +51 -90
  63. package/extensions/mcp/trust.ts +13 -6
  64. package/extensions/model-default/index.ts +86 -19
  65. package/extensions/permissions/index.ts +157 -48
  66. package/extensions/permissions/modes.ts +5 -0
  67. package/extensions/plan-mode/index.ts +142 -45
  68. package/extensions/plan-mode/run-state.ts +26 -0
  69. package/extensions/plugins/index.ts +4 -1
  70. package/extensions/powershell/index.ts +2 -0
  71. package/extensions/recap/index.ts +11 -14
  72. package/extensions/search-tools/index.ts +2 -0
  73. package/extensions/skill/announce.ts +30 -0
  74. package/extensions/skill/index.ts +65 -48
  75. package/extensions/skill/invoke.ts +0 -67
  76. package/extensions/subagents/agents.ts +30 -8
  77. package/extensions/subagents/fork-cache.ts +9 -3
  78. package/extensions/subagents/index.ts +180 -107
  79. package/extensions/subagents/outcome.ts +3 -0
  80. package/extensions/subagents/parent-mode.ts +50 -0
  81. package/extensions/subagents/pending-claim.ts +18 -1
  82. package/extensions/subagents/runner.ts +29 -2
  83. package/extensions/subagents/runs.ts +5 -0
  84. package/extensions/system-prompt/index.ts +40 -6
  85. package/extensions/system-prompt/template.ts +10 -1
  86. package/extensions/system-reminder/index.ts +174 -202
  87. package/extensions/tool-search/index.ts +241 -167
  88. package/extensions/workflow/agent-session.ts +4 -1
  89. package/extensions/workflow/index.ts +16 -4
  90. package/extensions/workflow/run-manager.ts +4 -0
  91. package/extensions/worktree/index.ts +50 -14
  92. package/package.json +5 -2
  93. package/extensions/lib/context-stack.ts +0 -81
  94. package/extensions/subagents/announce.ts +0 -69
  95. package/extensions/tool-search/announce.ts +0 -58
@@ -1,90 +1,77 @@
1
1
  /**
2
- * System-reminder queue — the steering mechanism shared by all One Code
3
- * extensions. Reminders are injected transiently into the outgoing request via
4
- * pi's `context` event. Context and sticky snapshots are stored as hidden custom
5
- * entries by the owner, never as rendered transcript messages.
2
+ * System reminders: the steering blocks One Code adds to a request, rendered
3
+ * once and then kept as sent (working-docs/features/lifecycle-grid-hunt/design.md
4
+ * section 5). The ledger (extensions/ledger) sends through pi only the
5
+ * messages after the last request's coverage, the tail, and takes everything
6
+ * above it from that request's record. So a reminder is rendered only onto the
7
+ * tail, rides the request that first carried it, and stays in the record
8
+ * byte for byte; nothing here is restored or re-rendered later.
6
9
  *
7
10
  * Cross-extension contract: emit on the event bus channel
8
- * `one-code:system-reminder` with `{ text, scope?, placement? }` to enqueue from
9
- * any extension (including third-party ones). The queue instance itself lives
10
- * in the system-reminder extension; do not import one from here (jiti gives
11
- * every extension its own module instance, so a shared export steers nothing).
11
+ * `one-code:system-reminder` with `{ text, placement? }`. The live
12
+ * state lives in the system-reminder extension; do not import one from here
13
+ * (jiti gives every extension its own module instance, so a shared export
14
+ * steers nothing).
12
15
  *
13
- * Three placements, chosen by what kind of fact the reminder is:
16
+ * Four placements, chosen by what kind of fact the reminder is:
14
17
  *
15
- * - `first-prepend` — SESSION CONTEXT that never changes mid-session (deferred
16
- * tools, agent catalog, MCP instructions, skills, `# claudeMd`). Front of the
17
- * FIRST user message, before the user's text, ordered by `order`, exactly
18
- * Claude Code's first-message stack. Byte-stable, so the cached prefix holds.
19
- * - `sticky-append` — SESSION STATE that switches on and off (auto mode, plan
20
- * mode, a worktree session, ultracode). Appended to EVERY user message
21
- * stamped after the state switched on (`since`). Mid-turn it first rides the
22
- * switching call's tool result (`toolCallId` → `tailPin`), or the first
23
- * carrier after the switch, never the already-cached turn opener. That pin
24
- * and every later user carrier keep identical bytes. Switching off CLOSES
25
- * the lifetime (`until`) and freezes its actual user carriers (`userPins`),
26
- * so queued input cannot acquire past state. It never removes blocks from
27
- * history. Re-entry opens a separate lifetime. Closed lifetimes
28
- * are persisted alongside open ones and dropped only when all their carriers
29
- * leave the context (compaction/fork). They never migrate onto a compaction
30
- * summary; retained messages still carry their historical blocks. Legacy
31
- * `opener` anchors are honored on resume, but never created for new states.
32
- * - `last-append` — ONE-SHOT STEERING about what just happened (a deferred tool
33
- * miss, a file changed under the model, a mode change). Delivered where the
34
- * model reads next, and then KEPT there so the message never changes again:
35
- * a one-shot pending when a tool result is stored is written INTO that result
36
- * (`takeOneShots` from the `tool_result` hook — Claude Code persists its
37
- * mid-turn reminders the same way, so the transcript carries them and no
38
- * request ever re-caches the result); a one-shot still pending when a request
39
- * goes out is `pin`ned to the request's tail (the trailing tool result by call
40
- * id, else the last user message by timestamp) and re-attached to that same
41
- * message on every later request, byte-identical. Pins are persisted with
42
- * sticky lifetimes so a resume also preserves the switch announcements.
43
- * - `user-prepend` — LOCAL-COMMAND BREADCRUMBS (`/clear`, `/model`, a panel
44
- * command): what Claude Code shows the model when the user ran a slash
45
- * command. Bare text blocks placed BEFORE the user's text on the next user
46
- * message that opens a request (after the `first-prepend` stack on message
47
- * one), then pinned there like a delivered one-shot. They never ride a tool
48
- * result — a command run mid-turn waits for the next prompt. The caveat
49
- * block is queued `once` per pending run (a second command joins the first
50
- * caveat); the command and stdout blocks are never collapsed.
51
- * `lib/local-command.ts` builds the blocks.
18
+ * - `first-prepend`: SESSION CONTEXT (environment, deferred tools, agent
19
+ * catalog, MCP instructions, skills, `# claudeMd`). Live state, kept current
20
+ * by its owner; rendered only on a request that opens an epoch, in front of
21
+ * the first user message and ordered by `order`: Claude Code's first-message
22
+ * stack. Inside an epoch message 1 is the record, so a change is not
23
+ * rendered; its owner announces the difference at render time
24
+ * (RENDER_CHANNEL: live minus what the epoch told).
25
+ * - `sticky-append`: SESSION STATE that switches on and off (auto mode, plan
26
+ * mode, a worktree session, ultracode). Appended to every user message of
27
+ * the tail stamped at or after the state switched on (`since`). Switched on
28
+ * mid-turn, it first rides the switching call's result (`toolCallId`), or
29
+ * the first result after the switch, never the already-sent turn opener.
30
+ * Removing it closes it: later messages stop carrying it, and the ones
31
+ * already sent keep it in the record.
32
+ * - `last-append`: ONE-SHOT steering about what just happened (a deferred
33
+ * tool miss, a file changed under the model, a mode change). Persisted when
34
+ * emitted as a `one-code:pending` entry, so it survives `/reload` and quit
35
+ * and is gone with an abandoned `/tree` branch; delivered on the tail of the
36
+ * next request (the call's own result when `toolCallId` names one in the
37
+ * tail, the prompt's own message when `promptTimestamp` names one, else
38
+ * the trailing tool result, else the last user message), and
39
+ * recorded as consumed by that request.
40
+ * - `user-prepend`: LOCAL-COMMAND BREADCRUMBS (`/clear`, `/model`, a panel
41
+ * command): pending like a one-shot, but placed only before the text of a
42
+ * prompt that opens a request; mid-turn it waits. The caveat block is
43
+ * queued `once` per run (a second command joins the first caveat); the
44
+ * command and stdout blocks are never collapsed. `lib/local-command.ts`
45
+ * builds them.
52
46
  *
53
- * Persisted or pinned is decided by WHEN the one-shot is emitted, which is a
54
- * load-order rule: system-reminder's `tool_result` hook takes the one-shots
55
- * pending at that moment, so only an extension whose own `tool_result` handler
56
- * runs BEFORE it (listed before `system-reminder` in `package.json`
57
- * `pi.extensions`, like `context-budget`) or that emits from `tool_call` gets
58
- * its block written into the result. Emitting from a later `tool_result`
59
- * handler, or from `tool_execution_end` (pi runs it AFTER the `tool_result`
60
- * hooks, findings §3), lands the block on the same result but as a pin. Same
61
- * wording on the wire, so this only matters for `--resume` and for ordering
62
- * after the `<total_tokens>` line.
63
- *
64
- * A compaction summary counts as a user message for anchoring (pi renders it as
65
- * one), so the context stack survives a compaction that left no user turn. So
66
- * does a `custom` harness message (task notification, wakeup, hook context):
67
- * pi sends it as a user message, and a turn it opens has no other user turn.
68
- *
69
- * Retry safety falls out of this: pi re-runs the `context` transform per LLM
70
- * attempt, and both a persisted and a pinned one-shot are present on every
71
- * attempt by construction.
47
+ * A compaction summary counts as a user message for the stack (pi renders it
48
+ * as one), and so does a `custom` harness message (task notification, wakeup,
49
+ * hook context): pi sends it as a user message, and a turn it opens has no
50
+ * other user turn.
72
51
  */
73
52
 
74
53
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
75
54
  import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
55
+ import type { BlockFact, FileVersion, ToldFact } from "./ledger/records.ts";
56
+ import { contentHash } from "./ledger/wire.ts";
57
+ import type { MovedBlock } from "./system-role.ts";
76
58
 
77
59
  export const REMINDER_CHANNEL = "one-code:system-reminder";
78
60
 
79
- /** "next-turn" fires once on the next LLM call; "every-turn" fires on every call until removed. */
80
- export type ReminderScope = "next-turn" | "every-turn";
61
+ /**
62
+ * Emitted by the system-reminder extension once per request, while it renders
63
+ * the tail (a `RenderRequest`). An owner of a capability block subscribes and
64
+ * adds the blocks this request needs from live state and what the epoch has
65
+ * told: on a request inside an epoch, the names live now that the epoch has
66
+ * not told yet. Computed when the request goes out, never when a change is
67
+ * queued, so a capability that came and went before the request is never
68
+ * announced.
69
+ */
70
+ export const RENDER_CHANNEL = "one-code:render";
81
71
 
82
72
  /** See the module header for which placement fits which kind of reminder. */
83
73
  export type ReminderPlacement = "first-prepend" | "sticky-append" | "last-append" | "user-prepend";
84
74
 
85
- /** Where a delivered one-shot stays: a tool result (by call id) or a user turn (by timestamp). */
86
- export type PinAnchor = { kind: "toolResult"; toolCallId: string } | { kind: "user"; timestamp: number };
87
-
88
75
  /**
89
76
  * `order` values for the `first-prepend` context stack, matching Claude Code's
90
77
  * fixed sequence on the first user message: environment → model line → deferred
@@ -133,533 +120,337 @@ export function movesToSystemRole(order: number): boolean {
133
120
  return order < CONTEXT_ORDER.claudeMd || order === CONTEXT_ORDER.date;
134
121
  }
135
122
 
136
- /** A drained reminder with everything the injector needs to place it. */
137
- export interface ReminderEntry {
138
- /** Present in session snapshots; not rendered into the request. */
139
- key?: string;
140
- text: string;
141
- placement: ReminderPlacement;
142
- order: number;
143
- /**
144
- * Literal text appended AFTER the closing `</system-reminder>` tag. Claude
145
- * Code's `# claudeMd` block ends `</system-reminder>\n\n` on the wire; this
146
- * reproduces that byte-for-byte. Defaults to "".
147
- */
148
- suffix?: string;
149
- /**
150
- * `sticky-append` only: when the state switched on. Every user message
151
- * stamped at or after it carries the block while open. Closing freezes the
152
- * actual carriers in `userPins`; legacy snapshots use `until` as the end.
153
- */
154
- since?: number;
155
- /** `sticky-append` only: exclusive end of a closed lifetime; absent while active. */
156
- until?: number;
157
- /** Actual user carriers of the last request; frozen on close, so queued input cannot acquire old state. */
158
- userPins?: number[];
159
- /** Pending sticky activation belongs to this call, not another result in its parallel batch. */
160
- toolCallId?: string;
161
- /**
162
- * Legacy `sticky-append` anchor from older snapshots. Honored to keep their
163
- * bytes stable on resume; new lifetimes use null, never an earlier opener.
164
- */
165
- opener?: number | null;
166
- /**
167
- * `sticky-append` only: its first mid-turn tool result, or the tail of a
168
- * request without any user carrier (overflow compaction). Once chosen it
169
- * stays on that message, even after later user turns arrive or state closes.
170
- */
171
- tailPin?: PinAnchor;
172
- /** Set on a pinned one-shot: the exact message it rides on every request. */
173
- pin?: PinAnchor;
174
- /**
175
- * Emit `text` as-is instead of inside a `<system-reminder>` frame — for the
176
- * few Claude Code blocks that ride bare, like `<total_tokens>` after a tool
177
- * result. Defaults to false.
178
- */
179
- raw?: boolean;
180
- /**
181
- * `first-prepend` only: sent only inside the mid-conversation system
182
- * message, never on the user message of a model without one (Claude Code's
183
- * auto-mode note).
184
- */
185
- systemRoleOnly?: boolean;
186
- /**
187
- * `sticky-append` only: never on the message that carries the context
188
- * stack, which already says the same (the first prompt's `<total_tokens>`).
189
- */
190
- skipStackCarrier?: boolean;
191
- }
192
-
193
123
  export interface ReminderPayload {
194
124
  text?: string;
195
- scope?: ReminderScope;
196
- /** Key so a reminder can be replaced (next-turn) or replaced/removed (every-turn). */
125
+ /** Key so a reminder can be replaced (a pending one-shot, a live block) or removed (a live block). */
197
126
  key?: string;
198
- /** Close a sticky lifetime under `key`; remove other every-turn reminders. */
127
+ /** Remove the live block or close the sticky state under `key`. */
199
128
  remove?: boolean;
200
129
  /** Where in the message stack this reminder lands. Defaults to `last-append`. */
201
130
  placement?: ReminderPlacement;
202
131
  /** Sort key among `first-prepend` reminders (Claude Code order). Defaults to 0. */
203
132
  order?: number;
204
- /** Literal text appended after the closing `</system-reminder>` tag. Defaults to "". */
205
- suffix?: string;
206
133
  /** Emit the text bare, with no `<system-reminder>` frame. */
207
134
  raw?: boolean;
208
- /** `first-prepend` only: sent only inside the mid-conversation system message (see ReminderEntry). */
135
+ /** `first-prepend` only: sent only inside the mid-conversation system message, never on a user message. */
209
136
  systemRoleOnly?: boolean;
210
- /** `sticky-append` only: never on the message that carries the context stack (see ReminderEntry). */
137
+ /** `sticky-append` only: never on the message that carries the context stack, which already says the same. */
211
138
  skipStackCarrier?: boolean;
212
- /** `user-prepend` only: skip when the same text is already pending (the shared caveat). */
139
+ /** `user-prepend` only: placed once per request, however many are pending (the shared caveat). */
213
140
  once?: boolean;
141
+ /** `user-prepend` only: on a request that does not open with a prompt (mid-turn, an overflow retry), the tail carries it instead. */
142
+ orTail?: boolean;
143
+ /** A one-shot dropped, undelivered, at a later compaction (a post-compaction restore). */
144
+ untilCompaction?: boolean;
145
+ /** A one-shot only: the files whose whole content it shows, recorded on its pending entry (`PendingRecordData.observations`). */
146
+ observations?: FileVersion[];
147
+ /** A one-shot only: the changed file versions it names, recorded on its pending entry (`PendingRecordData.notified`). */
148
+ notified?: FileVersion[];
214
149
  /**
215
- * `sticky-append` only: anchor the block from this timestamp instead of now —
216
- * `0` puts it on every user message in the session, including ones a resume
217
- * brought back (the `<total_tokens>` line rides every user message in CC).
150
+ * `sticky-append` only: the state is on from this timestamp instead of now.
151
+ * `0` puts it on every user message (the `<total_tokens>` line rides every
152
+ * user message in Claude Code).
218
153
  */
219
154
  since?: number;
220
155
  /**
221
- * The tool call whose result this reminder belongs to. For `last-append`,
222
- * only that result's `tool_result` hook takes the one-shot. For a new sticky
223
- * lifetime, that result is its first pin. Both avoid attaching a switch or
224
- * countdown to another call in a parallel batch.
156
+ * The tool call whose result this reminder belongs to. A one-shot rides that
157
+ * result when the request carries it; a sticky state rides it first. Both
158
+ * avoid attaching a switch or countdown to another call in a parallel batch.
225
159
  */
226
160
  toolCallId?: string;
161
+ /**
162
+ * A one-shot only: the timestamp of the user message it belongs to (a
163
+ * prompt's hook context). It rides that message when the request carries
164
+ * it, so a prompt whose request failed keeps its own context when the
165
+ * next prompt resends it.
166
+ */
167
+ promptTimestamp?: number;
168
+ /** `first-prepend` only: what the block tells the model, recorded when an epoch sends it. */
169
+ facts?: ToldFact[];
227
170
  }
228
171
 
229
- interface StoredReminder extends ReminderEntry {
230
- key?: string;
172
+ /** The fact that the whole-text block `key` told `value` (a text, or a snapshot such as a git status). */
173
+ export function toldBlockFact(key: string, value: unknown): BlockFact {
174
+ return { kind: "block", key, hash: contentHash(value) };
231
175
  }
232
176
 
233
- type EnqueueOptions = {
234
- scope?: ReminderScope;
235
- key?: string;
236
- placement?: ReminderPlacement;
237
- order?: number;
238
- suffix?: string;
177
+ /** A live `first-prepend` block. */
178
+ export interface StackEntry {
179
+ key: string;
180
+ text: string;
181
+ order: number;
239
182
  raw?: boolean;
240
183
  systemRoleOnly?: boolean;
241
- skipStackCarrier?: boolean;
242
- /** Test seam / explicit anchor for `sticky-append`; defaults to now. */
243
- since?: number;
244
- /** `user-prepend` only: skip when the same text is already pending. */
245
- once?: boolean;
246
- /** The switching tool result, or the hook that takes a one-shot (see ReminderPayload). */
247
- toolCallId?: string;
248
- };
249
-
250
- export class ReminderQueue {
251
- private nextTurn: StoredReminder[] = [];
252
- /** Delivered one-shots, each fixed to the message it first rode (see `pin`). */
253
- private pinned: StoredReminder[] = [];
254
- private everyTurn = new Map<string, StoredReminder>();
255
- /** Open and closed lifetimes in original insertion order, independent of live keys. */
256
- private sticky: StoredReminder[] = [];
257
- private readonly now: () => number;
258
- /** Only restored keys are protected; fresh sessions retain their existing emitter behavior. */
259
- private restoredKeys = new Set<string>();
260
- private pendingCapabilities = new Set<string>();
261
- private changes = 0;
262
-
263
- constructor(now: () => number = Date.now) {
264
- this.now = now;
265
- }
266
-
267
- /**
268
- * Moves on every change to what the queue holds (a drain only when it
269
- * re-anchored, pinned or dropped something), so the owner can skip
270
- * serializing a snapshot that cannot have changed.
271
- */
272
- get version(): number {
273
- return this.changes;
274
- }
275
-
276
- enqueue(text: string, opts?: EnqueueOptions): void {
277
- if (!text.trim()) return;
278
- const placement = opts?.placement ?? "last-append";
279
- if (placement === "first-prepend" && opts?.key) {
280
- if (this.restoredKeys.has(opts.key)) return;
281
- this.pendingCapabilities.delete(opts.key);
282
- }
283
- const entry: StoredReminder = {
284
- text,
285
- placement,
286
- order: opts?.order ?? 0,
287
- suffix: opts?.suffix,
288
- key: opts?.key,
289
- raw: opts?.raw,
290
- toolCallId: opts?.toolCallId,
291
- };
292
- if (opts?.systemRoleOnly) entry.systemRoleOnly = true;
293
- if (opts?.skipStackCarrier) entry.skipStackCarrier = true;
294
- const key = opts?.key ?? text;
295
- const previous = this.everyTurn.get(key);
296
- if (placement === "sticky-append") {
297
- // Re-emitting an unchanged standing fact keeps both its anchors and its
298
- // position among other blocks. A changed fact opens a new lifetime.
299
- const same = previous?.placement === placement && previous.text === text &&
300
- previous.suffix === entry.suffix && previous.raw === entry.raw &&
301
- previous.skipStackCarrier === entry.skipStackCarrier && previous.order === entry.order ? previous : undefined;
302
- entry.since = opts?.since ?? same?.since ?? this.now();
303
- entry.userPins = [];
304
- if (same && same.since === entry.since && opts?.scope === "every-turn") return;
305
- }
306
- this.changes++;
307
- if (opts?.scope === "every-turn") {
308
- if (previous?.placement === "sticky-append") this.remove(key);
309
- this.everyTurn.set(key, entry);
310
- if (placement === "sticky-append") {
311
- entry.key = key;
312
- this.sticky.push(entry);
313
- }
314
- return;
315
- }
316
- // A breadcrumb run shares one caveat block (`once`): the same text pending
317
- // twice would show the model two identical blocks in a row. Only the
318
- // announcer's caveat asks for this — two commands with the same (empty)
319
- // stdout are two commands, and both stay.
320
- if (placement === "user-prepend" && opts?.once && this.nextTurn.some((r) => r.placement === placement && r.text === text)) return;
321
- // A keyed next-turn reminder replaces its predecessor, so a rapidly
322
- // re-emitted state change (cycling permission modes) announces only
323
- // where it settled.
324
- if (opts?.key) this.nextTurn = this.nextTurn.filter((r) => r.key !== opts.key);
325
- this.nextTurn.push(entry);
326
- }
327
-
328
- remove(key: string): void {
329
- this.changes++;
330
- if (!this.restoredKeys.has(key)) {
331
- const entry = this.everyTurn.get(key);
332
- if (entry?.placement === "sticky-append") entry.until = this.now();
333
- this.everyTurn.delete(key);
334
- }
335
- this.pendingCapabilities.delete(key);
336
- }
337
-
338
- /** Seed before startup emitters. Earlier hooks (context-budget) may already have emitted. */
339
- restore(stack: readonly ReminderEntry[], sticky: readonly ReminderEntry[], frozenKeys: ReadonlySet<string>, liveKeys: ReadonlySet<string> = new Set(), pinned: readonly ReminderEntry[] = []): void {
340
- this.changes++;
341
- const early = [...this.everyTurn.values()];
342
- this.everyTurn.clear();
343
- this.sticky = [];
344
- this.pinned = structuredClone([...pinned]);
345
- this.restoredKeys = new Set(frozenKeys);
346
- this.pendingCapabilities = new Set(stack.flatMap((entry) => entry.key && liveKeys.has(entry.key) ? [entry.key] : []));
347
- for (const source of [...stack, ...sticky]) {
348
- const entry = structuredClone(source);
349
- if (entry.placement === "sticky-append") this.sticky.push(entry);
350
- if (entry.until === undefined) this.everyTurn.set(entry.key ?? entry.text, entry);
351
- }
352
- for (const entry of early) this.enqueue(entry.text, { ...entry, scope: "every-turn" });
353
- }
354
-
355
- /**
356
- * A session start with no snapshot (`/clear` after a resume): the previous
357
- * session's resume locks must not keep the new session's emitters out.
358
- */
359
- releaseRestore(): void {
360
- this.restoredKeys.clear();
361
- this.pendingCapabilities.clear();
362
- }
184
+ facts?: ToldFact[];
185
+ }
363
186
 
364
- /** The first request has seen all startup/turn emitters. An absent capability is gone. */
365
- finishRestore(): void {
366
- if (this.pendingCapabilities.size > 0) this.changes++;
367
- for (const key of this.pendingCapabilities) this.everyTurn.delete(key);
368
- this.pendingCapabilities.clear();
369
- }
187
+ /** A live `sticky-append` state. */
188
+ export interface StickyEntry {
189
+ key: string;
190
+ text: string;
191
+ raw?: boolean;
192
+ /** When it switched on: user messages stamped at or after it carry it. */
193
+ since: number;
194
+ toolCallId?: string;
195
+ skipStackCarrier?: boolean;
196
+ }
370
197
 
371
- /** A compaction replaces these facts atomically, bypassing only their resume locks. */
372
- replaceFirstPrepend(keys: readonly string[], entries: readonly ReminderEntry[]): void {
373
- this.changes++;
374
- const replaced = new Set(keys);
375
- for (const key of replaced) this.everyTurn.delete(key);
376
- this.nextTurn = this.nextTurn.filter((entry) => !entry.key || !replaced.has(entry.key));
377
- for (const entry of entries) {
378
- if (entry.key && replaced.has(entry.key) && entry.placement === "first-prepend") {
379
- this.everyTurn.set(entry.key, structuredClone(entry));
380
- }
381
- }
382
- }
198
+ /** A sticky state as a render places it: `delivered` once an answered request in the epoch carried it (its `stickyFact` is told), so a tool-result pin is only for its first delivery. */
199
+ export type StickyToPlace = StickyEntry & { delivered: boolean };
383
200
 
384
- /**
385
- * A `/tree` switch to a branch whose last snapshot is `snapshot`: its
386
- * session facts (`factKeys`, an absent one included), its sticky lifetimes
387
- * and its pins come back, so its messages keep the bytes they were sent
388
- * with. Live state stays live: other first-prepend blocks keep their
389
- * current text, and a lifetime open on the branch stays open only while its
390
- * owner still holds it, else it closes on its recorded carriers. A live
391
- * lifetime the branch shows closed goes on as a new one from now. What the
392
- * branch left behind put here stays until a drain finds its carriers gone.
393
- */
394
- restoreBranch(snapshot: { stack: readonly ReminderEntry[]; sticky: readonly ReminderEntry[]; pinned?: readonly ReminderEntry[] }, factKeys: readonly string[]): void {
395
- this.replaceFirstPrepend(factKeys, snapshot.stack);
396
- const same = (a: ReminderEntry, b: ReminderEntry) => a.key === b.key && a.text === b.text && a.since === b.since;
397
- const restored: StoredReminder[] = structuredClone([...snapshot.sticky]);
398
- const now = this.now();
399
- for (const entry of restored) {
400
- if (entry.until !== undefined) continue;
401
- const live = this.sticky.find((current) => current.until === undefined && same(current, entry));
402
- if (live?.key !== undefined && this.everyTurn.get(live.key) === live) this.everyTurn.set(live.key, entry);
403
- else entry.until = now;
404
- }
405
- this.sticky = [...restored, ...this.sticky.filter((current) => !restored.some((entry) => same(entry, current)))];
406
- // The branch's carriers keep their closed copy of a lifetime still live here.
407
- for (const [key, live] of this.everyTurn) {
408
- if (live.placement !== "sticky-append" || live.until !== undefined || this.sticky.includes(live)) continue;
409
- const { toolCallId: _call, tailPin: _pin, opener: _opener, ...state } = live;
410
- const reopened: StoredReminder = { ...state, key, since: now, userPins: [] };
411
- this.everyTurn.set(key, reopened);
412
- this.sticky.push(reopened);
413
- }
414
- const pinned: StoredReminder[] = structuredClone([...(snapshot.pinned ?? [])]);
415
- const known = new Set(pinned.map((entry) => JSON.stringify(strip(entry))));
416
- this.pinned = [...pinned, ...this.pinned.filter((entry) => !known.has(JSON.stringify(strip(entry))))];
417
- this.changes++;
418
- }
201
+ /** A one-shot to place on this request: a pending entry, or a block an owner rendered for it. */
202
+ export interface OneShot {
203
+ /** The pending entry's id; absent on a render-time block. */
204
+ id?: string;
205
+ text: string;
206
+ raw?: boolean;
207
+ placement: "last-append" | "user-prepend";
208
+ toolCallId?: string;
209
+ promptTimestamp?: number;
210
+ once?: boolean;
211
+ orTail?: boolean;
212
+ facts?: ToldFact[];
213
+ }
419
214
 
420
- /** A new snapshot supersedes undelivered notices about its old facts. Delivered pins stay history. */
421
- cancelPending(keys: readonly string[]): void {
422
- const cancelled = new Set(keys);
423
- const before = this.nextTurn.length;
424
- this.nextTurn = this.nextTurn.filter((entry) => !entry.key || !cancelled.has(entry.key));
425
- if (this.nextTurn.length !== before) this.changes++;
426
- }
215
+ /** What a capability owner adds on RENDER_CHANNEL. */
216
+ export interface RenderedBlock {
217
+ text: string;
218
+ /** What the block tells the model, recorded with the request that carries it. */
219
+ facts?: ToldFact[];
220
+ }
427
221
 
428
- /** Hidden session metadata, preserving keys and insertion order without changing drain's API. */
429
- persistentEntries(placement: "first-prepend" | "sticky-append"): ReminderEntry[] {
430
- return [
431
- ...(placement === "sticky-append" ? this.sticky.map((entry) => ({ ...strip(entry), key: entry.key })) :
432
- [...this.everyTurn.entries()].filter(([, entry]) => entry.placement === placement).map(([key, entry]) => ({ ...strip(entry), key }))),
433
- ...this.nextTurn.filter((entry) => entry.placement === placement).map((entry) => ({ ...strip(entry), key: entry.key })),
434
- ];
435
- }
222
+ /** The RENDER_CHANNEL payload. */
223
+ export interface RenderRequest {
224
+ /** True when the request opens an epoch: the live stack is rendered, so owners add nothing. */
225
+ epoch: boolean;
226
+ /** What the epoch has told the model so far, by fact kind. */
227
+ told: ReadonlyMap<string, ReadonlySet<string>>;
228
+ /** Modes the epoch has told, by key: on or off as last told. */
229
+ modes: ReadonlyMap<string, boolean>;
230
+ /** Whole-text blocks the epoch has told, by key: the content hash of the text last told (`toldBlockFact`). */
231
+ toldBlocks: ReadonlyMap<string, string>;
232
+ /** The request's model. */
233
+ model: { provider?: string; api?: string; id?: string; compat?: unknown } | undefined;
234
+ /** Blocks for the tail of this request. */
235
+ blocks: RenderedBlock[];
236
+ }
436
237
 
437
- /** Delivered steering is history too, especially the notice that closes a sticky state. */
438
- persistentPins(): ReminderEntry[] {
439
- return this.pinned.map(strip);
440
- }
238
+ /**
239
+ * The live reminder state: the `first-prepend` stack and the open sticky
240
+ * states, as their owners last emitted them. One-shots are not held here:
241
+ * they are persisted as pending entries when emitted.
242
+ */
243
+ export class LiveReminders {
244
+ private stack = new Map<string, StackEntry>();
245
+ /** Open states in the order they switched on. */
246
+ private sticky = new Map<string, StickyEntry>();
247
+ private readonly now: () => number;
441
248
 
442
- /**
443
- * Take the pending `last-append` one-shots out of the queue — for the
444
- * `tool_result` hook, which writes them into the stored result. A one-shot
445
- * bound to another call's result stays queued for that result's hook. Other
446
- * placements stay queued for the next request.
447
- */
448
- takeOneShots(toolCallId?: string): ReminderEntry[] {
449
- const { matching, rest } = partition(
450
- this.nextTurn,
451
- (r) => r.placement === "last-append" && (r.toolCallId === undefined || r.toolCallId === toolCallId),
452
- );
453
- this.nextTurn = rest;
454
- if (matching.length > 0) this.changes++;
455
- return matching.map(strip);
249
+ constructor(now: () => number = Date.now) {
250
+ this.now = now;
456
251
  }
457
252
 
458
253
  /**
459
- * Fix the pending one-shots of `placement` to `anchor` — the message they
460
- * ride on this request — so every later request re-attaches them there
461
- * unchanged. Called by the owner at `context` time, after it has decided the
462
- * tail: `last-append` goes on the tail (`tailAnchor`), `user-prepend` only
463
- * on a request that ends in a user-like message (`openingUserAnchor`) —
464
- * mid-turn, breadcrumbs stay queued.
254
+ * Apply one reminder payload. Returns "one-shot" for a `last-append` or
255
+ * `user-prepend` reminder, which the caller persists as a pending entry.
465
256
  */
466
- pin(anchor: PinAnchor, placement: "last-append" | "user-prepend" = "last-append", messages?: readonly AgentMessage[]): void {
467
- const { matching, rest } = partition(this.nextTurn, (r) => r.placement === placement);
468
- for (const entry of matching) {
469
- // A one-shot bound to a call whose result hook already ran (a read's nested
470
- // instructions) rides that call's result, not whichever result ended the batch.
471
- const own = entry.toolCallId !== undefined && messages ? ownResultAnchor(messages, entry.toolCallId) : undefined;
472
- this.pinned.push({ ...entry, pin: own ?? anchor });
257
+ apply(payload: ReminderPayload): "live" | "one-shot" | "ignored" {
258
+ const placement = payload.placement ?? "last-append";
259
+ if (payload.remove) {
260
+ if (!payload.key) return "ignored";
261
+ this.stack.delete(payload.key);
262
+ this.sticky.delete(payload.key);
263
+ return "live";
473
264
  }
474
- this.nextTurn = rest;
475
- if (matching.length > 0) this.changes++;
476
- }
477
-
478
- /**
479
- * Everything to inject on this request: every-turn state, pinned one-shots,
480
- * then whatever is still pending. A `user-prepend` breadcrumb leaves the
481
- * queue only through `pin(anchor, "user-prepend")` (it must land on a user
482
- * message, not a tool result), so an unpinned one stays for the next request. `messages` is the request being built:
483
- * pins whose anchor is no longer in it (compacted away, or left behind on
484
- * another branch) are dropped here, as part of draining, so no caller can
485
- * forget the step. Nothing else is ever evicted: a pin is one small object,
486
- * and removing a pin whose message is still in context would change that
487
- * message and re-cache everything after it — the old "oldest past 400" cap
488
- * did exactly that (CACHE-REVIEW-2026-09-04 M3).
489
- */
490
- drain(messages: AgentMessage[]): ReminderEntry[] {
491
- const locate = pinLocator(messages);
492
- const stackCarrier = messages.findIndex(isUserLike);
493
- let changed = false;
494
- for (const entry of this.sticky) {
495
- if (entry.until !== undefined) continue;
496
- if (entry.opener === undefined) {
497
- entry.opener = null;
498
- changed = true;
499
- }
500
- // Only live states may find a new carrier after compaction. A closed
501
- // state's missing pin must never move to the summary or a later turn.
502
- const tailPin = entry.tailPin;
503
- if (entry.tailPin && locate(entry.tailPin) === -1) entry.tailPin = undefined;
504
- if (entry.tailPin === undefined) entry.tailPin = firstStickyPin(messages, entry);
505
- if (!samePin(tailPin, entry.tailPin)) changed = true;
506
- // Timestamps alone cannot close a lifetime: a steer typed while it was
507
- // active can remain queued until after the switch. Keep only messages
508
- // that actually rode the block, including across a startup-time close.
509
- const userPins = messages.flatMap((m, index) => carriesSticky(m, entry) &&
510
- (!entry.skipStackCarrier || index !== stackCarrier) ? [m.timestamp] : []);
511
- // The same array stays while its carriers do, so its lookup set (`carrierSet`) is reused.
512
- if (!entry.userPins || entry.userPins.length !== userPins.length || entry.userPins.some((stamp, i) => stamp !== userPins[i])) {
513
- entry.userPins = userPins;
514
- changed = true;
515
- }
516
- if ((entry.tailPin || entry.userPins.length > 0) && entry.toolCallId !== undefined) {
517
- delete entry.toolCallId;
518
- changed = true;
519
- }
265
+ const text = payload.text;
266
+ if (typeof text !== "string" || !text.trim()) return "ignored";
267
+ if (placement === "last-append" || placement === "user-prepend") return "one-shot";
268
+ const key = payload.key ?? text;
269
+ if (placement === "first-prepend") {
270
+ const entry: StackEntry = { key, text, order: payload.order ?? 0 };
271
+ if (payload.raw) entry.raw = true;
272
+ if (payload.systemRoleOnly) entry.systemRoleOnly = true;
273
+ if (payload.facts) entry.facts = payload.facts;
274
+ this.stack.set(key, entry);
275
+ return "live";
520
276
  }
521
- const sticky = this.sticky.length;
522
- this.sticky = this.sticky.filter((entry) => entry.until === undefined ||
523
- messages.some((m) => carriesSticky(m, entry)) || (entry.tailPin !== undefined && locate(entry.tailPin) !== -1));
524
- const pinned = this.pinned.length;
525
- if (this.pinned.length > 0) this.pinned = this.pinned.filter((entry) => locate(entry.pin as PinAnchor) !== -1);
526
- const { matching: held, rest: pending } = partition(this.nextTurn, (r) => r.placement === "user-prepend");
527
- this.nextTurn = held;
528
- if (changed || pending.length > 0 || this.sticky.length !== sticky || this.pinned.length !== pinned) this.changes++;
529
- return [...[...this.everyTurn.values()].filter((entry) => entry.placement !== "sticky-append").map(strip),
530
- ...this.sticky.map(strip), ...this.pinned.map(strip), ...pending.map(strip)];
277
+ const previous = this.sticky.get(key);
278
+ const same = previous && previous.text === text && !!previous.raw === !!payload.raw && !!previous.skipStackCarrier === !!payload.skipStackCarrier;
279
+ // Re-emitting an unchanged standing state keeps its start and delivery; a
280
+ // changed one is a new state from now.
281
+ if (same && (payload.since === undefined || payload.since === previous.since)) return "live";
282
+ const entry: StickyEntry = { key, text, since: payload.since ?? this.now() };
283
+ if (payload.raw) entry.raw = true;
284
+ if (payload.skipStackCarrier) entry.skipStackCarrier = true;
285
+ if (payload.toolCallId) entry.toolCallId = payload.toolCallId;
286
+ this.sticky.delete(key);
287
+ this.sticky.set(key, entry);
288
+ return "live";
531
289
  }
532
290
 
533
- get size(): number {
534
- return [...this.everyTurn.values()].filter((entry) => entry.placement !== "sticky-append").length +
535
- this.sticky.length + this.nextTurn.length + this.pinned.length;
291
+ /** The stack in Claude Code's order (`order`, then emission order). */
292
+ stackEntries(): StackEntry[] {
293
+ return [...this.stack.values()].map((entry, i) => ({ entry, i })).sort((a, b) => a.entry.order - b.entry.order || a.i - b.i).map((x) => x.entry);
536
294
  }
537
295
 
538
- /** True when a one-shot of `placement` is waiting to be delivered (the owner decides where). */
539
- hasPending(placement: "last-append" | "user-prepend"): boolean {
540
- return this.nextTurn.some((r) => r.placement === placement);
296
+ stickyEntries(): StickyEntry[] {
297
+ return [...this.sticky.values()];
541
298
  }
299
+ }
542
300
 
543
- /** `hasPending("last-append")` — kept for the existing callers and tests. */
544
- get hasPendingOneShots(): boolean {
545
- return this.hasPending("last-append");
546
- }
301
+ /** The told fact of a sticky state's first delivery: it follows the branch, and a failed request's is revoked. */
302
+ export function stickyFact(entry: Pick<StickyEntry, "key" | "text" | "since">): BlockFact {
303
+ return toldBlockFact(`sticky:${entry.key}`, { since: entry.since, text: entry.text });
547
304
  }
548
305
 
549
- /** One pass: the entries `test` accepts and the rest, in order. */
550
- function partition<T>(items: T[], test: (item: T) => boolean): { matching: T[]; rest: T[] } {
551
- const matching: T[] = [];
552
- const rest: T[] = [];
553
- for (const item of items) (test(item) ? matching : rest).push(item);
554
- return { matching, rest };
306
+ export interface RenderInput {
307
+ /** The request opens an epoch: the stack goes on the first user message. */
308
+ epoch: boolean;
309
+ /** The model takes a mid-conversation system message (`systemRoleOnly` blocks render, `moved` is filled). */
310
+ layout: boolean;
311
+ stack: readonly StackEntry[];
312
+ sticky: readonly StickyToPlace[];
313
+ oneShots: readonly OneShot[];
555
314
  }
556
315
 
557
- function strip(r: StoredReminder): ReminderEntry {
558
- const entry: ReminderEntry = { text: r.text, placement: r.placement, order: r.order, suffix: r.suffix };
559
- if (r.raw) entry.raw = true;
560
- if (r.systemRoleOnly) entry.systemRoleOnly = true;
561
- if (r.skipStackCarrier) entry.skipStackCarrier = true;
562
- if (r.since !== undefined) entry.since = r.since;
563
- if (r.until !== undefined) entry.until = r.until;
564
- if (r.userPins !== undefined) entry.userPins = [...r.userPins];
565
- if (r.placement === "sticky-append" && r.toolCallId !== undefined) entry.toolCallId = r.toolCallId;
566
- if (r.opener !== undefined) entry.opener = r.opener;
567
- if (r.tailPin) entry.tailPin = r.tailPin;
568
- if (r.pin !== undefined) entry.pin = r.pin;
569
- return entry;
316
+ export interface RenderResult {
317
+ messages: AgentMessage[];
318
+ /** The stack blocks that move to the mid-conversation system message (epoch and layout only). */
319
+ moved: MovedBlock[];
320
+ /** True when the stack was placed. */
321
+ stackPlaced: boolean;
322
+ /** The sticky states this request carried. */
323
+ stickyPlaced: string[];
324
+ /** The one-shots this request carried. */
325
+ placed: OneShot[];
570
326
  }
571
327
 
328
+ type ContentBlock = TextContent | ImageContent;
329
+
572
330
  /**
573
- * One pass over `messages` → a lookup from a pin to the index of the message it
574
- * rides (-1 when that message left the context). Built once per request by the
575
- * queue's drain and once by injectReminders, so pin count never multiplies the
576
- * message count on the per-request path.
331
+ * The request's messages (its tail, or all of them on an epoch) with the
332
+ * reminders placed, and what was placed. The input is not mutated. A one-shot
333
+ * that finds no anchor is not placed and stays pending.
577
334
  */
578
- function pinLocator(messages: AgentMessage[]): (pin: PinAnchor) => number {
579
- const byToolCall = new Map<string, number>();
580
- const byTimestamp = new Map<number, number>();
581
- messages.forEach((m, index) => {
582
- if (m.role === "toolResult") {
583
- const id = (m as { toolCallId?: string }).toolCallId;
584
- if (typeof id === "string" && !byToolCall.has(id)) byToolCall.set(id, index);
585
- } else if (isUserLike(m)) {
586
- const stamp = (m as { timestamp?: number }).timestamp;
587
- if (typeof stamp === "number" && !byTimestamp.has(stamp)) byTimestamp.set(stamp, index);
335
+ export function renderReminders(messages: readonly AgentMessage[], input: RenderInput): RenderResult {
336
+ const before = new Map<number, TextContent[]>();
337
+ const after = new Map<number, TextContent[]>();
338
+ const push = (map: Map<number, TextContent[]>, index: number, blocks: TextContent[]) => {
339
+ if (index < 0 || blocks.length === 0) return;
340
+ map.set(index, [...(map.get(index) ?? []), ...blocks]);
341
+ };
342
+ const firstUser = messages.findIndex(isUserLike);
343
+ const lastUser = messages.findLastIndex(isUserLike);
344
+ const last = messages.length - 1;
345
+ // Mid-turn, the request ends in the tool results the model is about to read:
346
+ // a one-shot belongs there. Otherwise on the last user turn.
347
+ const tail = last >= 0 && messages[last].role === "toolResult" ? last : lastUser;
348
+ const stackAt = firstUser === -1 ? tail : firstUser;
349
+
350
+ let stackPlaced = false;
351
+ let moved: MovedBlock[] = [];
352
+ if (input.epoch && stackAt !== -1) {
353
+ const stack = input.stack.filter((entry) => input.layout || !entry.systemRoleOnly);
354
+ push(before, stackAt, stack.map(block));
355
+ stackPlaced = stack.length > 0;
356
+ if (input.layout) {
357
+ moved = stack.filter((entry) => movesToSystemRole(entry.order)).map((entry) => ({ framed: framedReminderText(entry), inner: entry.text }));
588
358
  }
589
- });
590
- return (pin) => (pin.kind === "toolResult" ? byToolCall.get(pin.toolCallId) : byTimestamp.get(pin.timestamp)) ?? -1;
591
- }
592
-
593
- function samePin(a: PinAnchor | undefined, b: PinAnchor | undefined): boolean {
594
- if (!a || !b) return a === b;
595
- return a.kind === "toolResult" ? b.kind === "toolResult" && a.toolCallId === b.toolCallId : b.kind === "user" && a.timestamp === b.timestamp;
596
- }
597
-
598
- /** Each carrier list's lookup set, built once per array: a check runs for every message against every lifetime. */
599
- const carrierSets = new WeakMap<readonly number[], Set<number>>();
600
-
601
- function carrierSet(userPins: readonly number[]): Set<number> {
602
- let set = carrierSets.get(userPins);
603
- if (!set) carrierSets.set(userPins, (set = new Set(userPins)));
604
- return set;
605
- }
359
+ }
606
360
 
607
- /** Closed lifetimes keep only delivered carriers; legacy snapshots retain their timestamp interval. */
608
- function carriesSticky(message: AgentMessage, entry: ReminderEntry): boolean {
609
- if (!isStickyCarrier(message)) return false;
610
- const stamp = (message as { timestamp?: number }).timestamp ?? 0;
611
- if (entry.until !== undefined && entry.userPins !== undefined) return carrierSet(entry.userPins).has(stamp);
612
- return (stamp >= (entry.since ?? 0) && (entry.until === undefined || stamp < entry.until)) ||
613
- (entry.opener !== null && entry.opener !== undefined && stamp === entry.opener);
614
- }
361
+ const stickyPlaced: string[] = [];
362
+ for (const state of input.sticky) {
363
+ const carriers: number[] = [];
364
+ messages.forEach((message, index) => {
365
+ if (!isStickyCarrier(message) || stamp(message) < state.since) return;
366
+ if (state.skipStackCarrier && input.epoch && index === firstUser) return;
367
+ carriers.push(index);
368
+ });
369
+ // The pin is for a state switched on mid-turn; one on from the start
370
+ // (`since: 0`, the constant budget line) rides user messages only.
371
+ if (!state.delivered && state.since > 0) {
372
+ const first = firstResultAfter(messages, state);
373
+ if (first !== -1 && !carriers.includes(first)) carriers.unshift(first);
374
+ }
375
+ if (carriers.length === 0) continue;
376
+ stickyPlaced.push(state.key);
377
+ for (const index of carriers.sort((a, b) => a - b)) push(after, index, [block(state)]);
378
+ }
615
379
 
616
- /** First carrier after activation; never reach back into an earlier user turn. */
617
- function firstStickyPin(messages: AgentMessage[], entry: ReminderEntry): PinAnchor | undefined {
618
- if (entry.until !== undefined) return undefined;
619
- if (entry.toolCallId !== undefined) return ownResultAnchor(messages, entry.toolCallId);
620
- for (const message of messages) {
621
- if (carriesSticky(message, entry)) return undefined;
622
- if (message.role === "toolResult" && message.timestamp >= (entry.since ?? 0)) {
623
- return { kind: "toolResult", toolCallId: message.toolCallId };
380
+ const placed: OneShot[] = [];
381
+ const opening = last >= 0 && isUserLike(messages[last]) ? last : -1;
382
+ const placedOnce = new Set<string>();
383
+ for (const shot of input.oneShots) {
384
+ if (shot.placement === "user-prepend" && (opening !== -1 || !shot.orTail)) {
385
+ if (opening === -1) continue;
386
+ placed.push(shot);
387
+ if (shot.once && placedOnce.has(shot.text)) continue;
388
+ if (shot.once) placedOnce.add(shot.text);
389
+ push(before, opening, [block(shot)]);
390
+ continue;
624
391
  }
392
+ const own = shot.toolCallId
393
+ ? messages.findIndex((m) => m.role === "toolResult" && (m as { toolCallId?: string }).toolCallId === shot.toolCallId)
394
+ : shot.promptTimestamp !== undefined
395
+ ? promptAt(messages, shot.promptTimestamp)
396
+ : -1;
397
+ const at = own !== -1 ? own : tail;
398
+ if (at === -1) continue;
399
+ placed.push(shot);
400
+ push(after, at, [block(shot)]);
625
401
  }
626
- // Overflow compaction may retain only an old reply/result. With no real
627
- // user turn, a live state must still be visible and pinned to that tail.
628
- return messages.some(isStickyCarrier) ? undefined : tailAnchor(messages);
629
- }
630
402
 
631
- /** `toolCallId`'s own result as an anchor, when this request holds it. */
632
- function ownResultAnchor(messages: readonly AgentMessage[], toolCallId: string): PinAnchor | undefined {
633
- return messages.some((m) => m.role === "toolResult" && m.toolCallId === toolCallId) ? { kind: "toolResult", toolCallId } : undefined;
403
+ const changed = before.size > 0 || after.size > 0;
404
+ return {
405
+ messages: changed ? messages.map((m, index) => withBlocks(m, before.get(index) ?? [], after.get(index) ?? [])) : [...messages],
406
+ moved,
407
+ stackPlaced,
408
+ stickyPlaced,
409
+ placed,
410
+ };
634
411
  }
635
412
 
636
- /** The anchor a one-shot lands on for this request: the trailing tool result, else the last user-like message. */
637
- export function tailAnchor(messages: AgentMessage[]): PinAnchor | undefined {
638
- const last = messages[messages.length - 1] as (AgentMessage & { toolCallId?: string }) | undefined;
639
- if (last?.role === "toolResult" && typeof last.toolCallId === "string") return { kind: "toolResult", toolCallId: last.toolCallId };
640
- for (let i = messages.length - 1; i >= 0; i--) {
641
- const m = messages[i] as AgentMessage & { timestamp?: number };
642
- if (isUserLike(m) && typeof m.timestamp === "number") {
643
- return { kind: "user", timestamp: m.timestamp };
644
- }
645
- }
646
- return undefined;
413
+ function stamp(message: AgentMessage): number {
414
+ return (message as { timestamp?: number }).timestamp ?? 0;
647
415
  }
648
416
 
417
+ /** How much later pi may stamp a custom message it rebuilt from the session than the one it delivered. */
418
+ const RESTAMP_WINDOW_MS = 1000;
419
+
649
420
  /**
650
- * The anchor for a `user-prepend` breadcrumb on this request: the trailing
651
- * message when it is a user-like turn (the prompt that opens the request), else
652
- * undefined — mid-turn (a trailing tool result) the breadcrumbs keep waiting.
421
+ * The prompt a one-shot's `promptTimestamp` names, or -1 when the request does
422
+ * not carry it. pi stores a custom message (a `/skill:` invocation) with the
423
+ * time it persisted it, at `message_end`, so after `/reload` or resume the
424
+ * rebuilt message is stamped a little after the delivered one the pin took;
425
+ * a user message keeps its own stamp.
653
426
  */
654
- export function openingUserAnchor(messages: AgentMessage[]): PinAnchor | undefined {
655
- const last = messages[messages.length - 1] as (AgentMessage & { timestamp?: number }) | undefined;
656
- if (last && isUserLike(last) && typeof last.timestamp === "number") return { kind: "user", timestamp: last.timestamp };
657
- return undefined;
427
+ function promptAt(messages: readonly AgentMessage[], timestamp: number): number {
428
+ const exact = messages.findIndex((m) => isStickyCarrier(m) && stamp(m) === timestamp);
429
+ if (exact !== -1) return exact;
430
+ return messages.findIndex((m) => m.role === "custom" && stamp(m) > timestamp && stamp(m) - timestamp < RESTAMP_WINDOW_MS);
658
431
  }
659
432
 
660
- /** A tool result's content with reminder blocks appended (for the `tool_result` hook). */
661
- export function appendReminderBlocks(content: ContentBlock[], entries: ReminderEntry[]): ContentBlock[] {
662
- return [...content, ...entries.map(reminderBlock)];
433
+ /**
434
+ * Where a state switched on mid-turn first shows: its switching call's result
435
+ * when the request carries it, else (that result compacted away, or no call
436
+ * named) the first tool result stamped after the switch, but never one after a
437
+ * user message that already carries it. With no carrier at all (an overflow
438
+ * compaction kept only a reply and its results), the request's tail.
439
+ */
440
+ function firstResultAfter(messages: readonly AgentMessage[], state: StickyEntry): number {
441
+ if (state.toolCallId !== undefined) {
442
+ const own = messages.findIndex((m) => m.role === "toolResult" && (m as { toolCallId?: string }).toolCallId === state.toolCallId);
443
+ if (own !== -1) return own;
444
+ }
445
+ for (let i = 0; i < messages.length; i++) {
446
+ const message = messages[i];
447
+ if (stamp(message) < state.since) continue;
448
+ if (isStickyCarrier(message)) return -1;
449
+ if (message.role === "toolResult") return i;
450
+ }
451
+ if (messages.some(isStickyCarrier)) return -1;
452
+ const last = messages.length - 1;
453
+ return last >= 0 && messages[last].role === "toolResult" ? last : -1;
663
454
  }
664
455
 
665
456
  /**
@@ -690,18 +481,16 @@ export const COMPACTION_SUMMARY_PREFIX =
690
481
  "The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
691
482
  export const COMPACTION_SUMMARY_SUFFIX = "\n</summary>";
692
483
 
693
- type ContentBlock = TextContent | ImageContent;
694
-
695
484
  function toBlocks(content: string | ContentBlock[]): ContentBlock[] {
696
485
  return typeof content === "string" ? [{ type: "text", text: content }] : [...content];
697
486
  }
698
487
 
699
- /** A reminder's text exactly as it rides a message: framed (unless raw), then its suffix. */
700
- export function framedReminderText(entry: Pick<ReminderEntry, "text" | "raw" | "suffix">): string {
701
- return (entry.raw ? entry.text : wrapReminder(entry.text)) + (entry.suffix ?? "");
488
+ /** A reminder's text exactly as it rides a message: framed, unless raw. */
489
+ export function framedReminderText(entry: { text: string; raw?: boolean }): string {
490
+ return entry.raw ? entry.text : wrapReminder(entry.text);
702
491
  }
703
492
 
704
- function reminderBlock(entry: ReminderEntry): TextContent {
493
+ function block(entry: { text: string; raw?: boolean }): TextContent {
705
494
  return { type: "text", text: framedReminderText(entry) };
706
495
  }
707
496
 
@@ -710,9 +499,7 @@ function reminderBlock(entry: ReminderEntry): TextContent {
710
499
  * (task notification, wakeup, hook context, `<new-diagnostics>`); pi's
711
500
  * `convertToLlm` sends it to the model as `role: "user"`, so on the wire it IS
712
501
  * a user turn — and a turn opened by one (a `/loop` tick, an agent report
713
- * arriving while idle) has no `user` message of its own. Before 2026-09-05 such
714
- * a request got no context stack at all, and its pending one-shots pinned to
715
- * the previous user message (STEERING-REVIEW-2026-09-05 H1).
502
+ * arriving while idle) has no `user` message of its own (STEERING-REVIEW-2026-09-05 H1).
716
503
  */
717
504
  function isUserLike(m: AgentMessage): boolean {
718
505
  return isStickyCarrier(m) || m.role === "compactionSummary";
@@ -741,84 +528,3 @@ function withBlocks(message: AgentMessage, before: TextContent[], after: TextCon
741
528
  const target = message as AgentMessage & { content: string | ContentBlock[] };
742
529
  return { ...target, content: [...before, ...toBlocks(target.content), ...after] } as AgentMessage;
743
530
  }
744
-
745
- /**
746
- * Returns a copy of `messages` with reminder blocks injected (Claude Code
747
- * convention: reminders ride inside message content blocks, never as messages
748
- * of their own). See the module header for the three placements. A bare string
749
- * is `last-append` (back-compat). The input array and its messages are not
750
- * mutated. With nothing to anchor to (no user, tool-result, or compaction
751
- * summary message), messages are returned unchanged and the caller keeps the
752
- * reminders queued.
753
- */
754
- export function injectReminders(messages: AgentMessage[], reminders: Array<string | ReminderEntry>): AgentMessage[] {
755
- if (reminders.length === 0) return messages;
756
-
757
- const entries: ReminderEntry[] = reminders.map((r) =>
758
- typeof r === "string" ? { text: r, placement: "last-append", order: 0 } : r,
759
- );
760
- const firstPrepend = entries
761
- .filter((e) => e.placement === "first-prepend")
762
- .map((e, i) => ({ e, i }))
763
- .sort((a, b) => a.e.order - b.e.order || a.i - b.i)
764
- .map((x) => x.e);
765
- const sticky = entries.filter((e) => e.placement === "sticky-append");
766
- const pinnedEntries = entries.filter((e) => e.pin !== undefined);
767
- const lastAppend = entries.filter((e) => e.placement === "last-append" && e.pin === undefined);
768
- // An unpinned `user-prepend` never reaches here through the queue (drain
769
- // holds it back); a caller passing one directly gets it on the opening user
770
- // message, before the text, like a pinned one.
771
- const looseUserPrepend = entries.filter((e) => e.placement === "user-prepend" && e.pin === undefined);
772
-
773
- const firstUserIndex = messages.findIndex(isUserLike);
774
- const lastUserIndex = messages.findLastIndex(isUserLike);
775
- const lastIndex = messages.length - 1;
776
- // Mid-turn, the request ends in the tool result(s) the model is about to
777
- // read — that is where a one-shot reminder belongs. Otherwise the last user turn.
778
- const tailIndex = lastIndex >= 0 && messages[lastIndex].role === "toolResult" ? lastIndex : lastUserIndex;
779
- if (firstUserIndex === -1 && tailIndex === -1) return messages;
780
-
781
- const before = new Map<number, TextContent[]>();
782
- const after = new Map<number, TextContent[]>();
783
- const push = (map: Map<number, TextContent[]>, index: number, blocks: TextContent[]) => {
784
- if (index < 0 || blocks.length === 0) return;
785
- map.set(index, [...(map.get(index) ?? []), ...blocks]);
786
- };
787
-
788
- push(before, firstUserIndex === -1 ? tailIndex : firstUserIndex, firstPrepend.map(reminderBlock));
789
-
790
- const locate = pinLocator(messages);
791
- for (const entry of sticky) {
792
- const carriers: number[] = [];
793
- messages.forEach((m, index) => {
794
- if (carriesSticky(m, entry)) carriers.push(index);
795
- });
796
- const pin = entry.tailPin ?? firstStickyPin(messages, entry);
797
- const pinned = pin ? locate(pin) : -1;
798
- if (pinned !== -1 && !carriers.includes(pinned)) carriers.unshift(pinned);
799
- if (entry.skipStackCarrier && carriers.includes(firstUserIndex)) {
800
- for (const index of carriers.filter((index) => index !== firstUserIndex).sort((a, b) => a - b)) push(after, index, [reminderBlock(entry)]);
801
- continue;
802
- }
803
- for (const index of carriers.sort((a, b) => a - b)) push(after, index, [reminderBlock(entry)]);
804
- }
805
-
806
- push(after, tailIndex === -1 ? firstUserIndex : tailIndex, lastAppend.map(reminderBlock));
807
- if (lastUserIndex !== -1) push(before, lastUserIndex, looseUserPrepend.map(reminderBlock));
808
-
809
- // Pinned one-shots ride the exact message they first landed on; a message
810
- // compacted away simply no longer carries its pin. A pinned breadcrumb sits
811
- // BEFORE that message's text (after the first-prepend stack, which was
812
- // pushed first); every other pin sits after it.
813
- if (pinnedEntries.length > 0) {
814
- const locate = pinLocator(messages);
815
- for (const entry of pinnedEntries) {
816
- const index = locate(entry.pin as PinAnchor);
817
- if (index === -1) continue;
818
- push(entry.placement === "user-prepend" ? before : after, index, [reminderBlock(entry)]);
819
- }
820
- }
821
-
822
- if (before.size === 0 && after.size === 0) return messages;
823
- return messages.map((m, index) => withBlocks(m, before.get(index) ?? [], after.get(index) ?? []));
824
- }