one-code-extension 0.3.1 → 0.4.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 (113) hide show
  1. package/README.md +88 -10
  2. package/docs/guide/README.md +1 -0
  3. package/docs/guide/configuration.md +2 -2
  4. package/docs/guide/differences-from-claude-code.md +13 -4
  5. package/docs/guide/hooks.md +14 -2
  6. package/docs/guide/installation.md +54 -6
  7. package/docs/guide/permissions-modes-and-auto-mode.md +23 -7
  8. package/docs/guide/reference.md +5 -2
  9. package/docs/guide/sessions-and-context.md +18 -0
  10. package/docs/guide/terminal-interface.md +1 -1
  11. package/docs/guide/tools.md +24 -2
  12. package/docs/guide/troubleshooting.md +14 -4
  13. package/docs/guide/windows.md +184 -0
  14. package/extensions/auto-mode/actions.ts +3 -1
  15. package/extensions/auto-mode/paths.ts +21 -23
  16. package/extensions/auto-mode/safety-floor.ts +44 -6
  17. package/extensions/auto-mode/shell-analysis.ts +25 -10
  18. package/extensions/auto-mode/transcript.ts +15 -5
  19. package/extensions/background/index.ts +83 -34
  20. package/extensions/background/monitor-batch.ts +7 -5
  21. package/extensions/background/wakeup.ts +18 -24
  22. package/extensions/bash/background.ts +19 -10
  23. package/extensions/bash/guards.ts +36 -16
  24. package/extensions/bash/index.ts +36 -166
  25. package/extensions/branding/index.ts +35 -4
  26. package/extensions/btw/index.ts +207 -0
  27. package/extensions/btw/panel.ts +179 -0
  28. package/extensions/btw/prompt.ts +45 -0
  29. package/extensions/clear/index.ts +15 -0
  30. package/extensions/compaction/fit.ts +9 -6
  31. package/extensions/doctor/build.ts +22 -2
  32. package/extensions/doctor/dependencies.ts +63 -31
  33. package/extensions/doctor/fix-prompt.ts +12 -4
  34. package/extensions/doctor/index.ts +1 -0
  35. package/extensions/doctor/models.ts +2 -1
  36. package/extensions/doctor/report.ts +2 -3
  37. package/extensions/doctor/update-lookup.ts +19 -11
  38. package/extensions/effort/index.ts +2 -1
  39. package/extensions/footer/footer-line.ts +19 -0
  40. package/extensions/footer/index.ts +25 -5
  41. package/extensions/hooks/executor.ts +84 -18
  42. package/extensions/hooks/index.ts +1 -0
  43. package/extensions/hooks/matcher.ts +23 -9
  44. package/extensions/hooks/settings.ts +15 -0
  45. package/extensions/lib/branch-restore.ts +12 -0
  46. package/extensions/lib/claude-context.ts +2 -3
  47. package/extensions/lib/claude-settings.ts +19 -0
  48. package/extensions/lib/command-launch.ts +71 -0
  49. package/extensions/lib/deferred.ts +173 -33
  50. package/extensions/lib/git.ts +16 -0
  51. package/extensions/lib/keys.ts +38 -0
  52. package/extensions/lib/local-command.ts +112 -0
  53. package/extensions/lib/model-facts.generated.json +263 -68
  54. package/extensions/lib/model-policy.ts +13 -0
  55. package/extensions/lib/model-tier.ts +16 -5
  56. package/extensions/lib/notifications.ts +547 -53
  57. package/extensions/lib/paths.ts +130 -2
  58. package/extensions/lib/pi-ai-estimate.ts +57 -26
  59. package/extensions/lib/pi-version.ts +1 -1
  60. package/extensions/lib/process-tree.ts +131 -5
  61. package/extensions/lib/reminders.ts +80 -27
  62. package/extensions/lib/scratchpad.ts +11 -10
  63. package/extensions/lib/shell-spawn.ts +298 -0
  64. package/extensions/lib/shell-tool.ts +252 -0
  65. package/extensions/lib/shell-tools.ts +13 -0
  66. package/extensions/lib/side-call.ts +76 -0
  67. package/extensions/lib/tui-render.ts +62 -11
  68. package/extensions/lib/update-check.mjs +155 -0
  69. package/extensions/lib/usage-bus.ts +1 -1
  70. package/extensions/lib/which.ts +62 -0
  71. package/extensions/lib/worktree-channel.ts +18 -0
  72. package/extensions/lib/worktree-isolation.ts +2 -1
  73. package/extensions/lsp/client.ts +14 -3
  74. package/extensions/lsp/index.ts +12 -5
  75. package/extensions/lsp/install-hints.ts +33 -3
  76. package/extensions/lsp/plugin-servers.ts +2 -2
  77. package/extensions/lsp/servers.ts +29 -7
  78. package/extensions/mcp/index.ts +3 -2
  79. package/extensions/memory/entries.ts +9 -6
  80. package/extensions/memory/index.ts +3 -2
  81. package/extensions/memory/open-external.ts +4 -1
  82. package/extensions/model-default/index.ts +21 -2
  83. package/extensions/permissions/index.ts +57 -28
  84. package/extensions/permissions/matcher.ts +120 -33
  85. package/extensions/permissions/modes.ts +14 -6
  86. package/extensions/permissions/powershell-rules.ts +549 -0
  87. package/extensions/permissions/session-grant.ts +9 -9
  88. package/extensions/plugins/index.ts +3 -2
  89. package/extensions/plugins/install/install.ts +1 -1
  90. package/extensions/powershell/description.ts +139 -0
  91. package/extensions/powershell/guards.ts +91 -0
  92. package/extensions/powershell/index.ts +89 -0
  93. package/extensions/powershell/policy.ts +91 -0
  94. package/extensions/recap/index.ts +10 -24
  95. package/extensions/recap/prompt.ts +3 -3
  96. package/extensions/session-title/index.ts +120 -0
  97. package/extensions/session-title/title.ts +111 -0
  98. package/extensions/skill/index.ts +40 -18
  99. package/extensions/skill/invoke.ts +1 -0
  100. package/extensions/subagents/hand-back-review.ts +10 -12
  101. package/extensions/subagents/index.ts +221 -78
  102. package/extensions/subagents/model-select.ts +75 -7
  103. package/extensions/system-prompt/environment.ts +14 -1
  104. package/extensions/system-reminder/index.ts +9 -0
  105. package/extensions/tasks/index.ts +2 -1
  106. package/extensions/tool-search/index.ts +41 -20
  107. package/extensions/workflow/agent-session.ts +6 -4
  108. package/extensions/workflow/index.ts +28 -5
  109. package/extensions/worktree/guards.ts +5 -4
  110. package/extensions/worktree/index.ts +15 -2
  111. package/extensions/worktree/rewrite.ts +17 -0
  112. package/package.json +7 -4
  113. 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
- const matches = contained.filter((model) => model.id.toLowerCase().includes(alias));
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: process.env.SHELL ? (process.env.SHELL.split("/").pop() ?? "unknown") : "unknown",
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.registerCommand("tasks", {
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
- withDeferredToolDefinitions,
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) every deferred tool rides every Anthropic request as a
142
- // `defer_loading: true` definition from request 1, so `tools` never changes
143
- // when tool_search loads one. Anthropic keeps deferred definitions out of the
144
- // cached prefix, but adding one mid-session still re-cached the whole message
145
- // history (lib/deferred.ts withDeferredToolDefinitions has the measurement);
146
- // unloaded deferred definitions cost no input tokens, so this is free.
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 withDeferredToolDefinitions(event.payload as Record<string, unknown>, pi.getAllTools(), (name) =>
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
- /** Deactivate every deferred-registry tool and announce the loadable set. */
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 and refresh the
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(_toolCallId, params) {
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.registerCommand("tools-deferred", {
303
+ registerLocalCommand(pi, "tools-deferred", {
283
304
  description: "Show which tools are deferred (loadable via tool_search)",
284
- handler: async (_args, ctx) => {
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
- throw new WorkflowScriptError(
162
- `agent() model "${resolution.unresolved}" is not available.\n${menu.join("\n")}\nAny exact provider/model-id also resolves.`,
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
- notifyTask("one-code:workflow-result", buildRunReport(handle), { runId: handle.runId, name: handle.meta.name, status: handle.status });
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(_toolCallId, params, signal, onUpdate, ctx) {
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 follow-up message when the run finishes. " +
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.registerCommand("workflows", {
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 { isWithin, toAbsolute } from "../auto-mode/paths.ts";
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.startsWith("/") || target === "~" || target.startsWith("~/")) dir = toAbsolute("/", target, homedir());
128
- else if (dir !== undefined) dir = toAbsolute(dir, target, homedir());
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 = toAbsolute(effective, raw, homedir());
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
- const next: WorktreeState = { path: target, createdByUs: false, originalCwd: ctx.cwd, sharedRoot: repoRoot };
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}. All work now happens there; exit_worktree returns to ${ctx.cwd}.` }],
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "one-code-extension",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "One Code as a pi package — the Claude Code experience on your own pi install: ported system prompt, Claude-Code-format permissions, .claude/ compatibility, ToolSearch-style deferred tools, and system-reminder steering. For the bundled app, install `@one-ai/one-code` instead.",
5
5
  "keywords": [
6
6
  "pi",
@@ -44,6 +44,7 @@
44
44
  "extensions/skill/index.ts",
45
45
  "extensions/background/index.ts",
46
46
  "extensions/bash/index.ts",
47
+ "extensions/powershell/index.ts",
47
48
  "extensions/subagents/index.ts",
48
49
  "extensions/workflow/index.ts",
49
50
  "extensions/effort/index.ts",
@@ -53,6 +54,8 @@
53
54
  "extensions/turn-duration/index.ts",
54
55
  "extensions/interrupted/index.ts",
55
56
  "extensions/recap/index.ts",
57
+ "extensions/btw/index.ts",
58
+ "extensions/session-title/index.ts",
56
59
  "extensions/exit/index.ts",
57
60
  "extensions/init/index.ts",
58
61
  "extensions/clear/index.ts",
@@ -97,9 +100,9 @@
97
100
  "typebox": "^1.3.7"
98
101
  },
99
102
  "devDependencies": {
100
- "@earendil-works/pi-agent-core": "^0.85.1",
101
- "@earendil-works/pi-ai": "^0.85.1",
102
- "@earendil-works/pi-coding-agent": "^0.85.1",
103
+ "@earendil-works/pi-agent-core": "^0.86.1",
104
+ "@earendil-works/pi-ai": "^0.86.1",
105
+ "@earendil-works/pi-coding-agent": "^0.86.1",
103
106
  "@types/node": "^26.1.2",
104
107
  "@types/turndown": "^5.0.6",
105
108
  "typescript": "^5.6.0",