one-code-extension 0.3.1 → 0.4.1
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 +214 -147
- package/docs/guide/README.md +1 -0
- package/docs/guide/configuration.md +2 -2
- package/docs/guide/differences-from-claude-code.md +13 -4
- package/docs/guide/hooks.md +14 -2
- package/docs/guide/installation.md +54 -6
- package/docs/guide/permissions-modes-and-auto-mode.md +23 -7
- package/docs/guide/reference.md +5 -2
- package/docs/guide/sessions-and-context.md +18 -0
- package/docs/guide/terminal-interface.md +1 -1
- package/docs/guide/tools.md +24 -2
- package/docs/guide/troubleshooting.md +14 -4
- package/docs/guide/windows.md +189 -0
- package/extensions/auto-mode/actions.ts +3 -1
- package/extensions/auto-mode/paths.ts +21 -23
- package/extensions/auto-mode/safety-floor.ts +44 -6
- package/extensions/auto-mode/shell-analysis.ts +25 -10
- package/extensions/auto-mode/transcript.ts +15 -5
- package/extensions/background/index.ts +83 -34
- package/extensions/background/monitor-batch.ts +7 -5
- package/extensions/background/wakeup.ts +18 -24
- package/extensions/bash/background.ts +19 -10
- package/extensions/bash/guards.ts +36 -16
- package/extensions/bash/index.ts +36 -166
- package/extensions/branding/index.ts +35 -4
- package/extensions/btw/index.ts +207 -0
- package/extensions/btw/panel.ts +179 -0
- package/extensions/btw/prompt.ts +45 -0
- package/extensions/clear/index.ts +15 -0
- package/extensions/compaction/fit.ts +9 -6
- package/extensions/doctor/build.ts +22 -2
- package/extensions/doctor/dependencies.ts +63 -31
- package/extensions/doctor/fix-prompt.ts +12 -4
- package/extensions/doctor/index.ts +1 -0
- package/extensions/doctor/models.ts +2 -1
- package/extensions/doctor/report.ts +2 -3
- package/extensions/doctor/update-lookup.ts +19 -11
- package/extensions/effort/index.ts +2 -1
- package/extensions/footer/footer-line.ts +19 -0
- package/extensions/footer/index.ts +25 -5
- package/extensions/hooks/executor.ts +84 -18
- package/extensions/hooks/index.ts +1 -0
- package/extensions/hooks/matcher.ts +23 -9
- package/extensions/hooks/settings.ts +15 -0
- package/extensions/lib/branch-restore.ts +12 -0
- package/extensions/lib/claude-context.ts +2 -3
- package/extensions/lib/claude-settings.ts +19 -0
- package/extensions/lib/command-launch.ts +71 -0
- package/extensions/lib/deferred.ts +173 -33
- package/extensions/lib/git.ts +16 -0
- package/extensions/lib/keys.ts +38 -0
- package/extensions/lib/local-command.ts +112 -0
- package/extensions/lib/model-facts.generated.json +263 -68
- package/extensions/lib/model-policy.ts +13 -0
- package/extensions/lib/model-tier.ts +16 -5
- package/extensions/lib/notifications.ts +547 -53
- package/extensions/lib/paths.ts +130 -2
- package/extensions/lib/pi-ai-estimate.ts +57 -26
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/process-tree.ts +131 -5
- package/extensions/lib/reminders.ts +80 -27
- package/extensions/lib/scratchpad.ts +11 -10
- package/extensions/lib/shell-spawn.ts +298 -0
- package/extensions/lib/shell-tool.ts +252 -0
- package/extensions/lib/shell-tools.ts +13 -0
- package/extensions/lib/side-call.ts +76 -0
- package/extensions/lib/tui-render.ts +62 -11
- package/extensions/lib/update-check.mjs +155 -0
- package/extensions/lib/usage-bus.ts +1 -1
- package/extensions/lib/which.ts +62 -0
- package/extensions/lib/worktree-channel.ts +18 -0
- package/extensions/lib/worktree-isolation.ts +2 -1
- package/extensions/lsp/client.ts +14 -3
- package/extensions/lsp/index.ts +12 -5
- package/extensions/lsp/install-hints.ts +33 -3
- package/extensions/lsp/plugin-servers.ts +2 -2
- package/extensions/lsp/servers.ts +29 -7
- package/extensions/mcp/index.ts +3 -2
- package/extensions/memory/entries.ts +9 -6
- package/extensions/memory/index.ts +3 -2
- package/extensions/memory/open-external.ts +4 -1
- package/extensions/model-default/index.ts +21 -2
- package/extensions/permissions/index.ts +57 -28
- package/extensions/permissions/matcher.ts +120 -33
- package/extensions/permissions/modes.ts +14 -6
- package/extensions/permissions/powershell-rules.ts +549 -0
- package/extensions/permissions/session-grant.ts +9 -9
- package/extensions/plugins/index.ts +3 -2
- package/extensions/plugins/install/install.ts +1 -1
- package/extensions/powershell/description.ts +139 -0
- package/extensions/powershell/guards.ts +91 -0
- package/extensions/powershell/index.ts +89 -0
- package/extensions/powershell/policy.ts +91 -0
- package/extensions/recap/index.ts +10 -24
- package/extensions/recap/prompt.ts +3 -3
- package/extensions/session-title/index.ts +120 -0
- package/extensions/session-title/title.ts +111 -0
- package/extensions/skill/index.ts +40 -18
- package/extensions/skill/invoke.ts +1 -0
- package/extensions/subagents/hand-back-review.ts +10 -12
- package/extensions/subagents/index.ts +221 -78
- package/extensions/subagents/model-select.ts +75 -7
- package/extensions/system-prompt/environment.ts +14 -1
- package/extensions/system-reminder/index.ts +9 -0
- package/extensions/tasks/index.ts +2 -1
- package/extensions/tool-search/index.ts +41 -20
- package/extensions/workflow/agent-session.ts +6 -4
- package/extensions/workflow/index.ts +28 -5
- package/extensions/worktree/guards.ts +5 -4
- package/extensions/worktree/index.ts +15 -2
- package/extensions/worktree/rewrite.ts +17 -0
- package/package.json +9 -5
- package/types/app.d.ts +18 -2
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
modelsContainedToSession,
|
|
60
60
|
modelSpec as spec,
|
|
61
61
|
pricedInput,
|
|
62
|
+
supportsImageInput,
|
|
62
63
|
} from "../lib/model-policy.ts";
|
|
63
64
|
import { atLeastTier, capableContainedCandidates, economicalContainedCandidates, intrinsicTier, type PromptTier } from "../lib/model-tier.ts";
|
|
64
65
|
|
|
@@ -125,6 +126,18 @@ export interface ResolveInput {
|
|
|
125
126
|
configuredDefault?: SubagentDefault;
|
|
126
127
|
sessionModel?: Model<Api>;
|
|
127
128
|
available: Model<Api>[];
|
|
129
|
+
/**
|
|
130
|
+
* Set by a spawn path when the session model is image-capable: the subagent
|
|
131
|
+
* inherits the parent transcript (fork) or may read an image/PDF file by path
|
|
132
|
+
* (any run), so a text-only worker cannot serve. The automatic pick and any
|
|
133
|
+
* alias then resolve to an image-capable same-provider model; an explicit
|
|
134
|
+
* text-only choice is hard-blocked (a per-call `model` field, retryable) or
|
|
135
|
+
* upgraded with a notice (a standing setting or agent-file model). The
|
|
136
|
+
* `/subagent` set command leaves this false — a saved default may be meant for
|
|
137
|
+
* another (text) session, and is gated at spawn instead. See
|
|
138
|
+
* `docs/decisions/model-policy.md`.
|
|
139
|
+
*/
|
|
140
|
+
requireImageInput?: boolean;
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
export interface SubagentModelResolution {
|
|
@@ -136,6 +149,13 @@ export interface SubagentModelResolution {
|
|
|
136
149
|
* the tool call with the menu, so the model that chose the string can retry.
|
|
137
150
|
*/
|
|
138
151
|
unresolved?: string;
|
|
152
|
+
/**
|
|
153
|
+
* The reason for `unresolved`, when it is not the plain "no such model" case —
|
|
154
|
+
* currently only a per-call text-only model refused on an image-capable
|
|
155
|
+
* session. The caller shows this in place of its default "unknown model" line
|
|
156
|
+
* so the fix is named. Undefined for an ordinary unavailable model.
|
|
157
|
+
*/
|
|
158
|
+
unresolvedReason?: string;
|
|
139
159
|
/** One-line warnings the parent should surface (fallbacks, provider crossings). */
|
|
140
160
|
notices: string[];
|
|
141
161
|
}
|
|
@@ -200,8 +220,13 @@ function resolveAlias(
|
|
|
200
220
|
contained: Model<Api>[],
|
|
201
221
|
available: Model<Api>[],
|
|
202
222
|
sessionModel: Model<Api> | undefined,
|
|
223
|
+
requireImageInput = false,
|
|
203
224
|
): AliasResolution | undefined {
|
|
204
|
-
|
|
225
|
+
// An alias asks for a *class*, so an image-capable session resolves it to an
|
|
226
|
+
// image-capable model of that class rather than erroring — the alias is
|
|
227
|
+
// auto-upgraded, never hard-blocked (unlike an explicit text-only id).
|
|
228
|
+
const imageOk = (model: Model<Api>) => !requireImageInput || supportsImageInput(model);
|
|
229
|
+
const matches = contained.filter((model) => model.id.toLowerCase().includes(alias) && imageOk(model));
|
|
205
230
|
if (matches.length > 0) {
|
|
206
231
|
const undated = matches.filter((model) => !isSnapshotDatedId(model.id));
|
|
207
232
|
const pool = undated.length > 0 ? undated : matches;
|
|
@@ -209,11 +234,12 @@ function resolveAlias(
|
|
|
209
234
|
}
|
|
210
235
|
if (!sessionModel) return undefined;
|
|
211
236
|
const tier = ALIAS_TIER[alias];
|
|
237
|
+
// The session model carries the caller's own modality, so frontier is always safe.
|
|
212
238
|
if (tier === "frontier") return { model: sessionModel, how: "tier" };
|
|
213
239
|
// Name-class only, deliberately: an explicit alias asks for a *class* of model,
|
|
214
240
|
// so a measured pass (which lets a strong flash serve the automatic default)
|
|
215
241
|
// does not lift a lean-named model into "sonnet" here.
|
|
216
|
-
const byTier = economicalContainedCandidates(available, sessionModel, contained).find((model) => atLeastTier(intrinsicTier(model), tier));
|
|
242
|
+
const byTier = economicalContainedCandidates(available, sessionModel, contained, requireImageInput).find((model) => atLeastTier(intrinsicTier(model), tier));
|
|
217
243
|
return byTier ? { model: byTier, how: "tier" } : undefined;
|
|
218
244
|
}
|
|
219
245
|
|
|
@@ -233,6 +259,17 @@ export function resolveSubagentModel(input: ResolveInput): SubagentModelResoluti
|
|
|
233
259
|
// A per-call `model` that did not resolve — deferred, not errored (see the
|
|
234
260
|
// "per-call request" paragraph in the module doc above).
|
|
235
261
|
let callFailed: string | undefined;
|
|
262
|
+
let callFailedReason: string | undefined;
|
|
263
|
+
|
|
264
|
+
// The modality gate: when the caller marks the session image-capable, a
|
|
265
|
+
// text-only worker cannot receive the images/PDFs the session may feed it. An
|
|
266
|
+
// EXPLICIT text-only id is refused here (a per-call `model` field is
|
|
267
|
+
// hard-blocked so the model retries; a standing setting or agent-file model is
|
|
268
|
+
// upgraded with a notice). Aliases and the automatic pick are steered to an
|
|
269
|
+
// image-capable model upstream instead, never refused. `requireImageInput`
|
|
270
|
+
// already reflects `supportsImageInput(sessionModel)` — the caller computed it.
|
|
271
|
+
const requireImageInput = input.requireImageInput ?? false;
|
|
272
|
+
const modalityMismatch = (model: Model<Api>) => requireImageInput && !supportsImageInput(model);
|
|
236
273
|
|
|
237
274
|
// The `subagentModel` setting is stale when it was stamped for a different
|
|
238
275
|
// provider than this session (or never stamped — a hand-edited setting): the
|
|
@@ -252,7 +289,7 @@ export function resolveSubagentModel(input: ResolveInput): SubagentModelResoluti
|
|
|
252
289
|
|
|
253
290
|
const alias = wanted.toLowerCase();
|
|
254
291
|
if (alias in ALIAS_TIER) {
|
|
255
|
-
const resolved = resolveAlias(alias, contained, available, sessionModel);
|
|
292
|
+
const resolved = resolveAlias(alias, contained, available, sessionModel, requireImageInput);
|
|
256
293
|
if (resolved?.how === "name") return { model: resolved.model, source: entry.source, notices };
|
|
257
294
|
// SCOPE the claim. "No sonnet model exists" is false at machine level
|
|
258
295
|
// whenever Anthropic is also authenticated, and a model relaying it tells
|
|
@@ -281,6 +318,28 @@ export function resolveSubagentModel(input: ResolveInput): SubagentModelResoluti
|
|
|
281
318
|
|
|
282
319
|
const resolved = findConfigured(available, wanted);
|
|
283
320
|
if (resolved) {
|
|
321
|
+
if (modalityMismatch(resolved)) {
|
|
322
|
+
// An explicit text-only id on an image-capable session. A per-call
|
|
323
|
+
// `model` field is hard-blocked (deferred like an unavailable model,
|
|
324
|
+
// so the chain may still land on the agent/default, else the caller
|
|
325
|
+
// shows the menu and the model retries with an image-capable one). A
|
|
326
|
+
// standing setting or agent-file model is upgraded silently to the
|
|
327
|
+
// automatic same-provider image-capable pick with a notice, because
|
|
328
|
+
// the user configured it once and it should not break a fork.
|
|
329
|
+
if (entry.source === "call") {
|
|
330
|
+
callFailed = wanted;
|
|
331
|
+
callFailedReason =
|
|
332
|
+
`Requested subagent model ${spec(resolved)} is text-only, but this session works with images or PDFs a subagent may need to read ` +
|
|
333
|
+
"(the inherited transcript, or a file it opens). Pick an image-capable model.";
|
|
334
|
+
notices.push(callFailedReason);
|
|
335
|
+
} else {
|
|
336
|
+
notices.push(
|
|
337
|
+
`${spec(resolved)} (from ${entry.knob}) is text-only, but this session works with images or PDFs a subagent may need to read; ` +
|
|
338
|
+
"an image-capable same-provider model runs this subagent instead.",
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
284
343
|
if (sessionModel && crossesProvider(resolved, sessionModel)) {
|
|
285
344
|
// An `.claude/agents` model is a Claude Code convention; on a
|
|
286
345
|
// non-Claude session it does not get to move a subagent (which
|
|
@@ -334,7 +393,7 @@ export function resolveSubagentModel(input: ResolveInput): SubagentModelResoluti
|
|
|
334
393
|
// Per-call model failed and nothing downstream resolved: surface it so the
|
|
335
394
|
// caller shows the menu and the model retries, rather than silently dropping
|
|
336
395
|
// to the automatic/session pick for a model the main model named.
|
|
337
|
-
if (callFailed) return { source: "call", unresolved: callFailed, notices };
|
|
396
|
+
if (callFailed) return { source: "call", unresolved: callFailed, unresolvedReason: callFailedReason, notices };
|
|
338
397
|
|
|
339
398
|
// Automatic selection is a cost optimisation, so it needs price evidence:
|
|
340
399
|
// with the session price unknown there is no demonstrable saving, and picking
|
|
@@ -355,7 +414,7 @@ export function resolveSubagentModel(input: ResolveInput): SubagentModelResoluti
|
|
|
355
414
|
// With an Artificial Analysis snapshot (lib/capability-index.ts) the floor
|
|
356
415
|
// is measured — coding index ≥ min(session, Sonnet 5), no tolerance —
|
|
357
416
|
// and passers rank by price; unscored candidates are judged by name-class tier.
|
|
358
|
-
const cheaper = capableContainedCandidates(available, sessionModel, "subagent", { strict: true, contained })[0];
|
|
417
|
+
const cheaper = capableContainedCandidates(available, sessionModel, "subagent", { strict: true, contained, requireImageInput })[0];
|
|
359
418
|
if (cheaper) return { model: cheaper, source: "automatic", notices };
|
|
360
419
|
}
|
|
361
420
|
|
|
@@ -399,6 +458,12 @@ export interface MenuOptions {
|
|
|
399
458
|
defaultSource?: SubagentModelSource;
|
|
400
459
|
/** How many cheaper-option lines to include. */
|
|
401
460
|
maxCheaper?: number;
|
|
461
|
+
/**
|
|
462
|
+
* When true (an image-capable session), text-only models are omitted from the
|
|
463
|
+
* menu — they would only be rejected again on retry (`modalityMismatch`). The
|
|
464
|
+
* caller passes `supportsImageInput(sessionModel)`.
|
|
465
|
+
*/
|
|
466
|
+
requireImageInput?: boolean;
|
|
402
467
|
/**
|
|
403
468
|
* The configured default and which knob set it, so the reminder can tell the
|
|
404
469
|
* main model when the user has *manually* pinned the subagent model via the
|
|
@@ -418,11 +483,14 @@ const price = (model: Model<Api>): string => {
|
|
|
418
483
|
* entries dropped, dated duplicates collapsed, capped — useful, not complete,
|
|
419
484
|
* which is safe because resolution accepts unlisted models too.
|
|
420
485
|
*/
|
|
421
|
-
export function subagentModelMenu({ available, sessionModel, defaultModel, defaultSource, maxCheaper = 3 }: MenuOptions): string[] {
|
|
486
|
+
export function subagentModelMenu({ available, sessionModel, defaultModel, defaultSource, maxCheaper = 3, requireImageInput = false }: MenuOptions): string[] {
|
|
422
487
|
const lines: string[] = [];
|
|
423
488
|
const listed = new Set<string>();
|
|
489
|
+
// On an image-capable session a text-only model is rejected on retry
|
|
490
|
+
// (`modalityMismatch`), so it never belongs in a retry menu.
|
|
491
|
+
const imageOk = (model: Model<Api>) => !requireImageInput || supportsImageInput(model);
|
|
424
492
|
const add = (model: Model<Api>, label: string) => {
|
|
425
|
-
if (listed.has(spec(model))) return;
|
|
493
|
+
if (listed.has(spec(model)) || !imageOk(model)) return;
|
|
426
494
|
listed.add(spec(model));
|
|
427
495
|
lines.push(`- ${spec(model)}${price(model)} — ${label}`);
|
|
428
496
|
};
|
|
@@ -23,6 +23,19 @@ export interface EnvironmentInfo {
|
|
|
23
23
|
memoryDir: string;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The `Shell:` value Claude Code prints: the basename of `SHELL`, else of
|
|
28
|
+
* `COMSPEC` (Windows, where `SHELL` is unset outside Git Bash), with a `.exe`
|
|
29
|
+
* suffix stripped and either path separator honoured — `cmd`, not `cmd.exe`.
|
|
30
|
+
*/
|
|
31
|
+
export function shellName(env: NodeJS.ProcessEnv = process.env): string {
|
|
32
|
+
const raw = env.SHELL || env.COMSPEC;
|
|
33
|
+
if (!raw) return "unknown";
|
|
34
|
+
const base = raw.split(/[\\/]/).pop() ?? "";
|
|
35
|
+
if (!base) return "unknown";
|
|
36
|
+
return base.toLowerCase().endsWith(".exe") ? base.slice(0, -4) : base;
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
export function collectEnvironment(cwd: string, modelLine: string): EnvironmentInfo {
|
|
27
40
|
const gitRoot = findGitRoot(cwd);
|
|
28
41
|
return {
|
|
@@ -30,7 +43,7 @@ export function collectEnvironment(cwd: string, modelLine: string): EnvironmentI
|
|
|
30
43
|
isGitRepo: gitRoot !== undefined,
|
|
31
44
|
platform: process.platform,
|
|
32
45
|
osVersion: `${os.type()} ${os.release()}`,
|
|
33
|
-
shell:
|
|
46
|
+
shell: shellName(),
|
|
34
47
|
modelLine,
|
|
35
48
|
memoryDir: projectMemoryDir(cwd, os.homedir()),
|
|
36
49
|
};
|
|
@@ -12,6 +12,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
12
12
|
import {
|
|
13
13
|
appendReminderBlocks,
|
|
14
14
|
injectReminders,
|
|
15
|
+
openingUserAnchor,
|
|
15
16
|
REMINDER_CHANNEL,
|
|
16
17
|
ReminderQueue,
|
|
17
18
|
type ReminderPayload,
|
|
@@ -44,6 +45,7 @@ export default function systemReminderExtension(pi: ExtensionAPI) {
|
|
|
44
45
|
suffix: payload.suffix,
|
|
45
46
|
raw: payload.raw,
|
|
46
47
|
since: payload.since,
|
|
48
|
+
once: payload.once,
|
|
47
49
|
});
|
|
48
50
|
}
|
|
49
51
|
});
|
|
@@ -75,6 +77,13 @@ export default function systemReminderExtension(pi: ExtensionAPI) {
|
|
|
75
77
|
const anchor = tailAnchor(event.messages);
|
|
76
78
|
if (anchor) reminderQueue.pin(anchor);
|
|
77
79
|
}
|
|
80
|
+
// A local-command breadcrumb rides the prompt that opens a request, before
|
|
81
|
+
// the user's text (Claude Code's placement), and stays there. Mid-turn —
|
|
82
|
+
// the request ends in a tool result — it waits for the next prompt.
|
|
83
|
+
if (reminderQueue.hasPending("user-prepend")) {
|
|
84
|
+
const anchor = openingUserAnchor(event.messages);
|
|
85
|
+
if (anchor) reminderQueue.pin(anchor, "user-prepend");
|
|
86
|
+
}
|
|
78
87
|
const reminders = reminderQueue.drain(event.messages);
|
|
79
88
|
return { messages: injectReminders(event.messages, reminders) };
|
|
80
89
|
});
|
|
@@ -22,6 +22,7 @@ import { DEFER_CHANNEL } from "../lib/deferred.ts";
|
|
|
22
22
|
import { ccToolRenderers, linesComponent, safeThemeBold, safeThemePaint, strike } from "../lib/tui-render.ts";
|
|
23
23
|
import { REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
24
24
|
import { formatTaskDetails, formatTaskLine, formatTaskList, formatTaskWidget, nudgeMessage, type TaskSnapshot, TaskStore } from "./store.ts";
|
|
25
|
+
import { registerLocalCommand } from "../lib/local-command.ts";
|
|
25
26
|
|
|
26
27
|
interface TaskDetails {
|
|
27
28
|
taskSnapshot: TaskSnapshot;
|
|
@@ -174,7 +175,7 @@ export default function tasksExtension(pi: ExtensionAPI) {
|
|
|
174
175
|
pi.events.emit(DEFER_CHANNEL, { name, keywords: ["task", "todo", "plan", "progress", "dependencies", "tracking"] });
|
|
175
176
|
}
|
|
176
177
|
|
|
177
|
-
pi
|
|
178
|
+
registerLocalCommand(pi, "tasks", {
|
|
178
179
|
description: "Show the structured task list; 'hide'/'show' toggles the widget",
|
|
179
180
|
handler: async (args, ctx) => {
|
|
180
181
|
const arg = args?.trim().toLowerCase();
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
* Provider-native deferral (Anthropic `defer_loading`, OpenAI `tool_search_call`)
|
|
18
18
|
* is only a cache optimization on top; without it, loading a tool mid-session
|
|
19
19
|
* grows the tools array and invalidates the prompt cache from the tools block
|
|
20
|
-
* down — an accepted tradeoff (findings §7).
|
|
20
|
+
* down — an accepted tradeoff (findings §7). On Anthropic tool-reference models
|
|
21
|
+
* this extension keeps the tools array byte-stable itself (the
|
|
22
|
+
* before_provider_request hook below), since pi 0.86 no longer does.
|
|
21
23
|
*
|
|
22
24
|
* Load order matters: this extension must come BEFORE any extension that defers
|
|
23
25
|
* a tool, because those emit their defer request while extensions are loading
|
|
@@ -36,9 +38,10 @@ import {
|
|
|
36
38
|
resultText,
|
|
37
39
|
searchTools,
|
|
38
40
|
selectedNames,
|
|
41
|
+
stabilizeDeferredTools,
|
|
39
42
|
supportsToolReferences,
|
|
40
43
|
toolNotFoundName,
|
|
41
|
-
|
|
44
|
+
toolSearchLoads,
|
|
42
45
|
} from "../lib/deferred.ts";
|
|
43
46
|
import { looksLikeAnthropicRequest } from "../lib/anthropic-payload.ts";
|
|
44
47
|
import { MCP_TOOLS_CHANNEL, type McpToolsPayload } from "../lib/mcp-share.ts";
|
|
@@ -47,6 +50,7 @@ import { sessionAlive } from "../lib/session-lifecycle.ts";
|
|
|
47
50
|
import { ccToolRenderers } from "../lib/tui-render.ts";
|
|
48
51
|
import { normalizeToolName } from "../permissions/matcher.ts";
|
|
49
52
|
import { applyAnnouncement, planAnnouncement } from "./announce.ts";
|
|
53
|
+
import { registerLocalCommand } from "../lib/local-command.ts";
|
|
50
54
|
|
|
51
55
|
/** Coalesces the burst of per-tool defers one server emits into one listing update. */
|
|
52
56
|
const ANNOUNCE_DEBOUNCE_MS = 100;
|
|
@@ -137,27 +141,41 @@ export default function toolSearchExtension(pi: ExtensionAPI) {
|
|
|
137
141
|
requestSent = true;
|
|
138
142
|
});
|
|
139
143
|
|
|
144
|
+
// Every tool_search load of this session (call id → names), seeded from the
|
|
145
|
+
// transcript at session_start and extended by each load: the wire hook needs
|
|
146
|
+
// it to reference a loaded tool from the result that loaded it.
|
|
147
|
+
let loads = new Map<string, string[]>();
|
|
148
|
+
// The loads a resumed session's transcript already holds (empty after
|
|
149
|
+
// /clear). The transcript tells the model those tools are callable, and
|
|
150
|
+
// Claude Code keeps a ToolSearch-loaded tool callable across a resume (its
|
|
151
|
+
// tool_reference blocks stay in the history), so deferring them again would
|
|
152
|
+
// send the model into a "Tool <name> not found" round trip. pi restores them
|
|
153
|
+
// as active from the transcript before session_start; deferAll and the
|
|
154
|
+
// late-defer handler leave those alone. A load made during this session
|
|
155
|
+
// activates the tool directly and never needs this set.
|
|
156
|
+
let loadedBefore = new Set<string>();
|
|
157
|
+
|
|
140
158
|
// On models that take client-side tool references (first-party Claude >= 4.5,
|
|
141
|
-
// not Haiku)
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
159
|
+
// not Haiku) the `tools` array is held byte-identical to request 1's: every
|
|
160
|
+
// deferred tool rides every Anthropic request as a `defer_loading: true`
|
|
161
|
+
// definition, and a tool that tool_search loaded — which pi 0.86 promotes into
|
|
162
|
+
// the eager list, re-caching everything from the tools block down — is
|
|
163
|
+
// demoted back to deferred and loaded through `tool_reference` blocks in the
|
|
164
|
+
// tool_search result that activated it (lib/deferred.ts stabilizeDeferredTools
|
|
165
|
+
// has the rules; docs/decisions/caching.md the measurements).
|
|
147
166
|
pi.on("before_provider_request", (event, ctx) => {
|
|
148
167
|
if (!supportsToolReferences(ctx.model as { provider?: string; id?: string } | undefined)) return undefined;
|
|
149
168
|
if (!looksLikeAnthropicRequest(event.payload)) return undefined;
|
|
150
|
-
return
|
|
151
|
-
deferredRegistry.has(name),
|
|
152
|
-
);
|
|
169
|
+
return stabilizeDeferredTools(event.payload as Record<string, unknown>, pi.getAllTools(), (name) => deferredRegistry.has(name), loads);
|
|
153
170
|
});
|
|
154
171
|
|
|
155
|
-
|
|
172
|
+
|
|
173
|
+
/** Deactivate every deferred-registry tool the transcript has not loaded, and announce the loadable set. */
|
|
156
174
|
const deferAll = () => {
|
|
157
175
|
const deferred = new Set(deferredRegistry.names);
|
|
158
176
|
if (deferred.size === 0) return;
|
|
159
177
|
const active = pi.getActiveTools();
|
|
160
|
-
const next = active.filter((name) => !deferred.has(name));
|
|
178
|
+
const next = active.filter((name) => !deferred.has(name) || loadedBefore.has(name));
|
|
161
179
|
if (next.length !== active.length) pi.setActiveTools(next);
|
|
162
180
|
announce();
|
|
163
181
|
};
|
|
@@ -167,11 +185,11 @@ export default function toolSearchExtension(pi: ExtensionAPI) {
|
|
|
167
185
|
deferredRegistry.add(request);
|
|
168
186
|
// A defer arriving after the session_start pass (MCP servers connect
|
|
169
187
|
// asynchronously and register their tools then) would otherwise leave the
|
|
170
|
-
// tool eager AND unlisted in the reminder. Deactivate it
|
|
171
|
-
// keyed reminder.
|
|
188
|
+
// tool eager AND unlisted in the reminder. Deactivate it (unless the
|
|
189
|
+
// transcript loaded it) and refresh the keyed reminder.
|
|
172
190
|
if (sessionStarted && request?.name) {
|
|
173
191
|
const active = pi.getActiveTools();
|
|
174
|
-
if (active.includes(request.name)) {
|
|
192
|
+
if (active.includes(request.name) && !loadedBefore.has(request.name)) {
|
|
175
193
|
pi.setActiveTools(active.filter((name) => name !== request.name));
|
|
176
194
|
}
|
|
177
195
|
// The tool is searchable (keyword search reads the registry) from now;
|
|
@@ -180,12 +198,14 @@ export default function toolSearchExtension(pi: ExtensionAPI) {
|
|
|
180
198
|
}
|
|
181
199
|
});
|
|
182
200
|
|
|
183
|
-
pi.on("session_start", () => {
|
|
201
|
+
pi.on("session_start", (_event, ctx) => {
|
|
184
202
|
sessionStarted = true;
|
|
185
203
|
// A pending debounce belongs to the previous session's listing (RPC's
|
|
186
204
|
// new_session emits session_start twice, findings §3); deferAll below
|
|
187
205
|
// announces the fresh one.
|
|
188
206
|
clearAnnounce();
|
|
207
|
+
loads = toolSearchLoads(ctx?.sessionManager?.getBranch?.() ?? []);
|
|
208
|
+
loadedBefore = new Set([...loads.values()].flat());
|
|
189
209
|
// A new session (/clear, or a resume in a new process) has no cached prefix
|
|
190
210
|
// yet: its first request gets a freshly written listing.
|
|
191
211
|
requestSent = false;
|
|
@@ -229,7 +249,7 @@ export default function toolSearchExtension(pi: ExtensionAPI) {
|
|
|
229
249
|
Type.Integer({ minimum: 1, maximum: 20, description: "Maximum tools to load (default 5)" }),
|
|
230
250
|
),
|
|
231
251
|
}),
|
|
232
|
-
async execute(
|
|
252
|
+
async execute(toolCallId, params) {
|
|
233
253
|
const available = searchableTools();
|
|
234
254
|
const matches = searchTools(params.query, available, params.max_results ?? 5);
|
|
235
255
|
|
|
@@ -261,6 +281,7 @@ export default function toolSearchExtension(pi: ExtensionAPI) {
|
|
|
261
281
|
const added = matches.map((m) => m.name).filter((name) => !active.includes(name));
|
|
262
282
|
if (added.length > 0) {
|
|
263
283
|
pi.setActiveTools([...new Set([...active, ...added])]);
|
|
284
|
+
loads.set(toolCallId, added);
|
|
264
285
|
}
|
|
265
286
|
|
|
266
287
|
const loaded = matches.map((m) => m.name);
|
|
@@ -279,9 +300,9 @@ export default function toolSearchExtension(pi: ExtensionAPI) {
|
|
|
279
300
|
},
|
|
280
301
|
});
|
|
281
302
|
|
|
282
|
-
pi
|
|
303
|
+
registerLocalCommand(pi, "tools-deferred", {
|
|
283
304
|
description: "Show which tools are deferred (loadable via tool_search)",
|
|
284
|
-
handler: async (
|
|
305
|
+
handler: async (args, ctx) => {
|
|
285
306
|
const available = searchableTools();
|
|
286
307
|
const activeSet = new Set(pi.getActiveTools());
|
|
287
308
|
const lines = available.map((t) => `${activeSet.has(t.name) ? "loaded " : "deferred"} ${t.name}`);
|
|
@@ -21,7 +21,7 @@ import type { Api, Model } from "@earendil-works/pi-ai";
|
|
|
21
21
|
import { Type } from "typebox";
|
|
22
22
|
import { whenAborted } from "../lib/abort.ts";
|
|
23
23
|
import { createSharedModelRuntime, finalAssistantText, openChildSession } from "../lib/agent-loader.ts";
|
|
24
|
-
import { modelSpec as modelSpecOf } from "../lib/model-policy.ts";
|
|
24
|
+
import { modelSpec as modelSpecOf, supportsImageInput } from "../lib/model-policy.ts";
|
|
25
25
|
import { isModelUnavailableError } from "../auto-mode/model-select.ts";
|
|
26
26
|
import { agentPromptIdentity, PrefixWarmGate, prefixWarmKey } from "../lib/prefix-warm-gate.ts";
|
|
27
27
|
import type { PermissionBridge } from "../permissions/subagent-gate.ts";
|
|
@@ -145,22 +145,24 @@ export function resolveWorkflowAgentModel(input: WorkflowModelInput): {
|
|
|
145
145
|
configuredDefault: input.configuredDefault,
|
|
146
146
|
sessionModel: input.sessionModel,
|
|
147
147
|
available: input.available,
|
|
148
|
+
requireImageInput: supportsImageInput(input.sessionModel),
|
|
148
149
|
});
|
|
149
150
|
if (resolution.unresolved) {
|
|
150
151
|
const fallback = resolveSubagentModel({
|
|
151
152
|
configuredDefault: input.configuredDefault,
|
|
152
153
|
sessionModel: input.sessionModel,
|
|
153
154
|
available: input.available,
|
|
155
|
+
requireImageInput: supportsImageInput(input.sessionModel),
|
|
154
156
|
});
|
|
155
157
|
const menu = subagentModelMenu({
|
|
156
158
|
available: input.available,
|
|
157
159
|
sessionModel: input.sessionModel,
|
|
158
160
|
defaultModel: fallback.model,
|
|
159
161
|
defaultSource: fallback.source,
|
|
162
|
+
requireImageInput: supportsImageInput(input.sessionModel),
|
|
160
163
|
});
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
);
|
|
164
|
+
const reason = resolution.unresolvedReason ?? `agent() model "${resolution.unresolved}" is not available.`;
|
|
165
|
+
throw new WorkflowScriptError(`${reason}\n${menu.join("\n")}\nAny exact provider/model-id also resolves.`);
|
|
164
166
|
}
|
|
165
167
|
const gate = expensiveModelGate(resolution, input.sessionModel, input.opts.allowExpensive);
|
|
166
168
|
if (gate) {
|
|
@@ -26,7 +26,7 @@ import { Type } from "typebox";
|
|
|
26
26
|
import { recordUsage } from "../lib/usage-bus.ts";
|
|
27
27
|
import { MODEL_UNUSABLE_CHANNEL, type ModelUnusableEvent } from "../lib/model-unusable.ts";
|
|
28
28
|
import { whenAborted } from "../lib/abort.ts";
|
|
29
|
-
import { createTaskNotifier, oneShotNote, sessionOutlivesTurn } from "../lib/notifications.ts";
|
|
29
|
+
import { createTaskNotifier, oneShotNote, sessionOutlivesTurn, taskNotification, taskStatusOf, workflowSummary } from "../lib/notifications.ts";
|
|
30
30
|
import { REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
31
31
|
import { ULTRACODE_MODE_CHANNEL } from "../effort/slider.ts";
|
|
32
32
|
import { PERMISSION_STATUS_CHANNEL } from "../permissions/modes.ts";
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
type ViewerRunSnapshot,
|
|
55
55
|
} from "./viewer.ts";
|
|
56
56
|
import { WorkflowWidget } from "./widget.ts";
|
|
57
|
+
import { registerLocalCommand } from "../lib/local-command.ts";
|
|
57
58
|
|
|
58
59
|
/**
|
|
59
60
|
* Claude Code's own arming reminder, verbatim in intent: the keyword is a
|
|
@@ -231,11 +232,32 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
231
232
|
}
|
|
232
233
|
};
|
|
233
234
|
|
|
235
|
+
/** The tool call that started each background run — the notification's `<tool-use-id>`. */
|
|
236
|
+
const startedBy = new Map<string, string>();
|
|
234
237
|
const deliverResult = (runId: string) => {
|
|
238
|
+
// One delivery per run, so the id is spent here even when the run has
|
|
239
|
+
// already gone from the manager or was delivered another way.
|
|
240
|
+
const toolUseId = startedBy.get(runId);
|
|
241
|
+
startedBy.delete(runId);
|
|
235
242
|
const handle = manager.get(runId);
|
|
236
243
|
if (!handle || deliveredRuns.has(runId)) return;
|
|
237
244
|
deliveredRuns.add(runId);
|
|
238
|
-
|
|
245
|
+
// CC's kind=workflow task notification; the run report (result or the
|
|
246
|
+
// failure and how to resume) is its `<result>`. Summary literal: see
|
|
247
|
+
// workflowSummary — unverified against CC.
|
|
248
|
+
const status = taskStatusOf(handle.status);
|
|
249
|
+
notifyTask(
|
|
250
|
+
"one-code:workflow-result",
|
|
251
|
+
taskNotification({
|
|
252
|
+
kind: "workflow",
|
|
253
|
+
taskId: handle.runId,
|
|
254
|
+
toolUseId,
|
|
255
|
+
status,
|
|
256
|
+
summary: workflowSummary(handle.meta.name, status, status === "completed" ? undefined : handle.errorMessage),
|
|
257
|
+
result: buildRunReport(handle),
|
|
258
|
+
}),
|
|
259
|
+
{ runId: handle.runId, name: handle.meta.name, status: handle.status },
|
|
260
|
+
);
|
|
239
261
|
};
|
|
240
262
|
|
|
241
263
|
pi.registerMessageRenderer("one-code:workflow-result", (message, { expanded }, theme) =>
|
|
@@ -260,7 +282,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
260
282
|
description: WORKFLOW_TOOL_DESCRIPTION,
|
|
261
283
|
promptSnippet: "Run a script that orchestrates many subagents (opt-in ultracode mode)",
|
|
262
284
|
parameters: WorkflowParams,
|
|
263
|
-
async execute(
|
|
285
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
264
286
|
lastCtx = ctx;
|
|
265
287
|
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
266
288
|
|
|
@@ -345,6 +367,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
345
367
|
};
|
|
346
368
|
}
|
|
347
369
|
|
|
370
|
+
startedBy.set(handle.runId, toolCallId);
|
|
348
371
|
void handle.finished.then(() => deliverResult(handle.runId));
|
|
349
372
|
return {
|
|
350
373
|
content: [
|
|
@@ -353,7 +376,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
353
376
|
text:
|
|
354
377
|
`Workflow **${handle.meta.name}** ${handle.resumed ? "resumed" : "started"} in the background.\n` +
|
|
355
378
|
`runId: ${handle.runId}\nscript: ${handle.scriptPath}\n` +
|
|
356
|
-
"The result will arrive as a
|
|
379
|
+
"The result will arrive as a task notification when the run finishes. " +
|
|
357
380
|
"You know nothing about its outcome until then — do not predict it. " +
|
|
358
381
|
"The user can watch with /workflows and stop with /workflows stop.",
|
|
359
382
|
},
|
|
@@ -370,7 +393,7 @@ export default function workflowExtension(pi: ExtensionAPI) {
|
|
|
370
393
|
},
|
|
371
394
|
});
|
|
372
395
|
|
|
373
|
-
pi
|
|
396
|
+
registerLocalCommand(pi, "workflows", {
|
|
374
397
|
description: "Open the workflow viewer; list, stop, or inspect runs",
|
|
375
398
|
getArgumentCompletions: (prefix) => {
|
|
376
399
|
const items = ["stop ", "log ", "list"].filter((c) => c.startsWith(prefix));
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
|
-
import {
|
|
29
|
+
import { isAbsolute } from "node:path";
|
|
30
|
+
import { isWithin, toAbsoluteBash } from "../auto-mode/paths.ts";
|
|
30
31
|
import { gitSubcommand, leadTokens, parseCommand, resolvePayload, type Token } from "../auto-mode/shell-analysis.ts";
|
|
31
32
|
|
|
32
33
|
export interface WorktreeGuardContext {
|
|
@@ -124,8 +125,8 @@ export function worktreeBashGuardReason({ command, worktreePath, sharedRoot }: W
|
|
|
124
125
|
else if (hasExpansion(target) || target === "-") dir = undefined;
|
|
125
126
|
// An absolute (or ~) destination re-anchors the tracked directory
|
|
126
127
|
// even when it was unknown — later git commands become checkable again.
|
|
127
|
-
else if (target
|
|
128
|
-
else if (dir !== undefined) dir =
|
|
128
|
+
else if (isAbsolute(target) || target === "~" || target.startsWith("~/")) dir = toAbsoluteBash("/", target, homedir());
|
|
129
|
+
else if (dir !== undefined) dir = toAbsoluteBash(dir, target, homedir());
|
|
129
130
|
return undefined;
|
|
130
131
|
}
|
|
131
132
|
if (rawLead === "pushd" || rawLead === "popd") {
|
|
@@ -179,7 +180,7 @@ export function worktreeBashGuardReason({ command, worktreePath, sharedRoot }: W
|
|
|
179
180
|
`Use literal paths inside ${worktreePath} instead.`,
|
|
180
181
|
);
|
|
181
182
|
}
|
|
182
|
-
const resolved =
|
|
183
|
+
const resolved = toAbsoluteBash(effective, raw, homedir());
|
|
183
184
|
if (value === "-C") effective = resolved;
|
|
184
185
|
else extraTargets.push(resolved);
|
|
185
186
|
i += inlined ? 1 : 2;
|
|
@@ -20,6 +20,7 @@ import { Type } from "typebox";
|
|
|
20
20
|
import { restoreLatestDetails } from "../lib/branch-restore.ts";
|
|
21
21
|
import { DEFER_CHANNEL } from "../lib/deferred.ts";
|
|
22
22
|
import { REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
23
|
+
import { WORKTREE_CHANNEL, type WorktreeLocation } from "../lib/worktree-channel.ts";
|
|
23
24
|
import { worktreeBashGuardReason } from "./guards.ts";
|
|
24
25
|
import { ORIGINAL_COMMAND_CHANNEL, type OriginalCommandRecord } from "../lib/original-command.ts";
|
|
25
26
|
import { rewriteToolInput, validateWorktreeName } from "./rewrite.ts";
|
|
@@ -67,6 +68,8 @@ export default function worktreeExtension(pi: ExtensionAPI) {
|
|
|
67
68
|
|
|
68
69
|
const applyState = (next: WorktreeState | undefined) => {
|
|
69
70
|
state = next;
|
|
71
|
+
const location: WorktreeLocation | null = next ? { path: next.path, branch: next.branch } : null;
|
|
72
|
+
pi.events.emit(WORKTREE_CHANNEL, location);
|
|
70
73
|
if (next) {
|
|
71
74
|
pi.events.emit(REMINDER_CHANNEL, {
|
|
72
75
|
text: reminderFor(next),
|
|
@@ -145,10 +148,20 @@ export default function worktreeExtension(pi: ExtensionAPI) {
|
|
|
145
148
|
const known = await listWorktreePaths(ctx.cwd);
|
|
146
149
|
const target = known.find((p) => p === params.path);
|
|
147
150
|
if (!target) return fail(`${params.path} is not a worktree of this repository. Known worktrees:\n${known.join("\n")}`);
|
|
148
|
-
|
|
151
|
+
// The branch is display-only here (footer, reminder): a detached HEAD
|
|
152
|
+
// leaves it unset silently, a failing git is reported in the result text.
|
|
153
|
+
let branch: string | undefined;
|
|
154
|
+
let branchNote = "";
|
|
155
|
+
try {
|
|
156
|
+
const ref = await git(["rev-parse", "--abbrev-ref", "HEAD"], target);
|
|
157
|
+
if (ref && ref !== "HEAD") branch = ref;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
branchNote = ` Its branch could not be read (${(error as Error).message.split("\n")[0]}).`;
|
|
160
|
+
}
|
|
161
|
+
const next: WorktreeState = { path: target, branch, createdByUs: false, originalCwd: ctx.cwd, sharedRoot: repoRoot };
|
|
149
162
|
applyState(next);
|
|
150
163
|
return {
|
|
151
|
-
content: [{ type: "text", text: `Switched into existing worktree ${target}
|
|
164
|
+
content: [{ type: "text", text: `Switched into existing worktree ${target}${branch ? ` (branch ${branch})` : ""}.${branchNote} All work now happens there; exit_worktree returns to ${ctx.cwd}.` }],
|
|
152
165
|
details: { worktreeState: next } satisfies WorktreeDetails,
|
|
153
166
|
};
|
|
154
167
|
}
|
|
@@ -20,6 +20,11 @@ export function shellQuote(path: string): string {
|
|
|
20
20
|
return `'${path.replace(/'/g, "'\\''")}'`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** PowerShell single-quoted literal: only `'` needs escaping, as `''`. */
|
|
24
|
+
export function powershellQuote(path: string): string {
|
|
25
|
+
return `'${path.replace(/'/g, "''")}'`;
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
|
|
24
29
|
/**
|
|
25
30
|
* Mutates `input` in place so the call runs inside the worktree. For bash,
|
|
@@ -46,6 +51,18 @@ export function rewriteToolInput(
|
|
|
46
51
|
}
|
|
47
52
|
return {};
|
|
48
53
|
}
|
|
54
|
+
if (toolName === "powershell") {
|
|
55
|
+
// `Set-Location -LiteralPath` takes the path verbatim (no wildcard
|
|
56
|
+
// expansion); `;` chains regardless of edition (`&&` is pwsh-7-only), and a
|
|
57
|
+
// failed Set-Location is a terminating error under -NonInteractive, so the
|
|
58
|
+
// command does not run in the wrong directory.
|
|
59
|
+
if (typeof input.command === "string") {
|
|
60
|
+
const originalCommand = input.command;
|
|
61
|
+
input.command = `Set-Location -LiteralPath ${powershellQuote(worktreePath)} -ErrorAction Stop; ${originalCommand}`;
|
|
62
|
+
return { originalCommand };
|
|
63
|
+
}
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
49
66
|
if (!PATH_TOOLS.has(toolName)) return {};
|
|
50
67
|
|
|
51
68
|
// Rewrite whichever field carries the path — `path` for pi's built-ins,
|