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,230 +1,202 @@
1
1
  /**
2
- * system-reminder extension — injects queued <system-reminder> blocks into the
3
- * outgoing LLM request (pi `context` event), and writes one-shots into the tool
4
- * result they follow (pi `tool_result` event) so they persist in the session
5
- * the way Claude Code's mid-turn reminders do. Context and sticky anchors are
6
- * restored from hidden custom entries; rendered messages stay untouched. This extension owns
7
- * the one queue instance; every other extension reaches it over
8
- * `one-code:system-reminder` (lib/reminders.ts has the placement contract).
2
+ * system-reminder extension, the renderer of the record-once request path
3
+ * (lib/reminders.ts has the placement contract, extensions/ledger the design).
4
+ * It owns the live reminder state every other extension feeds over
5
+ * `one-code:system-reminder`: the `first-prepend` stack and the open sticky
6
+ * states. A one-shot is persisted when emitted, as a `one-code:pending` entry,
7
+ * and delivered from the ledger's outbox.
8
+ *
9
+ * Its `context` hook runs right after the ledger's, which has cut the request
10
+ * to its tail and published the plan. It renders onto that tail only: the
11
+ * stack when the request opens an epoch, the sticky states, the pending
12
+ * one-shots, and the blocks capability owners add on RENDER_CHANNEL (live
13
+ * minus what the epoch told). It writes into the plan which pending items the
14
+ * request carries and what its new blocks tell, and the ledger records both
15
+ * with the request. Nothing is snapshotted or restored: everything above the
16
+ * tail is the record.
9
17
  *
10
18
  * On a model that takes a mid-conversation system message (lib/system-role.ts),
11
- * its `before_provider_request` hook then lifts the session-fact blocks off the
12
- * first user message into one system message after it, Claude Code's layout.
13
- * It loads before tool-search and compaction, so replays and forks inherit it.
19
+ * its `before_provider_request` hook lifts the stack's session-fact blocks off
20
+ * the first user message into one system message after it (an epoch's request
21
+ * only), and shapes the tail's `<total_tokens>` lines (lib/turn-budget-layout.ts).
14
22
  */
15
23
 
24
+ import { randomUUID } from "node:crypto";
16
25
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
26
  import { CLEAR_AT_BETA, MID_CONVERSATION_SYSTEM_BETA, reseatMessageMark, withBetas } from "../lib/anthropic-payload.ts";
18
- import {
19
- appendReminderBlocks,
20
- framedReminderText,
21
- injectReminders,
22
- movesToSystemRole,
23
- openingUserAnchor,
24
- REMINDER_CHANNEL,
25
- ReminderQueue,
26
- type ReminderEntry,
27
- type ReminderPayload,
28
- tailAnchor,
29
- } from "../lib/reminders.ts";
30
- import {
31
- CONTEXT_BASELINE_CHANNEL, CONTEXT_RESTORE_CHANNEL, CONTEXT_STACK_ENTRY, CONTEXT_STATE_ENTRY, CONTEXT_FACTS_REFRESH_CHANNEL,
32
- contextStackOnBranch, RESTORED_STACK_KEYS, LIVE_CONTEXT_KEYS,
33
- type ContextBaseline, type ContextRestoreRequest, type ContextStackSnapshot, type ContextFactsRefresh,
34
- } from "../lib/context-stack.ts";
35
- import { CONTEXT_FACT_KEYS, DATE_CHANGE_KEY, RESUME_FACTS_KEY } from "../lib/context-facts.ts";
36
- import { claudeFamily } from "../lib/model-tier.ts";
37
27
  import { emptyToolResultContent } from "../lib/empty-tool-result.ts";
28
+ import { foldBranch } from "../lib/ledger/fold.ts";
29
+ import { LEDGER_PLAN_CHANNEL, type RequestPlan } from "../lib/ledger/plan.ts";
30
+ import { type EntryLike, makeRecord, type PendingRecordData, RECORD, stateOnBranch } from "../lib/ledger/records.ts";
31
+ import { recordState } from "../lib/ledger/state.ts";
32
+ import { contentHash } from "../lib/ledger/wire.ts";
33
+ import { claudeFamily } from "../lib/model-tier.ts";
34
+ import { extensionVersion } from "../lib/package-version.ts";
35
+ import { LiveReminders, type OneShot, REMINDER_CHANNEL, RENDER_CHANNEL, type RenderRequest, renderReminders, type ReminderPayload, stickyFact } from "../lib/reminders.ts";
36
+ import { sessionAlive } from "../lib/session-lifecycle.ts";
38
37
  import { instructionRole, messagesKey, type MovedBlock, systemRoleLayout, wireShape, withSystemRoleContext } from "../lib/system-role.ts";
39
38
  import { resolveCountdowns, withTurnBudgetMessages } from "../lib/turn-budget-layout.ts";
40
39
 
41
- /**
42
- * Roles a reminder can be attached to (see injectReminders). `custom` is a
43
- * harness message (a task notification, a wakeup, hook context) that pi sends
44
- * to the model as a user message; a turn opened by one has no `user` message
45
- * of its own, so without it the model got no reminder stack at all
46
- * (STEERING-REVIEW-2026-09-05 H1).
47
- */
48
- const ANCHOR_ROLES = new Set(["user", "toolResult", "compactionSummary", "custom"]);
40
+ /** Whether requests carry the `<total_tokens>` budget lines (`CC_TOTAL_TOKENS=0` turns them off). */
41
+ const budgetMarkers = () => process.env.CC_TOTAL_TOKENS !== "0";
42
+
43
+ /** A pending entry as a one-shot to place. */
44
+ function oneShotOf(item: PendingRecordData): OneShot {
45
+ return {
46
+ id: item.id,
47
+ text: item.text,
48
+ placement: item.placement ?? "last-append",
49
+ ...(item.raw ? { raw: true } : {}),
50
+ ...(item.toolCallId ? { toolCallId: item.toolCallId } : {}),
51
+ ...(item.promptTimestamp !== undefined ? { promptTimestamp: item.promptTimestamp } : {}),
52
+ ...(item.once ? { once: true } : {}),
53
+ ...(item.orTail ? { orTail: true } : {}),
54
+ };
55
+ }
56
+
57
+ /** The `one-code:state` key prefix of a sticky state's recorded start. */
58
+ const STICKY_START_PREFIX = "sticky-start:";
49
59
 
50
60
  export default function systemReminderExtension(pi: ExtensionAPI) {
51
- const reminderQueue = new ReminderQueue();
52
- let restored: ContextStackSnapshot | undefined;
53
- let stored = false;
54
- let baselines: Record<string, unknown> = {};
55
- let lastState = "";
56
- let lastStack = "";
57
- pi.events.on(CONTEXT_RESTORE_CHANNEL, (data) => {
58
- if (data && restored) (data as ContextRestoreRequest).restored = structuredClone(restored);
59
- });
60
- pi.events.on(CONTEXT_BASELINE_CHANNEL, (data) => {
61
- const baseline = data as ContextBaseline | undefined;
62
- if (typeof baseline?.key === "string") {
63
- baselines[baseline.key] = structuredClone(baseline.value);
64
- baselineChanges++;
65
- }
66
- });
67
- pi.on("session_start", (_event, ctx) => {
68
- restored = contextStackOnBranch(ctx.sessionManager.getBranch());
69
- stored = restored !== undefined;
70
- baselines = restored ? structuredClone(restored.baselines) : {};
71
- lastState = restored ? JSON.stringify({ version: 1, sticky: restored.sticky, pinned: restored.pinned ?? [], baselines }) : "";
72
- lastStack = restored ? JSON.stringify(restored.stack) : "";
73
- comparedQueue = -1;
74
- if (restored) reminderQueue.restore(restored.stack, restored.sticky, RESTORED_STACK_KEYS, LIVE_CONTEXT_KEYS, restored.pinned);
75
- else reminderQueue.releaseRestore();
76
- });
77
- // A /tree switch fires only session_tree (findings §3). The selected branch's
78
- // messages were sent with its own facts, sticky blocks and pins: take them
79
- // from its last snapshot, as a resume would, while live state and every
80
- // emitter's baseline stay current (claude-context republishes its own).
81
- pi.on("session_tree", (_event, ctx) => {
82
- const branch = contextStackOnBranch(ctx.sessionManager.getBranch());
83
- stored = branch !== undefined;
84
- lastState = branch ? JSON.stringify({ version: 1, sticky: branch.sticky, pinned: branch.pinned ?? [], baselines: branch.baselines }) : "";
85
- lastStack = branch ? JSON.stringify(branch.stack) : "";
86
- comparedQueue = -1;
87
- if (branch) reminderQueue.restoreBranch(branch, CONTEXT_FACT_KEYS);
88
- });
61
+ const alive = sessionAlive(pi);
62
+ const by = extensionVersion();
63
+ const live = new LiveReminders();
64
+ /** The plan the ledger published for the request being rendered. */
65
+ let plan: RequestPlan | undefined;
66
+ /** One-shots emitted before the session started (an extension's load): persisted at session_start. */
67
+ let early: ReminderPayload[] | undefined = [];
68
+ /** Without the ledger (a test harness, a session that does not load it): the pending ids this instance delivered. */
69
+ const deliveredHere = new Set<string>();
70
+
71
+ const persist = (payload: ReminderPayload) => {
72
+ const data = makeRecord("pending", by, {
73
+ id: randomUUID(),
74
+ text: payload.text as string,
75
+ placement: payload.placement === "user-prepend" ? "user-prepend" : "last-append",
76
+ ...(payload.key ? { key: payload.key } : {}),
77
+ ...(payload.raw ? { raw: true } : {}),
78
+ ...(payload.toolCallId ? { toolCallId: payload.toolCallId } : {}),
79
+ ...(payload.promptTimestamp !== undefined ? { promptTimestamp: payload.promptTimestamp } : {}),
80
+ ...(payload.once ? { once: true } : {}),
81
+ ...(payload.orTail ? { orTail: true } : {}),
82
+ ...(payload.untilCompaction ? { untilCompaction: true } : {}),
83
+ ...(payload.observations?.length ? { observations: payload.observations } : {}),
84
+ ...(payload.notified?.length ? { notified: payload.notified } : {}),
85
+ });
86
+ pi.appendEntry(RECORD.pending, data);
87
+ };
88
+
89
+ /** The session's branch: the sticky states' starts it recorded. */
90
+ let branchOf: (() => EntryLike[]) | undefined;
89
91
  /**
90
- * The queue version and baseline count the last comparison saw, or -1 when
91
- * it must run again. A stack taken before a drain that changed the queue
92
- * may not be the stack the queue holds after it, so that sets -1 too.
92
+ * When each sticky state started, as the branch recorded it. An owner that
93
+ * runs again (a resume, /reload, /tree) emits its state without a start, so
94
+ * the renderer takes the branch's start for the same key and text: an epoch
95
+ * that renders the conversation again (a model switch) then places the block
96
+ * where the original requests did, and its first delivery stays told. A
97
+ * removal records the run's end (null), so nothing carries past it.
93
98
  */
94
- let comparedQueue = -1;
95
- let comparedBaselines = -1;
96
- let baselineChanges = 0;
97
- /** `capturedAt`: the queue version when `stack` was taken. Unchanged since the last comparison, the snapshot is too. */
98
- const persistSnapshot = (stack: ReminderEntry[] = reminderQueue.persistentEntries("first-prepend"), capturedAt = reminderQueue.version) => {
99
- const version = reminderQueue.version;
100
- if (stored && capturedAt === version && version === comparedQueue && baselineChanges === comparedBaselines) return;
101
- comparedQueue = capturedAt === version ? version : -1;
102
- comparedBaselines = baselineChanges;
103
- const state = { version: 1 as const, sticky: reminderQueue.persistentEntries("sticky-append"), pinned: reminderQueue.persistentPins(), baselines };
104
- const serialized = JSON.stringify(state);
105
- const serializedStack = JSON.stringify(stack);
106
- if (!stored) {
107
- pi.appendEntry(CONTEXT_STACK_ENTRY, structuredClone({ ...state, stack }));
108
- stored = true;
109
- } else if (serialized !== lastState || serializedStack !== lastStack) {
110
- // Compaction and explicit capability switches start a new prefix.
111
- pi.appendEntry(CONTEXT_STATE_ENTRY, structuredClone({ ...state, ...(serializedStack !== lastStack ? { stack } : {}) }));
112
- }
113
- lastState = serialized;
114
- lastStack = serializedStack;
99
+ const stickyStart = (key: string): { since: number; hash: string } | undefined => {
100
+ const stored = branchOf ? (stateOnBranch(branchOf(), `${STICKY_START_PREFIX}${key}`) as { since?: unknown; hash?: unknown } | undefined) : undefined;
101
+ return typeof stored?.since === "number" && typeof stored.hash === "string" ? { since: stored.since, hash: stored.hash } : undefined;
115
102
  };
116
- pi.events.on(CONTEXT_FACTS_REFRESH_CHANNEL, (data) => {
117
- const refresh = data as ContextFactsRefresh;
118
- reminderQueue.replaceFirstPrepend(CONTEXT_FACT_KEYS, refresh.entries);
119
- reminderQueue.cancelPending([DATE_CHANGE_KEY, RESUME_FACTS_KEY]);
120
- baselines["claude-context"] = structuredClone(refresh.baseline);
121
- baselineChanges++;
122
- // /compact may be the last action before exit. Persist now, not just on
123
- // the next request, so a resume cannot revive the pre-compaction facts.
124
- persistSnapshot();
125
- });
126
- /** The blocks the last `context` pass placed for the system message; the payload hook moves them. */
127
- let moved: MovedBlock[] = [];
128
- /** The tool results' countdowns the last `context` pass lifted, by call id (lib/turn-budget-layout.ts). */
129
- let countdowns = new Map<string, number>();
130
103
 
131
104
  pi.events.on(REMINDER_CHANNEL, (data) => {
132
- const payload = data as ReminderPayload;
105
+ let payload = data as ReminderPayload | undefined;
133
106
  if (!payload) return;
134
- if (payload.remove && payload.key) {
135
- reminderQueue.remove(payload.key);
136
- } else if (typeof payload.text === "string") {
137
- reminderQueue.enqueue(payload.text, {
138
- scope: payload.scope,
139
- key: payload.key,
140
- placement: payload.placement,
141
- order: payload.order,
142
- suffix: payload.suffix,
143
- raw: payload.raw,
144
- systemRoleOnly: payload.systemRoleOnly,
145
- skipStackCarrier: payload.skipStackCarrier,
146
- since: payload.since,
147
- once: payload.once,
148
- toolCallId: payload.toolCallId,
149
- });
107
+ const sticky = payload.placement === "sticky-append" && !payload.remove && typeof payload.text === "string";
108
+ const key = payload.key ?? payload.text ?? "";
109
+ const recorded = sticky ? stickyStart(key) : undefined;
110
+ if (sticky && payload.since === undefined && recorded?.hash === contentHash(payload.text)) payload = { ...payload, since: recorded.since };
111
+ // A removal ends the state's run: switched on again, even with the same
112
+ // text, it is a new state that rides the switching call's result, not the
113
+ // old run's start, whose first delivery the branch already told.
114
+ if (payload.remove && payload.key && branchOf && alive() && stickyStart(payload.key)) recordState(pi, `${STICKY_START_PREFIX}${payload.key}`, null);
115
+ const applied = live.apply(payload);
116
+ if (sticky && applied === "live" && branchOf && alive()) {
117
+ const entry = live.stickyEntries().find((state) => state.key === key);
118
+ const hash = entry ? contentHash(entry.text) : undefined;
119
+ if (entry && entry.since > 0 && (recorded?.since !== entry.since || recorded?.hash !== hash)) recordState(pi, `${STICKY_START_PREFIX}${key}`, { since: entry.since, hash });
150
120
  }
121
+ if (applied !== "one-shot") return;
122
+ if (early) early.push(payload);
123
+ else if (alive()) persist(payload);
124
+ });
125
+ pi.on("session_start", (_event, ctx) => {
126
+ const sessionManager = ctx?.sessionManager;
127
+ branchOf = sessionManager ? () => sessionManager.getBranch() as EntryLike[] : undefined;
128
+ const queued = early ?? [];
129
+ early = undefined;
130
+ for (const payload of queued) persist(payload);
131
+ });
132
+ pi.events.on(LEDGER_PLAN_CHANNEL, (data) => {
133
+ plan = data as RequestPlan;
151
134
  });
152
135
 
153
- // A one-shot pending when a tool result is stored goes INTO that result: the
154
- // model reads it right there, the session file keeps it, and no later request
155
- // re-caches the message (a transient block would vanish on the next request
156
- // and cost the result and everything after it). Runs before the hooks
157
- // extension's PostToolUse in load order, so a hook that replaces the whole
158
- // result would drop these appended blocks — the hooks extension re-attaches
159
- // the trailing reminder run when it applies a replacement (review M3,
160
- // applyPostToolUseOutcome / isAppendedReminderText).
136
+ // A tool's empty result is normalized before anything else reads it. Read's
137
+ // raw bytes stay for file-tracker's empty-file warning.
161
138
  pi.on("tool_result", (event) => {
162
- // Normalize the tool's result before reminders make an empty result look
163
- // nonempty. Read's raw bytes stay for file-tracker's empty-file warning.
164
139
  const replacement = emptyToolResultContent(event.content, event.toolName);
165
- const entries = reminderQueue.hasPendingOneShots ? reminderQueue.takeOneShots(event.toolCallId) : [];
166
- if (!replacement && entries.length === 0) return;
167
- const content = replacement ?? event.content;
168
- return {
169
- content: entries.length ? appendReminderBlocks(content, entries) : content,
170
- ...(event.structuredContent !== undefined ? { structuredContent: event.structuredContent } : {}),
171
- };
140
+ if (!replacement) return;
141
+ return { content: replacement, ...(event.structuredContent !== undefined ? { structuredContent: event.structuredContent } : {}) };
172
142
  });
173
143
 
174
- /** The request's messages with the queued reminders placed, or the input when nothing can be placed. */
175
- const withReminders = (messages: Parameters<typeof injectReminders>[0], layout: ReturnType<typeof systemRoleLayout>) => {
176
- if (reminderQueue.size === 0 && !stored) return messages;
177
- // injectReminders is a no-op when there is nothing to attach to. Only
178
- // consume the queue once there is somewhere to put the reminders, so they
179
- // survive to the next eligible request.
180
- if (!messages.some((m) => ANCHOR_ROLES.has(m.role))) return messages;
181
- // A one-shot that arrived after the last tool result was stored (a mode
182
- // change while idle, a deferred-tool miss whose call had no tool_result
183
- // hook) rides this request's tail — and stays pinned there afterwards.
184
- if (reminderQueue.hasPendingOneShots) {
185
- const anchor = tailAnchor(messages);
186
- if (anchor) reminderQueue.pin(anchor, "last-append", messages);
187
- }
188
- // A local-command breadcrumb rides the prompt that opens a request, before
189
- // the user's text (Claude Code's placement), and stays there. Mid-turn —
190
- // the request ends in a tool result — it waits for the next prompt.
191
- if (reminderQueue.hasPending("user-prepend")) {
192
- const anchor = openingUserAnchor(messages);
193
- if (anchor) reminderQueue.pin(anchor, "user-prepend");
194
- }
195
- // Capture first-prepend before draining (one may be next-turn), then save
196
- // after drain resolves sticky anchors. Store logical entries before layout,
197
- // including systemRoleOnly blocks even on a model that cannot carry them.
198
- reminderQueue.finishRestore();
199
- const capturedAt = reminderQueue.version;
200
- const stack = reminderQueue.persistentEntries("first-prepend");
201
- const drained = reminderQueue.drain(messages);
202
- persistSnapshot(stack, capturedAt);
203
- // A block meant only for the system message is left out where there is none.
204
- const reminders = drained.filter((entry) => layout || !entry.systemRoleOnly);
205
- if (layout) {
206
- moved = reminders
207
- .filter((entry) => entry.placement === "first-prepend" && movesToSystemRole(entry.order))
208
- .sort((a, b) => a.order - b.order)
209
- .map((entry) => ({ framed: framedReminderText(entry), inner: entry.text }));
210
- }
211
- return injectReminders(messages, reminders);
212
- };
144
+ /** The stack blocks the last `context` pass placed for the system message; the payload hook moves them. */
145
+ let moved: MovedBlock[] = [];
146
+ /** Whether the last `context` pass rendered an epoch: only then does the first user message carry the stack. */
147
+ let epochRequest = true;
148
+ /** The tool results' countdowns the last `context` pass lifted, by call id (lib/turn-budget-layout.ts). */
149
+ let countdowns = new Map<string, number>();
213
150
 
214
151
  pi.on("context", (event, ctx) => {
215
- moved = [];
216
- countdowns = new Map();
152
+ const current = plan;
153
+ plan = undefined;
217
154
  const layout = systemRoleLayout(ctx.model);
218
- const messages = withReminders(event.messages, layout);
219
- // Each tool result's stored <total_tokens> countdown, on every request so
220
- // the layout never depends on the queue: lifted for a system message or
221
- // framed, while the result's blocks are still separate (lib/turn-budget-layout.ts).
155
+ const epoch = current?.epoch ?? true;
156
+ const fold = current?.fold ?? foldBranch(ctx.sessionManager.getBranch() as EntryLike[]);
157
+
158
+ // Owners add nothing on an epoch (the live stack is rendered), so the told maps need no reset here.
159
+ const request: RenderRequest = { epoch, told: fold.told, modes: fold.modes, toldBlocks: fold.blocks, model: ctx.model, blocks: [] };
160
+ pi.events.emit(RENDER_CHANNEL, request);
161
+ const oneShots: OneShot[] = [
162
+ ...fold.pending.filter((item) => !deliveredHere.has(item.id)).map(oneShotOf),
163
+ ...request.blocks.map((block) => ({ text: block.text, placement: "last-append" as const, ...(block.facts ? { facts: block.facts } : {}) })),
164
+ ];
165
+ const stack = live.stackEntries();
166
+ // A sticky state's first delivery is told, so a failed request's revokes and an epoch renders it again.
167
+ const toldBlocks = epoch ? new Map<string, string>() : fold.blocks;
168
+ const sticky = live.stickyEntries().map((entry) => {
169
+ const fact = stickyFact(entry);
170
+ return { ...entry, fact, delivered: toldBlocks.get(fact.key) === fact.hash };
171
+ });
172
+ const rendered = renderReminders(event.messages, { epoch, layout: layout !== undefined, stack, sticky, oneShots });
173
+ const stickyFacts = sticky.filter((entry) => !entry.delivered && rendered.stickyPlaced.includes(entry.key)).map((entry) => entry.fact);
174
+
175
+ const consumed = rendered.placed.flatMap((shot) => (shot.id ? [shot.id] : []));
176
+ // Each fact with the text that carries it: a repair that drops the text drops the fact.
177
+ const carried = [
178
+ ...(epoch && rendered.stackPlaced ? stack.filter((entry) => layout !== undefined || !entry.systemRoleOnly).map((entry) => ({ text: entry.text, facts: entry.facts ?? [] })) : []),
179
+ ...rendered.placed.map((shot) => ({ text: shot.text, facts: shot.facts ?? [] })),
180
+ ...sticky.filter((entry) => stickyFacts.includes(entry.fact)).map((entry) => ({ text: entry.text, facts: [entry.fact] })),
181
+ ].filter((block) => block.facts.length > 0);
182
+ if (current) {
183
+ current.consumed.push(...consumed);
184
+ current.carried.push(...carried);
185
+ } else {
186
+ for (const id of consumed) deliveredHere.add(id);
187
+ }
188
+
189
+ moved = rendered.moved;
190
+ epochRequest = epoch;
191
+ // Each tool result's <total_tokens> countdown, lifted for a system message
192
+ // or framed, while the result's blocks are still separate (lib/turn-budget-layout.ts).
222
193
  // With the budget off no result carries one, so a tool's own such line stays its output.
223
- const budgetOn = process.env.CC_TOTAL_TOKENS !== "0";
224
- const resolved = budgetOn && ctx.model && wireShape(ctx.model.api) ? resolveCountdowns(messages, layout !== undefined) : undefined;
194
+ countdowns = new Map();
195
+ const resolved = budgetMarkers() && ctx.model && wireShape(ctx.model.api) ? resolveCountdowns(rendered.messages, layout !== undefined) : undefined;
225
196
  if (resolved) countdowns = resolved.left;
226
- const out = resolved?.messages ?? messages;
227
- return out === event.messages ? undefined : { messages: out };
197
+ const out = resolved?.messages ?? rendered.messages;
198
+ const changed = out.length !== event.messages.length || out.some((message, i) => message !== event.messages[i]);
199
+ return changed ? { messages: out } : undefined;
228
200
  });
229
201
 
230
202
  pi.on("before_provider_request", (event, ctx) => {
@@ -239,14 +211,14 @@ export default function systemReminderExtension(pi: ExtensionAPI) {
239
211
  // gets its clear_at nudge after each tool result.
240
212
  const firstParty = model.provider === "anthropic";
241
213
  const nudge = layout === "anthropic" && firstParty && claudeFamily(model.id) === "fable";
242
- const markers = process.env.CC_TOTAL_TOKENS !== "0";
243
- payload = withTurnBudgetMessages(payload, { shape, systemRole: layout !== undefined, role, left: countdowns, nudge, markers }) ?? payload;
244
- if (payload === event.payload) return undefined;
245
- if (shape === "anthropic") {
214
+ payload = withTurnBudgetMessages(payload, { shape, systemRole: layout !== undefined, role, left: countdowns, nudge, markers: budgetMarkers(), stackCarrier: epochRequest }) ?? payload;
215
+ if (payload !== event.payload && shape === "anthropic") {
246
216
  const key = messagesKey(shape);
247
217
  payload = { ...payload, [key]: reseatMessageMark(payload[key] as Array<{ role?: unknown; content?: unknown }>) };
248
218
  }
249
- if (layout !== "anthropic" || !firstParty) return payload;
250
- return withBetas(payload, nudge ? [MID_CONVERSATION_SYSTEM_BETA, CLEAR_AT_BETA] : [MID_CONVERSATION_SYSTEM_BETA]);
219
+ // The betas go on every request of a model that takes the layout: an
220
+ // earlier request's system messages are part of every later one.
221
+ if (layout === "anthropic" && firstParty) payload = withBetas(payload, nudge ? [MID_CONVERSATION_SYSTEM_BETA, CLEAR_AT_BETA] : [MID_CONVERSATION_SYSTEM_BETA]);
222
+ return payload === event.payload ? undefined : payload;
251
223
  });
252
224
  }