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
@@ -26,7 +26,7 @@
26
26
  * and pi's event bus only delivers to listeners already registered.
27
27
  */
28
28
 
29
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
+ import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
30
30
  import { Type } from "typebox";
31
31
  import {
32
32
  DEFER_CHANNEL,
@@ -47,22 +47,37 @@ import {
47
47
  supportsToolReferences,
48
48
  toolNotFoundName,
49
49
  toolSearchLoads,
50
+ toolsInUse,
51
+ callableDeferredTools,
52
+ reconcileActive,
50
53
  WITHHOLD_CHANNEL,
54
+ DEFERRED_QUERY_CHANNEL,
55
+ type DeferredQuery,
56
+ deferredRemovedText,
51
57
  withheldMissReminderText,
58
+ unavailableToolReminderText,
52
59
  } from "../lib/deferred.ts";
53
60
  import { looksLikeAnthropicRequest, MID_CONVERSATION_SYSTEM_BETA, withBetas } from "../lib/anthropic-payload.ts";
54
- import { addendumNamesOnBranch, liftAddenda, supportsToolAdditions, TOOL_ADDITION_BETAS, withToolAdditions } from "../lib/tool-additions.ts";
55
- import { MCP_TOOLS_CHANNEL, type McpToolsPayload } from "../lib/mcp-share.ts";
56
- import { CONTEXT_ORDER, REMINDER_CHANNEL } from "../lib/reminders.ts";
61
+ import { liftAddenda, supportsToolAdditions, TOOL_ADDITION_BETAS, withToolAdditions } from "../lib/tool-additions.ts";
62
+ import { inContextEntries } from "../lib/compaction-boundary.ts";
63
+ import { CALLABLE_TOOLS } from "../lib/ledger/fold.ts";
64
+ import { foldOnce, sameEntries } from "../lib/ledger/fold-memo.ts";
65
+ import { recordedToolNames } from "../lib/ledger/wire.ts";
66
+ import { MCP_TOOLS_CHANNEL } from "../lib/mcp-share.ts";
67
+ import { sessionIdentity } from "../lib/anthropic-auth.ts";
68
+ import type { EntryLike } from "../lib/ledger/records.ts";
69
+ import { CONTEXT_ORDER, REMINDER_CHANNEL, RENDER_CHANNEL, type RenderRequest } from "../lib/reminders.ts";
57
70
  import { sessionAlive } from "../lib/session-lifecycle.ts";
58
71
  import { ccToolRenderers } from "../lib/tui-render.ts";
59
72
  import { normalizeToolName } from "../permissions/matcher.ts";
60
- import { applyAnnouncement, deferredToolsBaseline, deferredToolsFromReminder, planAnnouncement } from "./announce.ts";
61
- import { CONTEXT_BASELINE_CHANNEL, restoredContext } from "../lib/context-stack.ts";
62
73
  import { registerLocalCommand } from "../lib/local-command.ts";
63
74
 
64
- /** Coalesces the burst of per-tool defers one server emits into one listing update. */
65
- const ANNOUNCE_DEBOUNCE_MS = 100;
75
+ /** The told mode for "this epoch's message 1 lists deferred tools this run cannot load", said once per epoch. */
76
+ const UNAVAILABLE_MODE = "deferred-unavailable";
77
+
78
+ /** A session manager as far as the reconciler reads it (a test double may lack `getBranch`). */
79
+ type SessionLike = { getBranch?: () => unknown[] };
80
+ const branchOf = (session: SessionLike | undefined) => (session?.getBranch?.() ?? []) as EntryLike[];
66
81
 
67
82
  export default function toolSearchExtension(pi: ExtensionAPI) {
68
83
  // Owned here (see lib/deferred.ts): the only instance, fed over DEFER_CHANNEL.
@@ -74,31 +89,24 @@ export default function toolSearchExtension(pi: ExtensionAPI) {
74
89
  // A re-defer (a model change bringing it back) clears the name from here.
75
90
  const withheldNames = new Set<string>();
76
91
  let sessionStarted = false;
77
- // The deferred-tools listing is a first-prepend reminder on message 1, part of
78
- // the cached prefix of every later request. Before the first request nothing
79
- // is cached, so the listing is (re)written freely; after it the listing is
80
- // FROZEN and later arrivals ride a one-shot addendum where the model reads
81
- // next (./announce.ts — a rewrite re-cached 16.7k tokens of history on
82
- // request 2 of every run whose MCP tools registered inside the debounce,
83
- // measured 2026-09-04). While the initial MCP connect is still settling the
84
- // addendum waits for settle, so one burst of per-tool defers is one notice.
85
- let requestSent = false;
86
- let mcpSettled = false;
87
- let announcePending = false;
88
- let announceTimer: NodeJS.Timeout | undefined;
89
92
  // After session_shutdown every pi.* call throws (the runner is invalidated,
90
- // findings §8); a debounced announce landing in a timer callback then would
91
- // be an uncaught exception and take pi down (LIFECYCLE-REVIEW L1).
93
+ // findings §8): an event that lands then must do nothing (LIFECYCLE-REVIEW L1).
92
94
  const alive = sessionAlive(pi);
93
- /** Names the model has been told about: the frozen listing plus every addendum. */
94
- const announced = new Set<string>();
95
- /** The session's model as last seen, for an announcement made outside any hook. */
96
- let latestModel: { provider?: string; api?: string; compat?: unknown } | undefined;
95
+ /** The session's manager, for folding what the current epoch has told and loaded outside a hook that carries one. */
96
+ let session: SessionLike | undefined;
97
+ /** The session's model, for a decision made outside a hook that carries one. */
98
+ let currentModel: { provider?: string; api?: string; id?: string; compat?: unknown } | undefined;
99
+ /**
100
+ * The branch's fold with the live identity, so a model or auth switch
101
+ * counts. Shared with the ledger (lib/ledger/fold-memo.ts): the fold this
102
+ * turn_start takes serves the request's `context` hook, and a server's
103
+ * connect defers its tools in a burst with nothing appended between.
104
+ */
105
+ const foldOf = (owner: SessionLike | undefined, branch: EntryLike[], model: typeof currentModel) => foldOnce(owner, branch, sessionIdentity(pi, model));
97
106
 
98
107
  /** Whether tool_search is registered; a `--tools` list without it turns deferral off (lib/deferred.ts). */
99
108
  const deferring = () => deferralEnabled(pi.getAllTools());
100
- const searchableTools = () => {
101
- const all = pi.getAllTools();
109
+ const searchableTools = (all = pi.getAllTools()) => {
102
110
  if (!deferralEnabled(all)) return [];
103
111
  return all
104
112
  .filter((tool) => deferredRegistry.has(tool.name))
@@ -109,74 +117,80 @@ export default function toolSearchExtension(pi: ExtensionAPI) {
109
117
  }));
110
118
  };
111
119
 
112
- const publishBaseline = (names: ReadonlySet<string>) => {
113
- pi.events.emit(CONTEXT_BASELINE_CHANNEL, { key: "tool-search", value: [...names] });
114
- };
115
- const announce = () => {
120
+ /**
121
+ * The deferred-tools listing as live state: what an epoch's message 1 says.
122
+ * Inside an epoch message 1 is the record, so a change here is announced by
123
+ * the render step below, never by rewriting it.
124
+ */
125
+ const publishListing = () => {
116
126
  if (!alive()) return;
117
127
  const available = searchableTools();
118
- const plan = planAnnouncement({ requestSent, announced, available: available.map((t) => t.name) });
119
- // Store the post-announcement state before the next context pass can place it.
120
- if (plan.kind !== "none") {
121
- const next = new Set(announced);
122
- applyAnnouncement(next, plan);
123
- publishBaseline(next);
128
+ if (available.length === 0) {
129
+ pi.events.emit(REMINDER_CHANNEL, { key: "deferred-tools", remove: true });
130
+ return;
124
131
  }
125
- if (plan.kind === "rewrite") {
126
- pi.events.emit(REMINDER_CHANNEL, {
127
- scope: "every-turn",
128
- key: "deferred-tools",
129
- text: deferredReminderText(available),
130
- placement: "first-prepend",
131
- order: CONTEXT_ORDER.deferredTools,
132
- });
133
- } else if (plan.kind === "addendum") {
134
- // Unkeyed on purpose: a keyed next-turn reminder replaces its
135
- // predecessor, and an addendum replaced before delivery would lose names.
136
- pi.events.emit(REMINDER_CHANNEL, { text: deferredAddendumText(plan.added) });
137
- // Where the request carries it as Claude Code's tool_addition, the model
138
- // calls the tool directly, so pi's dispatcher must already accept it.
139
- if (supportsToolAdditions(latestModel)) {
140
- for (const name of plan.added) loadedBefore.add(name);
141
- pi.setActiveTools([...new Set([...pi.getActiveTools(), ...plan.added])]);
142
- }
143
- }
144
- applyAnnouncement(announced, plan);
145
- };
146
-
147
- const clearAnnounce = () => {
148
- if (announceTimer) clearTimeout(announceTimer);
149
- announceTimer = undefined;
150
- };
151
- const scheduleAnnounce = () => {
152
- clearAnnounce();
153
- if (!alive()) return;
154
- announceTimer = setTimeout(() => {
155
- announceTimer = undefined;
156
- // Decided at fire time, not schedule time: a request may have gone out
157
- // during the debounce (the -p race).
158
- if (requestSent && !mcpSettled) {
159
- announcePending = true;
160
- return;
161
- }
162
- announce();
163
- }, ANNOUNCE_DEBOUNCE_MS);
164
- announceTimer.unref?.();
132
+ pi.events.emit(REMINDER_CHANNEL, {
133
+ key: "deferred-tools",
134
+ text: deferredReminderText(available),
135
+ placement: "first-prepend",
136
+ order: CONTEXT_ORDER.deferredTools,
137
+ facts: [{ kind: "tools", names: available.map((tool) => tool.name) }],
138
+ });
165
139
  };
166
140
 
141
+ /** Deferred tools this process has seen loadable: one of them missing later is known to be gone. */
142
+ const seenHere = new Set<string>();
143
+ /** True once the MCP extension published its settled tool set: an MCP tool missing then is gone. */
144
+ let mcpSettled = false;
167
145
  pi.events.on(MCP_TOOLS_CHANNEL, (data) => {
168
- if (!(data as McpToolsPayload | undefined)?.settled) return;
169
- mcpSettled = true;
170
- if (announcePending) {
171
- announcePending = false;
172
- announce();
146
+ if ((data as { settled?: boolean } | undefined)?.settled) mcpSettled = true;
147
+ });
148
+
149
+ // Inside an epoch, a deferred tool that registered since the epoch's listing
150
+ // is announced on the request's tail: computed when the request goes out,
151
+ // from what is loadable then and what the epoch has told, so a tool withheld
152
+ // in the meantime is never announced (the H1 class). Where the model takes
153
+ // Claude Code's tool_addition, the addendum becomes one (the context and
154
+ // payload hooks below) and the tool is callable at once.
155
+ pi.events.on(RENDER_CHANNEL, (data) => {
156
+ const request = data as RenderRequest;
157
+ if (request.epoch) return;
158
+ const told = request.told.get("tools") ?? new Set<string>();
159
+ if (!deferring()) {
160
+ // The epoch's message 1 lists deferred tools this run cannot load.
161
+ if (told.size > 0 && !request.modes.has(UNAVAILABLE_MODE)) {
162
+ request.blocks.push({ text: loaderUnavailableReminderText(), facts: [{ kind: "mode", key: UNAVAILABLE_MODE, on: true }] });
163
+ }
164
+ return;
165
+ }
166
+ // A tool told and missing now is announced gone once its absence is known
167
+ // (the fold then holds it untold, so a return is announced as new): this
168
+ // process saw it leave, or it is an MCP tool and the MCP connects have
169
+ // settled (a resume sends its first request before a slow server
170
+ // registers, and a fork never had the parent's own tools). A tool
171
+ // withdrawn for the model is not gone: a call to it says so.
172
+ const loadable = new Set(searchableTools().map((tool) => tool.name));
173
+ for (const name of loadable) seenHere.add(name);
174
+ const absent = (name: string) => seenHere.has(name) || (name.startsWith("mcp__") && mcpSettled);
175
+ const removed = [...told].filter((name) => !loadable.has(name) && !withheldNames.has(name) && absent(name));
176
+ if (removed.length > 0) request.blocks.push({ text: deferredRemovedText(removed), facts: [{ kind: "tools", names: removed, removed: true }] });
177
+ const added = [...loadable].filter((name) => !told.has(name));
178
+ if (added.length === 0) return;
179
+ if (!supportsToolAdditions(request.model)) {
180
+ request.blocks.push({ text: deferredAddendumText(added), facts: [{ kind: "tools", names: added }] });
181
+ return;
173
182
  }
183
+ // Here an addendum is a tool_addition, callable at once: only a tool the
184
+ // reconciler activated before pi fixed this turn's tools (turn_start) is
185
+ // announced; one that arrived since waits for the next request.
186
+ const active = new Set(pi.getActiveTools());
187
+ const callable = added.filter((name) => active.has(name));
188
+ if (callable.length > 0) request.blocks.push({ text: deferredAddendumText(callable), facts: [{ kind: "tools", names: callable, callable: true }] });
174
189
  });
175
190
 
176
191
  /** The addenda the last `context` pass lifted off tool results, by call id (lib/tool-additions.ts). */
177
192
  let addendaByCall = new Map<string, string[]>();
178
193
  pi.on("context", (event, ctx) => {
179
- requestSent = true;
180
194
  addendaByCall = new Map();
181
195
  if (!supportsToolAdditions(ctx?.model) || !Array.isArray(event?.messages)) return undefined;
182
196
  const lifted = liftAddenda(event.messages);
@@ -184,33 +198,97 @@ export default function toolSearchExtension(pi: ExtensionAPI) {
184
198
  return lifted.messages === event.messages ? undefined : { messages: lifted.messages };
185
199
  });
186
200
 
187
- // Every tool_search load of this session (call id → names), seeded from the
188
- // transcript at session_start and extended by each load: the wire hook needs
189
- // it to reference a loaded tool from the result that loaded it.
201
+ // Every tool_search load still in context (call id → names), derived from the
202
+ // branch by each reconcile, plus the loads this turn made that are not on the
203
+ // branch yet: the wire hook needs it to reference a loaded tool from the
204
+ // result that loaded it.
190
205
  let loads = new Map<string, string[]>();
191
- // The loads a resumed session's transcript already holds (empty after
192
- // /clear). The transcript tells the model those tools are callable, and
193
- // Claude Code keeps a ToolSearch-loaded tool callable across a resume (its
194
- // tool_reference blocks stay in the history), so deferring them again would
195
- // send the model into a "Tool <name> not found" round trip. pi restores them
196
- // as active from the transcript before session_start; deferAll and the
197
- // late-defer handler leave those alone. A load made during this session
198
- // activates the tool directly and never needs this set.
199
- let loadedBefore = new Set<string>();
206
+ /** Loads `tool_search` made since the last turn started: their results reach the branch when the batch ends. */
207
+ const freshLoads = new Map<string, string[]>();
208
+
209
+ /** What the branch's in-context history loaded and uses, for the branch it was read from. */
210
+ let used: { entries: readonly EntryLike[]; loads: ReadonlyMap<string, string[]>; inUse: ReadonlySet<string> } | undefined;
211
+ /**
212
+ * The branch's in-context loads and tools in use, read again only when the
213
+ * branch changed (lib/ledger/fold-memo.ts `sameEntries`): the reconciler runs
214
+ * at every turn, model switch, defer and withhold, and a server's connect
215
+ * defers its tools in a burst with nothing appended between. Both are pure
216
+ * functions of the entries; the registry, the model and this turn's loads
217
+ * are applied on top at each run.
218
+ */
219
+ const branchUse = (branch: EntryLike[]) => {
220
+ if (used && sameEntries(used.entries, branch)) return used;
221
+ const inContext = inContextEntries(branch) as SessionEntry[];
222
+ const loads = toolSearchLoads(inContext);
223
+ used = { entries: [...branch], loads, inUse: toolsInUse(inContext, loads) };
224
+ return used;
225
+ };
226
+
227
+ /**
228
+ * The one place deferred tools are activated and deactivated (lib/deferred.ts
229
+ * callableDeferredTools): a deferred tool is active exactly when the branch
230
+ * gives a reason. Its history uses it (a load or a call in context), the
231
+ * epoch announced it callable, the recorded head the next request starts
232
+ * from declares it eagerly, or the next request announces it callable.
233
+ * Claude Code keeps a loaded tool callable across a resume (its
234
+ * tool_reference blocks stay in the history); a compaction returns to
235
+ * deferred what its kept turns no longer use, and keeps what the last
236
+ * request declared eagerly (removing it from `tools` would be the miss). A
237
+ * withheld tool is never active. Run before pi fixes a turn's tools
238
+ * (turn_start) and whenever the inputs change.
239
+ */
240
+ const reconcile = (owner: SessionLike | undefined, model: typeof currentModel) => {
241
+ if (!alive()) return;
242
+ const all = pi.getAllTools();
243
+ const registered = new Set(all.map((tool) => tool.name));
244
+ if (!deferralEnabled(all)) {
245
+ // Nothing is deferred without the loader, but a withheld tool is still
246
+ // never active: a /tree switch restores a loadout from before it left.
247
+ const active = pi.getActiveTools();
248
+ const next = reconcileActive(active, new Set(), new Set(), withheldNames, registered);
249
+ if (next !== active) pi.setActiveTools([...next]);
250
+ return;
251
+ }
252
+ const branch = branchOf(owner);
253
+ const use = branchUse(branch);
254
+ loads = new Map([...use.loads, ...freshLoads]);
255
+ const deferred = new Set(deferredRegistry.names);
256
+ // With nothing deferred no deferred tool is callable: the active set
257
+ // loses only withheld tools, and the branch's fold has nothing to decide.
258
+ let callable = new Set<string>();
259
+ if (deferred.size > 0) {
260
+ const fold = foldOf(owner, branch, model);
261
+ const inUse = new Set(use.inUse);
262
+ for (const names of freshLoads.values()) for (const name of names) inUse.add(name);
263
+ const last = fold.lastRequest;
264
+ const told = fold.told.get("tools") ?? new Set<string>();
265
+ const announcing = supportsToolAdditions(model) && !fold.opensEpoch ? new Set(searchableTools(all).map((tool) => tool.name).filter((name) => !told.has(name))) : new Set<string>();
266
+ callable = callableDeferredTools(deferred, {
267
+ inUse,
268
+ toldCallable: fold.told.get(CALLABLE_TOOLS) ?? new Set(),
269
+ declaredEager: last ? recordedToolNames(last, { eager: true }) : new Set(),
270
+ announcing,
271
+ });
272
+ }
273
+ const active = pi.getActiveTools();
274
+ const next = reconcileActive(active, deferred, callable, withheldNames, registered);
275
+ if (next !== active) pi.setActiveTools([...next]);
276
+ };
277
+ const reconcileNow = () => reconcile(session, currentModel);
200
278
 
201
279
  // On models that take client-side tool references (first-party Claude >= 4.5,
202
- // not Haiku) the `tools` array is held byte-identical to request 1's: every
203
- // deferred tool rides every Anthropic request as a `defer_loading: true`
204
- // definition, and a tool that tool_search loaded — which pi 0.86 promotes into
205
- // the eager list, re-caching everything from the tools block down — is
206
- // demoted back to deferred and loaded through `tool_reference` blocks in the
207
- // tool_search result that activated it (lib/deferred.ts stabilizeDeferredTools
208
- // has the rules; working-docs/decisions/caching.md the measurements).
209
- // On OpenAI Responses models with native tool loading (GPT-5.4 and newer on
210
- // openai and openai-codex) a loaded tool leaves `tools` for pi-ai's own
211
- // load item at the tool_search result instead (stabilizeResponsesToolLoads).
280
+ // not Haiku) every deferred tool rides every Anthropic request as a
281
+ // `defer_loading: true` definition, and a tool that tool_search loaded — which
282
+ // pi promotes into the eager list — is demoted back to deferred and loaded
283
+ // through `tool_reference` blocks in the tool_search result that activated it
284
+ // (lib/deferred.ts stabilizeDeferredTools has the rules;
285
+ // working-docs/decisions/caching.md the measurements). On OpenAI Responses
286
+ // models with native tool loading (GPT-5.4 and newer on openai and
287
+ // openai-codex) a loaded tool leaves `tools` for pi-ai's own load item at the
288
+ // tool_search result instead (stabilizeResponsesToolLoads). Inside an epoch
289
+ // the request carries only its tail here: the ledger's payload hook, later,
290
+ // puts the recorded list in front and keeps the recorded tools.
212
291
  pi.on("before_provider_request", (event, ctx) => {
213
- latestModel = ctx.model;
214
292
  const allTools = pi.getAllTools();
215
293
  const enabled = deferralEnabled(allTools);
216
294
  const toolLoadCompat = responsesToolLoadCompat(ctx.model as ResponsesToolLoadModel | undefined);
@@ -238,33 +316,22 @@ export default function toolSearchExtension(pi: ExtensionAPI) {
238
316
  return lifted ? withBetas(stable, TOOL_ADDITION_BETAS) : stable;
239
317
  });
240
318
 
241
-
242
- /** Deactivate every deferred-registry tool the transcript has not loaded, and announce the loadable set. */
243
- const deferAll = () => {
244
- const deferred = new Set(deferredRegistry.names);
245
- if (deferred.size === 0 || !deferring()) return;
246
- const active = pi.getActiveTools();
247
- const next = active.filter((name) => !deferred.has(name) || loadedBefore.has(name));
248
- if (next.length !== active.length) pi.setActiveTools(next);
249
- announce();
250
- };
319
+ pi.events.on(DEFERRED_QUERY_CHANNEL, (data) => {
320
+ const query = data as DeferredQuery;
321
+ for (const name of deferredRegistry.names) query.tools.push({ name, keywords: deferredRegistry.keywordsFor(name) });
322
+ });
251
323
 
252
324
  pi.events.on(DEFER_CHANNEL, (data) => {
253
325
  const request = data as DeferRequest;
254
326
  deferredRegistry.add(request);
327
+ // A withheld tool brought back is deferred again.
255
328
  if (request?.name) withheldNames.delete(request.name);
256
329
  // A defer arriving after the session_start pass (MCP servers connect
257
330
  // asynchronously and register their tools then) would otherwise leave the
258
- // tool eager AND unlisted in the reminder. Deactivate it (unless the
259
- // transcript loaded it) and refresh the keyed reminder.
260
- if (sessionStarted && request?.name && deferring()) {
261
- const active = pi.getActiveTools();
262
- if (active.includes(request.name) && !loadedBefore.has(request.name)) {
263
- pi.setActiveTools(active.filter((name) => name !== request.name));
264
- }
265
- // The tool is searchable (keyword search reads the registry) from now;
266
- // only the standing listing waits.
267
- scheduleAnnounce();
331
+ // tool eager AND unlisted.
332
+ if (sessionStarted && request?.name && deferring() && alive()) {
333
+ reconcileNow();
334
+ publishListing();
268
335
  }
269
336
  });
270
337
 
@@ -273,49 +340,47 @@ export default function toolSearchExtension(pi: ExtensionAPI) {
273
340
  if (!name || !deferredRegistry.remove(name)) return;
274
341
  withheldNames.add(name);
275
342
  if (!alive()) return;
276
- const active = pi.getActiveTools();
277
- if (active.includes(name)) pi.setActiveTools(active.filter((n) => n !== name));
278
- // Before the first request the listing is still free to change: rewrite it
279
- // now, synchronously, so a request going out right after session_start
280
- // never names the withheld tool. After it the frozen listing stands.
281
- if (sessionStarted && !requestSent) announce();
343
+ reconcileNow();
344
+ publishListing();
345
+ });
346
+
347
+ // A /tree switch keeps the process: pi restored the selected branch's
348
+ // loadout, and the selected branch decides which deferred tools are callable
349
+ // and referenced there.
350
+ pi.on("session_tree", (_event, ctx) => {
351
+ freshLoads.clear();
352
+ reconcile(ctx?.sessionManager, ctx?.model ?? currentModel);
282
353
  });
283
354
 
284
355
  pi.on("session_start", (_event, ctx) => {
285
356
  sessionStarted = true;
286
- // A pending debounce belongs to the previous session's listing (RPC's
287
- // new_session emits session_start twice, findings §3); deferAll below
288
- // announces the fresh one.
289
- clearAnnounce();
290
- latestModel = ctx?.model;
291
- const branch = ctx?.sessionManager?.getBranch?.() ?? [];
292
- loads = toolSearchLoads(branch);
293
- loadedBefore = new Set([...loads.values()].flat());
294
- // Tools added mid-session by tool_addition stay callable after a resume.
295
- if (supportsToolAdditions(latestModel)) for (const name of addendumNamesOnBranch(branch)) loadedBefore.add(name);
296
- const restored = restoredContext(pi.events);
297
- // A restored stack is already message 1. Seed its announced names so a live
298
- // capability change becomes one addendum, not a rewrite or duplicate notice.
299
- requestSent = restored !== undefined;
300
- announced.clear();
301
- const baseline = deferredToolsBaseline(restored?.baselines["tool-search"]);
302
- const legacy = deferredToolsFromReminder(restored?.stack.find((entry) => (entry as { key?: string }).key === "deferred-tools")?.text);
303
- for (const name of baseline ?? legacy ?? []) announced.add(name);
304
- // Native tool_addition entries can outlive an older baseline snapshot; they
305
- // already told the model about these names, so merge them before planning.
306
- for (const name of addendumNamesOnBranch(branch)) announced.add(name);
307
- deferAll();
308
- // A restored message 1 keeps its listing (its bytes are locked), so when
309
- // this run's tool list leaves tool_search out, say once that it is gone.
310
- if (restored && announced.size > 0 && !deferring()) {
311
- pi.events.emit(REMINDER_CHANNEL, { scope: "next-turn", key: "deferred-unavailable", text: loaderUnavailableReminderText() });
312
- }
313
- // Even an absent listing is meaningful on resume: preserve that baseline so
314
- // the first later deferred tool is a tail notice, not a message-1 rewrite.
315
- if (!restored) publishBaseline(announced);
357
+ currentModel = ctx?.model;
358
+ session = ctx?.sessionManager;
359
+ freshLoads.clear();
360
+ reconcileNow();
361
+ publishListing();
362
+ });
363
+ pi.on("model_select", (event) => {
364
+ currentModel = event.model;
365
+ reconcileNow();
366
+ });
367
+ // Every turn's tools are fixed right after this (pi's prepareRequest): the
368
+ // tools the request announces callable must be active by then.
369
+ pi.on("turn_start", (_event, ctx) => {
370
+ freshLoads.clear();
371
+ if (ctx?.model) currentModel = ctx.model;
372
+ reconcileNow();
316
373
  });
317
374
 
318
- pi.on("session_shutdown", clearAnnounce);
375
+ // A compaction opens an epoch, which lists every deferred tool again. The
376
+ // loads that named a loaded tool went with the summary: the tool goes back to
377
+ // deferred, so the new epoch declares it the way it declares the rest (Claude
378
+ // Code declares a deferred tool only while a reference to it is in the
379
+ // history), unless the reconciler keeps it.
380
+ pi.on("session_compact", (_event, ctx) => {
381
+ reconcile(ctx?.sessionManager, ctx?.model ?? currentModel);
382
+ publishListing();
383
+ });
319
384
 
320
385
  // The every-turn deferred-tools list tells the model to load via tool_search,
321
386
  // but if it calls a deferred tool directly anyway, pi's core dispatcher fails
@@ -334,7 +399,6 @@ export default function toolSearchExtension(pi: ExtensionAPI) {
334
399
  // Without tool_search there is nothing to steer a deferred-tool miss to.
335
400
  if (deferredRegistry.has(name) && deferring()) {
336
401
  pi.events.emit(REMINDER_CHANNEL, {
337
- scope: "next-turn",
338
402
  key: `deferred-miss-${name}`,
339
403
  text: deferredMissReminderText(name),
340
404
  });
@@ -343,10 +407,17 @@ export default function toolSearchExtension(pi: ExtensionAPI) {
343
407
  // the ordinary steer above would send the model to tool_search for a
344
408
  // name that will not be found there either.
345
409
  pi.events.emit(REMINDER_CHANNEL, {
346
- scope: "next-turn",
347
410
  key: `deferred-miss-${name}`,
348
411
  text: withheldMissReminderText(name),
349
412
  });
413
+ } else if (session && recordedToolNames(foldOf(session, branchOf(session), currentModel).lastRequest ?? { head: {}, list: [] }).has(name)) {
414
+ // The epoch's recorded tools stay declared after the tool left the
415
+ // session (a setting turned it off): the call cannot be salvaged, and
416
+ // the model must not keep trying it.
417
+ pi.events.emit(REMINDER_CHANNEL, {
418
+ key: `deferred-miss-${name}`,
419
+ text: unavailableToolReminderText(name),
420
+ });
350
421
  }
351
422
  });
352
423
 
@@ -421,7 +492,10 @@ export default function toolSearchExtension(pi: ExtensionAPI) {
421
492
  const now = new Set(pi.getActiveTools());
422
493
  const added = toActivate.filter((name) => now.has(name));
423
494
  const refused = toActivate.filter((name) => !now.has(name));
424
- if (added.length > 0) loads.set(toolCallId, added);
495
+ if (added.length > 0) {
496
+ freshLoads.set(toolCallId, added);
497
+ loads.set(toolCallId, added);
498
+ }
425
499
 
426
500
  const loaded = [...new Set([...matchNames, ...eager])].filter((name) => now.has(name));
427
501
  const already = loaded.filter((name) => !added.includes(name));
@@ -41,6 +41,7 @@ import { withoutUnusable } from "../lib/model-unusable.ts";
41
41
  import { cleanupWorktree, createWorktree, isGitRepo, type Worktree } from "../subagents/worktree.ts";
42
42
  import type { AgentCallOptions, AgentCallResult, AgentEffort, AgentRunUpdate, KeptWorktree } from "./types.ts";
43
43
  import { WorkflowScriptError } from "./types.ts";
44
+ import { type DeferRequest, deferLikeParentExtension, injectedByParent } from "../lib/deferred.ts";
44
45
 
45
46
  const MAX_SCHEMA_RETRIES = 2;
46
47
  const OUTPUT_CAP = 50_000;
@@ -78,6 +79,8 @@ export interface AgentRunnerOptions {
78
79
  * as custom tools over the parent's connections, as the subagent runner does.
79
80
  */
80
81
  getMcpTools?: () => ToolDefinition[] | Promise<ToolDefinition[]>;
82
+ /** The tools the parent defers (lib/deferred.ts DEFERRED_QUERY_CHANNEL): the agent defers them too, so its MCP tools are listed, not declared. */
83
+ getParentDeferred?: () => DeferRequest[];
81
84
  /** Where an answer past OUTPUT_CAP is saved (`<dir>/tool-results/`): the run's directory. */
82
85
  resultsDir?: string;
83
86
  }
@@ -299,7 +302,7 @@ export class AgentRunner {
299
302
  extraExtensionPaths: CHILD_EXTENSION_PATHS,
300
303
  // claude-context (a child extension) injects the CLAUDE.md instructions itself.
301
304
  noContextFiles: true,
302
- ...(handback ? { extraFactories: [subagentHandbackExtension(handback)] } : {}),
305
+ extraFactories: [deferLikeParentExtension(this.options.getParentDeferred?.() ?? [], injectedByParent([])), ...(handback ? [subagentHandbackExtension(handback)] : [])],
303
306
  getPermissionBridge: this.options.getPermissionBridge,
304
307
  getHookBridge: this.options.getHookBridge,
305
308
  },
@@ -76,6 +76,7 @@ import { isKeyRelease, keyId } from "../lib/key-input.ts";
76
76
  import { projectConfigDirName } from "../lib/config-mode.ts";
77
77
  import { panelUi } from "../lib/panel-focus.ts";
78
78
  import { dockRowsReporter, trackDockRows } from "../lib/dock-rows.ts";
79
+ import { queryDeferred } from "../lib/deferred.ts";
79
80
 
80
81
  /**
81
82
  * Claude Code's own arming reminder, verbatim in intent: the keyword is a
@@ -268,6 +269,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
268
269
  getPermissionBridge,
269
270
  getHookBridge,
270
271
  getMcpTools,
272
+ getParentDeferred: () => queryDeferred(pi.events),
271
273
  // Workflow agents run in their own sessions; their spend reaches the footer only through the bus.
272
274
  onUsage: (cost) => recordUsage(pi, "subagent", { cost: { total: cost } }),
273
275
  unusableModels: () => unusableModels,
@@ -342,15 +344,21 @@ export default function workflowExtension(pi: ExtensionAPI) {
342
344
  // comes back if a later session turns it on) and the keyword arms nothing.
343
345
  let workflowsEnabled = true;
344
346
  let workflowWithheld = false;
347
+ /** Take the tool out of the active set while workflows are off; true when it was active. */
348
+ const withholdIfOff = (): boolean => {
349
+ const active = pi.getActiveTools();
350
+ if (workflowsEnabled || !active.includes("workflow")) return false;
351
+ pi.setActiveTools(active.filter((name) => name !== "workflow"));
352
+ return true;
353
+ };
345
354
  const applyWorkflowSettings = (cwd: string) => {
346
355
  const settings = readWorkflowSettings(cwd, os.homedir());
347
356
  workflowsEnabled = settings.enabled;
348
357
  setWorkflowDescription(workflowDescription(settings.sizeGuideline, settings.sizeConfigured));
349
- const active = pi.getActiveTools();
350
- if (!settings.enabled && active.includes("workflow")) {
351
- pi.setActiveTools(active.filter((name) => name !== "workflow"));
358
+ if (withholdIfOff()) {
352
359
  workflowWithheld = true;
353
360
  } else if (settings.enabled && workflowWithheld) {
361
+ const active = pi.getActiveTools();
354
362
  if (!active.includes("workflow")) pi.setActiveTools([...active, "workflow"]);
355
363
  workflowWithheld = false;
356
364
  }
@@ -427,7 +435,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
427
435
  pi.on("input", (event) => {
428
436
  if (!workflowsEnabled || ultracodeMode || !/\bultracode\b/i.test(event.text)) return undefined;
429
437
  if (event.streamingBehavior) queuedKeyword.hold(event.text, true);
430
- else pi.events.emit(REMINDER_CHANNEL, { text: ULTRACODE_REMINDER, scope: "next-turn" });
438
+ else pi.events.emit(REMINDER_CHANNEL, { text: ULTRACODE_REMINDER });
431
439
  return undefined;
432
440
  });
433
441
  pi.on("message_start", (event) => {
@@ -513,6 +521,10 @@ export default function workflowExtension(pi: ExtensionAPI) {
513
521
  applyWorkflowSettings(ctx.cwd);
514
522
  registerInputHook(ctx);
515
523
  });
524
+ // pi restores the selected branch's tool loadout at a /tree switch: turned off, the tool stays out.
525
+ pi.on("session_tree", () => {
526
+ withholdIfOff();
527
+ });
516
528
 
517
529
  // The permission-mode badge is a below-editor widget too, re-set on every
518
530
  // mode change; setWidget order is last-write order, so re-set the strip
@@ -29,6 +29,7 @@ import { runWorkflowScript } from "./vm-runtime.ts";
29
29
  import type { AgentRecord, RunProgressEvent, RunStatus, WorkflowMeta } from "./types.ts";
30
30
  import { WorkflowScriptError } from "./types.ts";
31
31
  import type { ViewerRunSnapshot } from "./viewer.ts";
32
+ import type { DeferRequest } from "../lib/deferred.ts";
32
33
 
33
34
  const WALL_CLOCK_CAP_MS = 30 * 60 * 1000;
34
35
  const RECENT_EVENTS_CAP = 200;
@@ -58,6 +59,8 @@ export interface StartRunOptions {
58
59
  onChildWrite?: (path: string, agent: string | undefined) => void;
59
60
  /** The parent's live MCP tools, shared into every workflow agent (see AgentRunnerOptions). */
60
61
  getMcpTools?: () => ToolDefinition[] | Promise<ToolDefinition[]>;
62
+ /** The tools the parent defers: each agent defers them too. */
63
+ getParentDeferred?: () => DeferRequest[];
61
64
  }
62
65
 
63
66
  export class RunHandle extends EventEmitter {
@@ -263,6 +266,7 @@ export class WorkflowRunManager {
263
266
  onModelUnusable: options.onModelUnusable,
264
267
  onChildWrite: options.onChildWrite,
265
268
  getMcpTools: options.getMcpTools,
269
+ getParentDeferred: options.getParentDeferred,
266
270
  resultsDir: handle.runDir,
267
271
  });
268
272