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
@@ -5,9 +5,12 @@
5
5
  * CONTEXT_ORDER.claudeMd, the context block (userEmail + the git snapshot) at
6
6
  * CONTEXT_ORDER.context, and the date at CONTEXT_ORDER.date, which on a model
7
7
  * that takes a mid-conversation system message rides that message instead
8
- * (system-reminder, lib/system-role.ts). They ride the reminder queue
9
- * (per-request via pi's `context` event). system-reminder persists a hidden
10
- * snapshot so a resume retains the original facts without doubling the blocks.
8
+ * (system-reminder, lib/system-role.ts). They are live state: a request that
9
+ * opens an epoch renders them, and every later request in the epoch replays
10
+ * them from the ledger's record (extensions/ledger). The blocks carry ledger
11
+ * facts (lib/context-facts.ts), so the first request of a resumed process says
12
+ * once what changed in the workspace since, and a session that crosses local
13
+ * midnight learns the new date, both on the request's tail.
11
14
  *
12
15
  * When ONECODE.md files exist, this extension also emits a separate `# oneCodeMd`
13
16
  * block at CONTEXT_ORDER.oneCodeMd — after the instructions block, so One
@@ -41,10 +44,9 @@ import {
41
44
  import { collectGitStatus, GIT_SNAPSHOT_OWNER_CHANNEL } from "../lib/git-status.ts";
42
45
  import { readMemoryIndex } from "../lib/memory-index.ts";
43
46
  import { claudeConfigDir, oneCodeStateDir, tryRealpath } from "../lib/paths.ts";
44
- import { CONTEXT_ORDER, REMINDER_CHANNEL, type ReminderEntry } from "../lib/reminders.ts";
47
+ import { CONTEXT_ORDER, REMINDER_CHANNEL, RENDER_CHANNEL, type RenderRequest, toldBlockFact } from "../lib/reminders.ts";
45
48
  import { inContextEntries } from "../lib/compaction-boundary.ts";
46
- import { CONTEXT_BASELINE_CHANNEL, CONTEXT_FACTS_REFRESH_CHANNEL, contextStackOnBranch, restoredContext, type ContextStackSnapshot } from "../lib/context-stack.ts";
47
- import { compactionGitStatus, contextFactsBaseline, DATE_CHANGE_KEY, legacyResumeFactsNotice, RESUME_FACTS_KEY, resumeFactsNotice, storedFactsBaseline, type ContextFactsBaseline } from "../lib/context-facts.ts";
49
+ import { compactionGitStatus, contextBlockFacts, contextFingerprints, dateFingerprint, FACT_KEYS, instructionFacts, liveFingerprints, type LiveFingerprints, resumeFactsNotice } from "../lib/context-facts.ts";
48
50
  import { resolveToolPath } from "../lib/tool-path.ts";
49
51
  import { sessionWorkCwd, WORKTREE_CHANNEL, type WorktreeLocation } from "../lib/worktree-channel.ts";
50
52
  import { pathArgument } from "../auto-mode/paths.ts";
@@ -52,6 +54,9 @@ import { HARNESS_GIT_CONFIG } from "../lib/git.ts";
52
54
  import { EXTERNAL_INCLUDES_NO, EXTERNAL_INCLUDES_NONE, EXTERNAL_INCLUDES_SETTING, EXTERNAL_INCLUDES_YES, externalIncludesDialog, persistExternalIncludesApproval, readExternalIncludesApproval } from "../lib/claude-external-includes.ts";
53
55
  import { registerLocalCommand } from "../lib/local-command.ts";
54
56
  import { consentDialog, installConsentDialogs, startupConsentReady } from "../lib/consent-dialogs.ts";
57
+ import type { ToldFact } from "../lib/ledger/records.ts";
58
+ import { foldSession } from "../lib/ledger/fold-memo.ts";
59
+ import { sessionIdentity } from "../lib/anthropic-auth.ts";
55
60
 
56
61
  const REMINDER_KEY = "claude-context";
57
62
  const ONECODE_REMINDER_KEY = "one-code-context";
@@ -96,12 +101,8 @@ export default function claudeContextExtension(pi: ExtensionAPI) {
96
101
  pi.events.on(GIT_SNAPSHOT_OWNER_CHANNEL, () => {
97
102
  takesGitSnapshot = true;
98
103
  });
99
- /** The date the date reminder carries. */
104
+ /** The date the live date block carries. */
100
105
  let blockDate = "";
101
- /** The date the model was last told: the reminder's, or a later date-change notice's. */
102
- let shownDate = "";
103
- let hadInstructions = false;
104
- let hadOneCode = false;
105
106
  /**
106
107
  * Instruction files in context, by real path: the startup block's files and
107
108
  * their imports (`startupShown`, fixed for the session), plus nested files
@@ -111,8 +112,6 @@ export default function claudeContextExtension(pi: ExtensionAPI) {
111
112
  let attachedNested = new Set<string>();
112
113
  /** Where a nested instruction actually landed: a persisted result or a pinned request tail. */
113
114
  const shownByResult = new Map<string, Set<string>>();
114
- /** Nested files queued on a read's result, not yet sent in a request. */
115
- const pendingNested = new Map<string, string[]>();
116
115
  const remember = (toolCallId: string | undefined, keys: Iterable<string>) => {
117
116
  if (!toolCallId) return;
118
117
  const shown = shownByResult.get(toolCallId) ?? new Set<string>();
@@ -156,24 +155,33 @@ export default function claudeContextExtension(pi: ExtensionAPI) {
156
155
  let approvalPending: Promise<void> | undefined;
157
156
  let approvalAbort = new AbortController();
158
157
  let sessionGeneration = 0;
159
- let requestStarted = false;
160
158
 
159
+ /**
160
+ * The instructions facts while no instructions block renders (no CLAUDE.md,
161
+ * no memory index): they ride the date block, so an epoch still tells the
162
+ * absence and a resume notices a file that appeared since.
163
+ */
164
+ let absentInstructionFacts: ToldFact[] = [];
161
165
  const emitDate = (date: string) => {
162
166
  blockDate = date;
163
- shownDate = date;
164
167
  pi.events.emit(REMINDER_CHANNEL, {
165
168
  text: dateBlock(date),
166
- scope: "every-turn",
167
169
  key: DATE_REMINDER_KEY,
168
170
  placement: "first-prepend",
169
171
  order: CONTEXT_ORDER.date,
172
+ facts: [toldBlockFact(FACT_KEYS.date, date), ...absentInstructionFacts],
170
173
  });
171
174
  };
172
175
 
173
- let facts: ContextFactsBaseline | undefined;
174
- let pendingResume: ContextStackSnapshot | undefined;
175
- const baseline = () => ({ startupShown: [...startupShown], shownDate, facts });
176
- const publishBaseline = () => pi.events.emit(CONTEXT_BASELINE_CHANNEL, { key: "claude-context", value: baseline() });
176
+ /**
177
+ * The live workspace's fingerprints, taken at this process's first turn, for
178
+ * the resumed-session check, in each directory the epoch's facts may have
179
+ * been read in: the session's own at its start, the entered worktree at a
180
+ * compaction (session_compact reads there).
181
+ */
182
+ let livePrints: { atStart: LiveFingerprints; atCompaction: () => LiveFingerprints } | undefined;
183
+ /** True until this process's first request inside an epoch has compared the epoch's facts with the live workspace. */
184
+ let resumeCheck = true;
177
185
  const readFiles = (cwd: string) => {
178
186
  const oneCodeFiles = discoverOneCodeFiles({ cwd, homeOneCodeDir: oneCodeStateDir(), home: os.homedir(), includeExternal });
179
187
  return {
@@ -183,26 +191,21 @@ export default function claudeContextExtension(pi: ExtensionAPI) {
183
191
  oneCode: buildOneCodeBlock(oneCodeFiles),
184
192
  };
185
193
  };
186
- /** What a stored snapshot says the model was shown: its facts, the files its blocks carry, and its dates. */
187
- const adoptSnapshot = (snapshot: ContextStackSnapshot) => {
188
- const saved = snapshot.baselines["claude-context"] as { startupShown?: unknown; shownDate?: unknown; facts?: unknown } | undefined;
189
- facts = storedFactsBaseline(saved?.facts);
190
- startupShown = new Set(Array.isArray(saved?.startupShown) ? saved.startupShown.filter((p): p is string => typeof p === "string") : []);
191
- attachedNested = new Set(startupShown);
192
- blockDate = snapshot.stack.find((entry) => entry.key === DATE_REMINDER_KEY)?.text.match(/^Today's date is (\d{4}-\d{2}-\d{2})\.$/)?.[1] ?? "";
193
- shownDate = typeof saved?.shownDate === "string" ? saved.shownDate : blockDate;
194
- hadInstructions = snapshot.stack.some((entry) => entry.key === REMINDER_KEY);
195
- hadOneCode = snapshot.stack.some((entry) => entry.key === ONECODE_REMINDER_KEY);
196
- };
197
194
  let files: ReturnType<typeof readFiles> | undefined;
198
195
  const adoptFiles = (current: ReturnType<typeof readFiles>) => {
199
196
  files = current;
200
- // A file either startup block carries (or imports) is never attached again on a read.
197
+ rule = instructionRule(os.homedir());
198
+ };
199
+ /**
200
+ * The files message 1 carries (or imports): never attached again on a read.
201
+ * Taken when an epoch renders the blocks; a resumed epoch's are taken from
202
+ * the live files at start, the closest knowledge this process has.
203
+ */
204
+ const adoptShown = (current: ReturnType<typeof readFiles>) => {
201
205
  startupShown = new Set([...current.contextFiles, ...current.oneCodeFiles].flatMap((file) =>
202
206
  [file.path, ...(file.imported ?? collectImportedPaths(readOrEmpty(file.path), dirname(file.path), { home: os.homedir() }))],
203
207
  ).map((path) => tryRealpath(path) ?? path));
204
- attachedNested = new Set(startupShown);
205
- rule = instructionRule(os.homedir());
208
+ for (const path of startupShown) attachedNested.add(path);
206
209
  };
207
210
  const snapshotGit = (cwd: string) => {
208
211
  const tools = pi.getActiveTools();
@@ -216,14 +219,56 @@ export default function claudeContextExtension(pi: ExtensionAPI) {
216
219
  try { persistExternalIncludesApproval(ctx.cwd, os.homedir(), approved); }
217
220
  catch (error) { ctx.ui.notify(`Could not remember external CLAUDE.md include approval: ${String(error)}`, "error"); }
218
221
  };
219
- const refreshUnsentInstructions = (cwd: string) => {
220
- if (requestStarted || pendingResume) return;
222
+ /**
223
+ * The instructions and ONECODE.md blocks as live state, read in `cwd`. They
224
+ * render when an epoch opens; inside one, message 1 is the record, so a
225
+ * change here (a /config approval, an edited file) shows at the next epoch.
226
+ */
227
+ const emitInstructions = (current: ReturnType<typeof readFiles>) => {
228
+ const instructions = buildClaudeMdBlock(current);
229
+ if (instructions) {
230
+ pi.events.emit(REMINDER_CHANNEL, {
231
+ text: instructions,
232
+ key: REMINDER_KEY,
233
+ placement: "first-prepend",
234
+ order: CONTEXT_ORDER.claudeMd,
235
+ facts: instructionFacts(contextFingerprints(current)),
236
+ });
237
+ } else pi.events.emit(REMINDER_CHANNEL, { remove: true, key: REMINDER_KEY });
238
+ const absent = instructions ? [] : instructionFacts(contextFingerprints(current));
239
+ if (JSON.stringify(absent) !== JSON.stringify(absentInstructionFacts)) {
240
+ absentInstructionFacts = absent;
241
+ if (blockDate) emitDate(blockDate);
242
+ }
243
+ // One Code's own instructions ride in a separate block AFTER the instructions
244
+ // block, so they take precedence over CLAUDE.md (higher order = closer to the user text).
245
+ if (current.oneCode) {
246
+ pi.events.emit(REMINDER_CHANNEL, {
247
+ text: current.oneCode,
248
+ key: ONECODE_REMINDER_KEY,
249
+ placement: "first-prepend",
250
+ order: CONTEXT_ORDER.oneCodeMd,
251
+ });
252
+ } else pi.events.emit(REMINDER_CHANNEL, { remove: true, key: ONECODE_REMINDER_KEY });
253
+ };
254
+ const emitContextBlock = (gitText: string | null, atCompaction: boolean) => {
255
+ const context = buildContextBlock({ email, gitStatus: gitText });
256
+ if (!context) {
257
+ pi.events.emit(REMINDER_CHANNEL, { remove: true, key: CONTEXT_REMINDER_KEY });
258
+ return;
259
+ }
260
+ pi.events.emit(REMINDER_CHANNEL, {
261
+ text: context,
262
+ key: CONTEXT_REMINDER_KEY,
263
+ placement: "first-prepend",
264
+ order: CONTEXT_ORDER.context,
265
+ facts: contextBlockFacts(gitStatus ?? null, atCompaction),
266
+ });
267
+ };
268
+ const refreshInstructions = (cwd: string) => {
221
269
  const current = readFiles(cwd);
222
270
  adoptFiles(current);
223
- const text = buildClaudeMdBlock(current);
224
- if (text) pi.events.emit(REMINDER_CHANNEL, { text, scope: "every-turn", key: REMINDER_KEY, placement: "first-prepend", order: CONTEXT_ORDER.claudeMd });
225
- if (current.oneCode) pi.events.emit(REMINDER_CHANNEL, { text: current.oneCode, scope: "every-turn", key: ONECODE_REMINDER_KEY, placement: "first-prepend", order: CONTEXT_ORDER.oneCodeMd });
226
- publishBaseline();
271
+ emitInstructions(current);
227
272
  };
228
273
  const askApproval = async (ctx: ExtensionContext, paths: string[], afterStartup = false) => {
229
274
  const gen = sessionGeneration;
@@ -233,7 +278,7 @@ export default function claudeContextExtension(pi: ExtensionAPI) {
233
278
  const choice = await consentDialog(pi.events, () => ctx.ui.select(externalIncludesDialog(paths, os.homedir()), [EXTERNAL_INCLUDES_NO, EXTERNAL_INCLUDES_YES], { signal }), afterStartup);
234
279
  if (signal.aborted || gen !== sessionGeneration) return;
235
280
  saveApproval(ctx, choice === EXTERNAL_INCLUDES_YES);
236
- refreshUnsentInstructions(ctx.cwd);
281
+ refreshInstructions(ctx.cwd);
237
282
  };
238
283
  registerLocalCommand(pi, "config", {
239
284
  description: "Configure external CLAUDE.md includes for this project",
@@ -259,7 +304,7 @@ export default function claudeContextExtension(pi: ExtensionAPI) {
259
304
  if (picked !== row || gen !== sessionGeneration || approvalAbort.signal.aborted) return;
260
305
  if (includeExternal) {
261
306
  saveApproval(ctx, false);
262
- refreshUnsentInstructions(ctx.cwd);
307
+ refreshInstructions(ctx.cwd);
263
308
  } else await askApproval(ctx, paths);
264
309
  // As in Claude Code, this changes future loads, never retracts text or
265
310
  // rewrites the frozen first-message block after a request was sent.
@@ -281,13 +326,8 @@ export default function claudeContextExtension(pi: ExtensionAPI) {
281
326
  approvalAbort.abort();
282
327
  approvalAbort = new AbortController();
283
328
  approvalPending = undefined;
284
- requestStarted = false;
285
329
  const approval = readExternalIncludesApproval(ctx.cwd, os.homedir());
286
330
  includeExternal = approval.approved;
287
- const restored = restoredContext(pi.events);
288
- pendingResume = restored;
289
- files = undefined;
290
- facts = undefined;
291
331
  if ((event.reason ?? "startup") === "startup" && ctx.hasUI && !approval.approved && !approval.warningShown) {
292
332
  const paths = externalPaths(ctx.cwd);
293
333
  if (paths.length) {
@@ -297,61 +337,23 @@ export default function claudeContextExtension(pi: ExtensionAPI) {
297
337
  approvalPending = askApproval(ctx, paths, true).catch(() => {});
298
338
  }
299
339
  }
300
- if (restored) {
301
- adoptSnapshot(restored);
302
- restoreAttachments(ctx);
303
- rule = instructionRule(os.homedir());
304
- gitStatus = null; // The stored snapshot wins; do not take another one.
305
- publishBaseline();
306
- return;
307
- }
308
340
  // The instructions block carries the instruction files the mode and Claude
309
341
  // Code's instructionFiles pick (lib/claude-context.ts instructionRule): by
310
342
  // default the CLAUDE.md family, or the project's AGENTS.md files when it
311
343
  // has no CLAUDE.md, byte-exact with Claude Code either way. ONECODE.md
312
- // rides its own higher-precedence block below.
313
- const current = readFiles(ctx.cwd);
314
- adoptFiles(current);
315
- const instructions = buildClaudeMdBlock(current);
316
- if (instructions) {
317
- pi.events.emit(REMINDER_CHANNEL, {
318
- text: instructions,
319
- scope: "every-turn",
320
- key: REMINDER_KEY,
321
- placement: "first-prepend",
322
- order: CONTEXT_ORDER.claudeMd,
323
- });
324
- } else if (hadInstructions) {
325
- pi.events.emit(REMINDER_CHANNEL, { remove: true, key: REMINDER_KEY });
326
- }
327
- hadInstructions = instructions !== null;
344
+ // rides its own higher-precedence block.
345
+ refreshInstructions(ctx.cwd);
328
346
  email = resolveEmail(ctx.cwd);
329
- attachedNested = new Set(startupShown);
330
- shownByResult.clear();
331
- pendingNested.clear();
332
- rule = instructionRule(os.homedir());
333
- // /clear re-fires session_start: the next conversation takes its own snapshot.
347
+ attachedNested = new Set();
348
+ if (files) adoptShown(files);
349
+ restoreAttachments(ctx);
350
+ // /clear and a resume re-fire session_start: this process takes its own snapshot.
334
351
  gitStatus = undefined;
335
- // The user's local date, taken once: the reminder is frozen after the first
336
- // request, so a later date rides a one-shot (before_agent_start below).
352
+ livePrints = undefined;
353
+ resumeCheck = true;
354
+ // The user's local date. Inside an epoch the block on message 1 is the
355
+ // record, so a later date rides a notice (the render step below).
337
356
  emitDate(localDate());
338
-
339
- // One Code's own instructions ride in a separate block AFTER the instructions
340
- // block, so they take precedence over CLAUDE.md (higher order = closer to the user text).
341
- const oneCode = current.oneCode;
342
- if (oneCode) {
343
- pi.events.emit(REMINDER_CHANNEL, {
344
- text: oneCode,
345
- scope: "every-turn",
346
- key: ONECODE_REMINDER_KEY,
347
- placement: "first-prepend",
348
- order: CONTEXT_ORDER.oneCodeMd,
349
- });
350
- } else if (hadOneCode) {
351
- pi.events.emit(REMINDER_CHANNEL, { remove: true, key: ONECODE_REMINDER_KEY });
352
- }
353
- publishBaseline();
354
- hadOneCode = oneCode !== null;
355
357
  });
356
358
 
357
359
  // Claude Code snapshots git "at the start of the conversation": on the first
@@ -362,89 +364,79 @@ export default function claudeContextExtension(pi: ExtensionAPI) {
362
364
  const gen = sessionGeneration;
363
365
  await drainApproval();
364
366
  if (gen !== sessionGeneration || approvalAbort.signal.aborted) return;
365
- requestStarted = true;
366
- // Check only once, just before the first resumed request. No startup git
367
- // subprocesses, and no changes to the historical first-message blocks.
368
- if (pendingResume) {
369
- const restored = pendingResume;
370
- pendingResume = undefined;
371
- // Compare in the directory the facts were read in: a resume inside a worktree entered
372
- // after the session started would otherwise report every path as changed.
373
- const cwd = facts?.cwd ?? (facts?.atCompaction ? sessionWorkCwd(entered, ctx.cwd) : ctx.cwd);
374
- const current = readFiles(cwd);
375
- const liveGit = snapshotGit(cwd);
376
- const liveFacts = contextFactsBaseline({ ...current, gitStatus: liveGit, atCompaction: false });
377
- const text = facts ? resumeFactsNotice(facts, liveFacts) : legacyResumeFactsNotice(restored.stack, {
378
- instructions: buildClaudeMdBlock(current), email: resolveEmail(cwd), gitStatus: liveGit,
379
- });
380
- if (text) pi.events.emit(REMINDER_CHANNEL, { key: RESUME_FACTS_KEY, text });
381
- }
382
367
  if (gitStatus !== undefined) return;
368
+ // A process that starts on a compacted branch before the compaction's
369
+ // epoch opened (/compact, then /reload) renders that epoch's message 1:
370
+ // the blocks are the compaction's, not the conversation start's (R3-L11).
371
+ // The fold is shared with tool-search and the ledger: one fold per branch state (lib/ledger/fold-memo.ts).
372
+ const fold = foldSession(ctx.sessionManager, sessionIdentity(pi, ctx.model));
373
+ if (fold.opensEpoch && fold.cause === "compaction") {
374
+ refreshForCompaction(ctx);
375
+ return;
376
+ }
383
377
  gitStatus = snapshotGit(ctx.cwd);
384
- if (files) facts = contextFactsBaseline({ ...files, gitStatus, atCompaction: false, cwd: ctx.cwd });
385
- publishBaseline();
386
- const context = buildContextBlock({ email, gitStatus });
387
- if (!context) return;
388
- pi.events.emit(REMINDER_CHANNEL, {
389
- text: context,
390
- scope: "every-turn",
391
- key: CONTEXT_REMINDER_KEY,
392
- placement: "first-prepend",
393
- order: CONTEXT_ORDER.context,
394
- });
378
+ if (files) {
379
+ const atStart = liveFingerprints(files, gitStatus, takesGitSnapshot);
380
+ const workCwd = sessionWorkCwd(entered, ctx.cwd);
381
+ // The entered worktree's, read only if the epoch's facts came from a compaction there.
382
+ let inWorktree: LiveFingerprints | undefined;
383
+ const atCompaction = () => (inWorktree ??= liveFingerprints(readFiles(workCwd), snapshotGit(workCwd), takesGitSnapshot));
384
+ livePrints = { atStart, atCompaction: workCwd === ctx.cwd ? () => atStart : atCompaction };
385
+ }
386
+ emitContextBlock(gitStatus, false);
395
387
  });
396
388
 
397
- // A session that crosses local midnight learns the new date on its next
398
- // turn, as a one-shot on that turn's prompt (Claude Code's notice), so the
399
- // frozen reminder on message 1 and the cached prefix stay as they are.
400
- const announceDate = () => {
401
- if (!blockDate) return;
389
+ // Inside an epoch, two facts can have moved since its message 1: the date (a
390
+ // session that crosses local midnight learns it, Claude Code's notice), and,
391
+ // on the first request of a resumed process, the workspace the instructions
392
+ // and git blocks describe. Each is said once, on the request's tail.
393
+ pi.events.on(RENDER_CHANNEL, (data) => {
394
+ const request = data as RenderRequest;
395
+ if (request.epoch) {
396
+ // This request renders the live blocks as message 1: with today's date,
397
+ // even when it opens past local midnight (a model switch, a fold).
398
+ resumeCheck = false;
399
+ if (files) adoptShown(files);
400
+ const today = localDate();
401
+ if (blockDate && blockDate !== today) emitDate(today);
402
+ return;
403
+ }
402
404
  const today = localDate();
403
- if (today === shownDate) return;
404
- shownDate = today;
405
- pi.events.emit(REMINDER_CHANNEL, { key: DATE_CHANGE_KEY, text: dateChangeReminder(today) });
406
- publishBaseline();
407
- };
408
- pi.on("before_agent_start", announceDate);
405
+ const toldDate = request.toldBlocks.get(FACT_KEYS.date);
406
+ if (blockDate && toldDate !== undefined && toldDate !== dateFingerprint(today)) {
407
+ request.blocks.push({ text: dateChangeReminder(today), facts: [toldBlockFact(FACT_KEYS.date, today)] });
408
+ // The live block too, so a later epoch (a model switch, a fold) renders today's date on message 1.
409
+ emitDate(today);
410
+ }
411
+ if (!resumeCheck || !livePrints) return;
412
+ const atCompaction = request.modes.get(FACT_KEYS.compaction) ?? false;
413
+ const notice = resumeFactsNotice(request.toldBlocks, atCompaction, atCompaction ? livePrints.atCompaction() : livePrints.atStart);
414
+ // Checked until a request shows the notice told (its facts are the live
415
+ // fingerprints): a failed request revokes them, so the retry says it again.
416
+ if (notice) request.blocks.push(notice);
417
+ else resumeCheck = false;
418
+ });
409
419
 
410
- // All successful compactions (manual, automatic and idle) fire this hook.
411
- // The summary starts a new prefix: refresh facts silently, then persist the
412
- // replacement even if the process exits before another model request.
413
- pi.on("session_compact", (_event, ctx) => {
414
- pendingResume = undefined;
420
+ // All successful compactions (manual, automatic and idle) fire this hook. The
421
+ // summary opens an epoch, which renders these blocks: refresh them from the
422
+ // workspace now, in the entered worktree if any.
423
+ pi.on("session_compact", (_event, ctx) => refreshForCompaction(ctx));
424
+ function refreshForCompaction(ctx: ExtensionContext) {
415
425
  const cwd = sessionWorkCwd(entered, ctx.cwd);
416
426
  const current = readFiles(cwd);
417
427
  adoptFiles(current);
428
+ emitInstructions(current);
418
429
  // Only discarded carriers need reattachment; pi can retain the recent read/tool-result tail.
419
430
  keepRetainedAttachments(ctx);
420
431
  email = resolveEmail(cwd);
421
432
  gitStatus = snapshotGit(cwd);
422
- blockDate = shownDate = localDate();
423
- facts = contextFactsBaseline({ ...current, gitStatus, atCompaction: true, cwd });
424
- const entries: ReminderEntry[] = [];
425
- const add = (key: string, text: string | null, order: number) => {
426
- if (text) entries.push({ key, text, order, placement: "first-prepend" });
427
- };
428
- add(REMINDER_KEY, buildClaudeMdBlock(current), CONTEXT_ORDER.claudeMd);
429
- add(ONECODE_REMINDER_KEY, current.oneCode, CONTEXT_ORDER.oneCodeMd);
430
- add(CONTEXT_REMINDER_KEY, buildContextBlock({ email, gitStatus: compactionGitStatus(gitStatus) }), CONTEXT_ORDER.context);
431
- add(DATE_REMINDER_KEY, dateBlock(blockDate), CONTEXT_ORDER.date);
432
- pi.events.emit(CONTEXT_FACTS_REFRESH_CHANNEL, { entries, baseline: baseline() });
433
- });
433
+ emitContextBlock(compactionGitStatus(gitStatus), true);
434
+ emitDate(localDate());
435
+ }
434
436
 
435
437
  // A branch switch leaves the attachments of the branch left behind; the new one may lack them.
436
- // Its facts are the selected branch's (system-reminder restores its blocks): a branch from
437
- // before a compaction still shows the session-start facts, and a later compaction or date
438
- // notice compares against those.
439
438
  pi.on("session_tree", (_event, ctx) => {
440
- const branch = contextStackOnBranch(ctx.sessionManager.getBranch());
441
- if (branch) {
442
- adoptSnapshot(branch);
443
- if (pendingResume) pendingResume = branch;
444
- publishBaseline();
445
- }
446
439
  restoreAttachments(ctx);
447
- pendingNested.clear();
448
440
  });
449
441
 
450
442
  // A successful read attaches nested instruction files and matching path rules
@@ -481,16 +473,9 @@ export default function claudeContextExtension(pi: ExtensionAPI) {
481
473
  }
482
474
  pi.events.emit(REMINDER_CHANNEL, { text: nestedInstructionText(file), toolCallId: event.toolCallId });
483
475
  }
484
- if (shown.length > 0) pendingNested.set(event.toolCallId, shown);
485
- });
486
-
487
- // Recorded once the request carrying the read's result goes out, so a reload
488
- // before then (the queued text is not persisted) leaves them unshown.
489
- pi.on("context", (event) => {
490
- for (const [toolCallId, keys] of pendingNested) {
491
- if (!event.messages.some((m) => m.role === "toolResult" && m.toolCallId === toolCallId)) continue;
492
- remember(toolCallId, keys);
493
- pendingNested.delete(toolCallId);
494
- }
476
+ // Recorded with the queued text: both are on the branch now (the text as a
477
+ // pending one-shot bound to this result), so a quit before the request
478
+ // keeps them in step, and an abandoned /tree branch takes both away.
479
+ remember(event.toolCallId, shown);
495
480
  });
496
481
  }
@@ -57,6 +57,8 @@ import { looksLikeAnthropicRequest } from "../lib/anthropic-payload.ts";
57
57
  import { forcedReasoningLevel } from "../lib/model-policy.ts";
58
58
  import { captureRequest, extendPayload, LAST_REQUEST_CHANNEL, LastExchange, lastCovered, type RequestCapture, replayOutputCap } from "../lib/request-replay.ts";
59
59
  import { withoutSystemMessages } from "../lib/side-call.ts";
60
+ import { followCoveredMessages } from "../lib/covered-messages.ts";
61
+ import { LEDGER_SENT_CHANNEL, type LedgerSent } from "../lib/ledger/plan.ts";
60
62
  import { claudeCodeCut, type CutEntry, doomedMessages } from "./cut.ts";
61
63
  import { fitToBudget, replayFits, withoutUsage } from "./fit.ts";
62
64
  import { withoutThinking } from "./kept-thinking.ts";
@@ -110,25 +112,26 @@ export default function compactionExtension(pi: ExtensionAPI) {
110
112
  * (findings §27).
111
113
  */
112
114
  let captured: { messages: AgentMessage[]; systemPrompt: string; model: string | undefined } | undefined;
113
- pi.on("context", (event, ctx) => {
114
- captured = { messages: event.messages, systemPrompt: ctx.getSystemPrompt(), model: modelKey(ctx.model) };
115
+ followCoveredMessages(pi, (messages, ctx) => {
116
+ captured = { messages, systemPrompt: ctx.getSystemPrompt(), model: modelKey(ctx.model) };
115
117
  });
116
118
 
117
119
  /**
118
- * The same request as pi put it on the wire, and the reply that answered it
119
- * (lib/request-replay.ts). This extension loads after every extension that
120
- * edits the payload (tool-search, context-management), so the body it sees
121
- * is final; it is published for the btw and recap side calls too.
120
+ * The same request as it went on the wire, and the reply that answered it
121
+ * (lib/request-replay.ts): the body the ledger's payload hook publishes once
122
+ * it has spliced and validated it, so it is final; it is published for the
123
+ * btw and recap side calls too.
122
124
  */
123
125
  const exchange = new LastExchange<AgentMessage & { role: string }>();
124
126
  const publish = (capture: ReturnType<typeof captureRequest>) => {
125
127
  exchange.setCapture(capture);
126
128
  pi.events.emit(LAST_REQUEST_CHANNEL, capture);
127
129
  };
128
- pi.on("before_provider_request", (event, ctx) => {
130
+ pi.events.on(LEDGER_SENT_CHANNEL, (data) => {
131
+ const sent = data as LedgerSent;
129
132
  // The context this body was built from ends where a fork's own messages start.
130
133
  const covers = captured ? lastCovered(captured.messages as { role: string; timestamp?: number }[]) : undefined;
131
- publish(captureRequest(ctx.model as { api?: string; provider?: string; id?: string } | undefined, event.payload, covers, ctx.sessionManager.getSessionId()));
134
+ publish(captureRequest(sent.model, sent.payload, covers, sent.sessionId));
132
135
  });
133
136
  pi.on("message_end", (event) => {
134
137
  exchange.noteMessage(event.message as AgentMessage & { role: string });
@@ -32,17 +32,31 @@ function finite(n: number | null | undefined): number | undefined {
32
32
  * from; `left` takes the size now and returns what remains of the budget. The
33
33
  * value never rises within a turn (as CC's does not), the first known size
34
34
  * becomes the baseline when the turn started without an estimate, and an
35
- * unknown size repeats the last value.
35
+ * unknown size repeats the last value. A compaction inside the turn shrinks
36
+ * the context below the baseline, so `rebase` measures growth from the
37
+ * smaller context on top of what the turn had already spent; without it the
38
+ * countdown stood still until the context regrew past the turn's start.
36
39
  */
37
40
  export class TurnBudget {
38
41
  private start: number | undefined;
42
+ /** Spent since `start`. */
39
43
  private spent = 0;
44
+ /** Spent before the last rebase. */
45
+ private banked = 0;
40
46
 
41
47
  constructor(readonly budget: number) {}
42
48
 
43
49
  startTurn(contextTokens: number | null | undefined): void {
44
50
  this.start = finite(contextTokens);
45
51
  this.spent = 0;
52
+ this.banked = 0;
53
+ }
54
+
55
+ /** Measure from `contextTokens` (or the next known size) on, keeping what the turn spent so far. */
56
+ rebase(contextTokens: number | null | undefined): void {
57
+ this.banked += this.spent;
58
+ this.spent = 0;
59
+ this.start = finite(contextTokens);
46
60
  }
47
61
 
48
62
  left(contextTokens: number | null | undefined): number {
@@ -51,7 +65,7 @@ export class TurnBudget {
51
65
  if (this.start === undefined) this.start = now;
52
66
  this.spent = Math.max(this.spent, now - this.start);
53
67
  }
54
- return Math.max(0, this.budget - this.spent);
68
+ return Math.max(0, this.budget - this.banked - this.spent);
55
69
  }
56
70
  }
57
71
 
@@ -9,24 +9,21 @@
9
9
  * (CONTEXT_ORDER.totalTokens), so it joins the mid-conversation system
10
10
  * message where there is one;
11
11
  * - every later user message: the constant budget as a raw marker, a `sticky-append`
12
- * reminder keyed once per session (`since: 0`, so a resumed session's earlier
13
- * user messages carry it too — byte-identical to what they carried before);
12
+ * reminder keyed once per session (`since: 0`: every user message a request
13
+ * sends carries it, the first prompt excepted);
14
14
  * - every tool result: the countdown, a RAW `last-append` one-shot queued at
15
- * `tool_result` time and written into the stored result by system-reminder
16
- * (so it persists in the session like CC's and no later request re-caches the
17
- * message). That is why this extension sits BEFORE `system-reminder` in the
18
- * package manifest — pi runs `tool_result` handlers in load order, and the
19
- * block must be queued before system-reminder's handler takes the one-shots.
20
- * It emits only at runtime, never at load, so the "listeners load first" rule
21
- * for the bus is not at stake. The block is bound to its call's id: pi runs a
22
- * parallel batch's `tool_result` hooks concurrently, and unbound, the first
23
- * result would take every queued countdown and the others none;
15
+ * `tool_result` time and bound to its call's id, so it rides that result on
16
+ * the next request (pi runs a parallel batch's `tool_result` hooks
17
+ * concurrently; unbound, every countdown would land on the batch's last
18
+ * result). The request that carries it records it (extensions/ledger), so no
19
+ * later request re-renders it;
24
20
  * - the system prompt, before the gitStatus block — appended by the
25
21
  * system-prompt extension from the same pure module.
26
22
  *
27
23
  * A turn starts at the first `agent_start` after a settle (whoever opened it: a
28
24
  * prompt or a harness notification) and its baseline is pi's context estimate
29
- * at that moment. CC_TOTAL_TOKENS=0 turns all three off.
25
+ * at that moment; a compaction rebases it (budget.ts). CC_TOTAL_TOKENS=0 turns
26
+ * all three off.
30
27
  */
31
28
 
32
29
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -47,14 +44,12 @@ export default function contextBudgetExtension(pi: ExtensionAPI) {
47
44
  if (!enabled()) return;
48
45
  pi.events.emit(REMINDER_CHANNEL, {
49
46
  text: totalTokensBlock(budget.budget),
50
- scope: "every-turn",
51
47
  key: FIRST_PROMPT_KEY,
52
48
  placement: "first-prepend",
53
49
  order: CONTEXT_ORDER.totalTokens,
54
50
  } satisfies ReminderPayload);
55
51
  pi.events.emit(REMINDER_CHANNEL, {
56
52
  text: totalTokensBlock(budget.budget),
57
- scope: "every-turn",
58
53
  key: USER_MESSAGE_KEY,
59
54
  placement: "sticky-append",
60
55
  raw: true,
@@ -72,6 +67,12 @@ export default function contextBudgetExtension(pi: ExtensionAPI) {
72
67
  pi.on("agent_settled", () => {
73
68
  busy = false;
74
69
  });
70
+ // A compaction (an auto one mid-turn, an overflow retry) drops the context
71
+ // below the turn's baseline; pi knows its new size only after the next reply,
72
+ // so the first size it reports becomes the baseline.
73
+ pi.on("session_compact", (_event, ctx) => {
74
+ budget.rebase(ctx.getContextUsage()?.tokens);
75
+ });
75
76
 
76
77
  pi.on("tool_result", (event, ctx) => {
77
78
  if (!enabled()) return;