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.
- package/README.md +1 -1
- package/docs/guide/differences-from-claude-code.md +9 -6
- package/docs/guide/installation.md +3 -4
- package/docs/guide/troubleshooting.md +1 -1
- package/extensions/auto-mode/denials.ts +3 -2
- package/extensions/background/cron.ts +127 -0
- package/extensions/background/index.ts +135 -47
- package/extensions/background/loop-fire.ts +39 -2
- package/extensions/background/switch-guard.ts +10 -6
- package/extensions/background/wakeup.ts +21 -2
- package/extensions/bash/index.ts +8 -30
- package/extensions/btw/index.ts +32 -18
- package/extensions/cache-marks/index.ts +42 -0
- package/extensions/claude-context/index.ts +157 -172
- package/extensions/compaction/index.ts +11 -8
- package/extensions/context-budget/budget.ts +16 -2
- package/extensions/context-budget/index.ts +15 -14
- package/extensions/context-management/index.ts +11 -28
- package/extensions/effort/index.ts +70 -7
- package/extensions/file-tracker/index.ts +111 -44
- package/extensions/file-tracker/replay.ts +76 -49
- package/extensions/file-tracker/restore.ts +12 -9
- package/extensions/file-tracker/tracker.ts +14 -1
- package/extensions/hooks/index.ts +60 -76
- package/extensions/hooks/prompt-context.ts +59 -0
- package/extensions/hooks/trust.ts +13 -7
- package/extensions/ledger/index.ts +46 -0
- package/extensions/ledger/wire.ts +154 -0
- package/extensions/lib/agent-cron.ts +17 -1
- package/extensions/lib/agent-loader.ts +3 -2
- package/extensions/lib/anthropic-auth.ts +98 -0
- package/extensions/lib/child-extensions.ts +14 -4
- package/extensions/lib/context-facts.ts +75 -56
- package/extensions/lib/covered-messages.ts +23 -0
- package/extensions/lib/deferred.ts +152 -12
- package/extensions/lib/ledger/fold-memo.ts +49 -0
- package/extensions/lib/ledger/fold.ts +265 -0
- package/extensions/lib/ledger/layout.ts +72 -0
- package/extensions/lib/ledger/plan.ts +102 -0
- package/extensions/lib/ledger/records.ts +276 -0
- package/extensions/lib/ledger/state.ts +47 -0
- package/extensions/lib/ledger/validate.ts +141 -0
- package/extensions/lib/ledger/wire.ts +632 -0
- package/extensions/lib/local-command.ts +24 -6
- package/extensions/lib/model-catalog/huggingface.json +5 -1
- package/extensions/lib/model-catalog/models-dev.json +10535 -10299
- package/extensions/lib/model-catalog/openrouter.json +191 -393
- package/extensions/lib/notifications.ts +57 -17
- package/extensions/lib/pi-version.ts +25 -8
- package/extensions/lib/plan-mode-channels.ts +29 -7
- package/extensions/lib/previous-mark.ts +101 -0
- package/extensions/lib/process-state.ts +48 -0
- package/extensions/lib/process-tree.ts +3 -2
- package/extensions/lib/reminders.ts +327 -621
- package/extensions/lib/request-replay.ts +186 -78
- package/extensions/lib/side-call.ts +11 -0
- package/extensions/lib/subagent-handback.ts +0 -1
- package/extensions/lib/tool-additions.ts +2 -22
- package/extensions/lib/turn-budget-layout.ts +18 -2
- package/extensions/lsp/index.ts +6 -8
- package/extensions/mcp/announce.ts +63 -98
- package/extensions/mcp/index.ts +51 -90
- package/extensions/mcp/trust.ts +13 -6
- package/extensions/model-default/index.ts +86 -19
- package/extensions/permissions/index.ts +157 -48
- package/extensions/permissions/modes.ts +5 -0
- package/extensions/plan-mode/index.ts +142 -45
- package/extensions/plan-mode/run-state.ts +26 -0
- package/extensions/plugins/index.ts +4 -1
- package/extensions/powershell/index.ts +2 -0
- package/extensions/recap/index.ts +11 -14
- package/extensions/search-tools/index.ts +2 -0
- package/extensions/skill/announce.ts +30 -0
- package/extensions/skill/index.ts +65 -48
- package/extensions/skill/invoke.ts +0 -67
- package/extensions/subagents/agents.ts +30 -8
- package/extensions/subagents/fork-cache.ts +9 -3
- package/extensions/subagents/index.ts +180 -107
- package/extensions/subagents/outcome.ts +3 -0
- package/extensions/subagents/parent-mode.ts +50 -0
- package/extensions/subagents/pending-claim.ts +18 -1
- package/extensions/subagents/runner.ts +29 -2
- package/extensions/subagents/runs.ts +5 -0
- package/extensions/system-prompt/index.ts +40 -6
- package/extensions/system-prompt/template.ts +10 -1
- package/extensions/system-reminder/index.ts +174 -202
- package/extensions/tool-search/index.ts +241 -167
- package/extensions/workflow/agent-session.ts +4 -1
- package/extensions/workflow/index.ts +16 -4
- package/extensions/workflow/run-manager.ts +4 -0
- package/extensions/worktree/index.ts +50 -14
- package/package.json +5 -2
- package/extensions/lib/context-stack.ts +0 -81
- package/extensions/subagents/announce.ts +0 -69
- 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 {
|
|
55
|
-
import {
|
|
56
|
-
import {
|
|
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
|
-
/**
|
|
65
|
-
const
|
|
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)
|
|
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
|
-
/**
|
|
94
|
-
|
|
95
|
-
/** The session's model
|
|
96
|
-
let
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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 (
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
188
|
-
//
|
|
189
|
-
// it to reference a loaded tool from the
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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)
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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)
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
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
|
|