one-code-extension 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +1 -1
  2. package/docs/guide/differences-from-claude-code.md +9 -6
  3. package/docs/guide/installation.md +3 -4
  4. package/docs/guide/troubleshooting.md +1 -1
  5. package/extensions/auto-mode/denials.ts +3 -2
  6. package/extensions/background/cron.ts +127 -0
  7. package/extensions/background/index.ts +135 -47
  8. package/extensions/background/loop-fire.ts +39 -2
  9. package/extensions/background/switch-guard.ts +10 -6
  10. package/extensions/background/wakeup.ts +21 -2
  11. package/extensions/bash/index.ts +8 -30
  12. package/extensions/btw/index.ts +32 -18
  13. package/extensions/cache-marks/index.ts +42 -0
  14. package/extensions/claude-context/index.ts +157 -172
  15. package/extensions/compaction/index.ts +11 -8
  16. package/extensions/context-budget/budget.ts +16 -2
  17. package/extensions/context-budget/index.ts +15 -14
  18. package/extensions/context-management/index.ts +11 -28
  19. package/extensions/effort/index.ts +70 -7
  20. package/extensions/file-tracker/index.ts +111 -44
  21. package/extensions/file-tracker/replay.ts +76 -49
  22. package/extensions/file-tracker/restore.ts +12 -9
  23. package/extensions/file-tracker/tracker.ts +14 -1
  24. package/extensions/hooks/index.ts +60 -76
  25. package/extensions/hooks/prompt-context.ts +59 -0
  26. package/extensions/hooks/trust.ts +13 -7
  27. package/extensions/ledger/index.ts +46 -0
  28. package/extensions/ledger/wire.ts +154 -0
  29. package/extensions/lib/agent-cron.ts +17 -1
  30. package/extensions/lib/agent-loader.ts +3 -2
  31. package/extensions/lib/anthropic-auth.ts +98 -0
  32. package/extensions/lib/child-extensions.ts +14 -4
  33. package/extensions/lib/context-facts.ts +75 -56
  34. package/extensions/lib/covered-messages.ts +23 -0
  35. package/extensions/lib/deferred.ts +152 -12
  36. package/extensions/lib/ledger/fold-memo.ts +49 -0
  37. package/extensions/lib/ledger/fold.ts +265 -0
  38. package/extensions/lib/ledger/layout.ts +72 -0
  39. package/extensions/lib/ledger/plan.ts +102 -0
  40. package/extensions/lib/ledger/records.ts +276 -0
  41. package/extensions/lib/ledger/state.ts +47 -0
  42. package/extensions/lib/ledger/validate.ts +141 -0
  43. package/extensions/lib/ledger/wire.ts +632 -0
  44. package/extensions/lib/local-command.ts +24 -6
  45. package/extensions/lib/model-catalog/huggingface.json +5 -1
  46. package/extensions/lib/model-catalog/models-dev.json +10535 -10299
  47. package/extensions/lib/model-catalog/openrouter.json +191 -393
  48. package/extensions/lib/notifications.ts +57 -17
  49. package/extensions/lib/pi-version.ts +25 -8
  50. package/extensions/lib/plan-mode-channels.ts +29 -7
  51. package/extensions/lib/previous-mark.ts +101 -0
  52. package/extensions/lib/process-state.ts +48 -0
  53. package/extensions/lib/process-tree.ts +3 -2
  54. package/extensions/lib/reminders.ts +327 -621
  55. package/extensions/lib/request-replay.ts +186 -78
  56. package/extensions/lib/side-call.ts +11 -0
  57. package/extensions/lib/subagent-handback.ts +0 -1
  58. package/extensions/lib/tool-additions.ts +2 -22
  59. package/extensions/lib/turn-budget-layout.ts +18 -2
  60. package/extensions/lsp/index.ts +6 -8
  61. package/extensions/mcp/announce.ts +63 -98
  62. package/extensions/mcp/index.ts +51 -90
  63. package/extensions/mcp/trust.ts +13 -6
  64. package/extensions/model-default/index.ts +86 -19
  65. package/extensions/permissions/index.ts +157 -48
  66. package/extensions/permissions/modes.ts +5 -0
  67. package/extensions/plan-mode/index.ts +142 -45
  68. package/extensions/plan-mode/run-state.ts +26 -0
  69. package/extensions/plugins/index.ts +4 -1
  70. package/extensions/powershell/index.ts +2 -0
  71. package/extensions/recap/index.ts +11 -14
  72. package/extensions/search-tools/index.ts +2 -0
  73. package/extensions/skill/announce.ts +30 -0
  74. package/extensions/skill/index.ts +65 -48
  75. package/extensions/skill/invoke.ts +0 -67
  76. package/extensions/subagents/agents.ts +30 -8
  77. package/extensions/subagents/fork-cache.ts +9 -3
  78. package/extensions/subagents/index.ts +180 -107
  79. package/extensions/subagents/outcome.ts +3 -0
  80. package/extensions/subagents/parent-mode.ts +50 -0
  81. package/extensions/subagents/pending-claim.ts +18 -1
  82. package/extensions/subagents/runner.ts +29 -2
  83. package/extensions/subagents/runs.ts +5 -0
  84. package/extensions/system-prompt/index.ts +40 -6
  85. package/extensions/system-prompt/template.ts +10 -1
  86. package/extensions/system-reminder/index.ts +174 -202
  87. package/extensions/tool-search/index.ts +241 -167
  88. package/extensions/workflow/agent-session.ts +4 -1
  89. package/extensions/workflow/index.ts +16 -4
  90. package/extensions/workflow/run-manager.ts +4 -0
  91. package/extensions/worktree/index.ts +50 -14
  92. package/package.json +5 -2
  93. package/extensions/lib/context-stack.ts +0 -81
  94. package/extensions/subagents/announce.ts +0 -69
  95. package/extensions/tool-search/announce.ts +0 -58
@@ -19,15 +19,14 @@
19
19
  * that prompt. before_agent_start also re-announces the existing state.
20
20
  */
21
21
 
22
- import { existsSync, readFileSync } from "node:fs";
23
- import { join } from "node:path";
22
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
23
+ import { dirname, join } from "node:path";
24
24
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
25
25
  import { registerFormTool } from "../lib/tool-variants.ts";
26
26
  import { Type } from "typebox";
27
27
  import { DEFER_CHANNEL } from "../lib/deferred.ts";
28
28
  import { oneCodeStateDir } from "../lib/paths.ts";
29
29
  import { REMINDER_CHANNEL } from "../lib/reminders.ts";
30
- import { CONTEXT_BASELINE_CHANNEL, restoredContext } from "../lib/context-stack.ts";
31
30
  import { canShowCustomUi } from "../lib/headless-output.ts";
32
31
  import { ccToolRenderers, safeThemePaint } from "../lib/tui-render.ts";
33
32
  import { userDenialText } from "../lib/user-denial.ts";
@@ -35,6 +34,18 @@ import type { PermissionMode } from "../permissions/matcher.ts";
35
34
  import { PERMISSION_STATUS_CHANNEL, type PermissionStatus } from "../permissions/modes.ts";
36
35
  import { buildPlanModeReminder } from "./reminder.ts";
37
36
  import { randomSlug } from "./slug.ts";
37
+ import { readState, recordState } from "../lib/ledger/state.ts";
38
+
39
+ /** A plan file larger than this is named after a compaction, not inlined: the model reads it when it needs it. */
40
+ const PLAN_REFERENCE_CHARS = 40_000;
41
+
42
+ /** The plan the summary replaced, re-attached after a compaction. */
43
+ function planFileReferenceText(path: string, content: string): string {
44
+ if (content.length > PLAN_REFERENCE_CHARS) return `A plan file exists from plan mode at: ${path}\n\nIt is too large to include here (${content.length.toLocaleString("en-US")} characters); read it before continuing.\n\nIf this plan is relevant to the current work and not already complete, continue working on it.`;
45
+ return `A plan file exists from plan mode at: ${path}\n\nPlan contents:\n\n${content}\n\nIf this plan is relevant to the current work and not already complete, continue working on it.`;
46
+ }
47
+
48
+ import { PLAN_ENTRY_STATE, parsePlanRun, type PlanRun } from "./run-state.ts";
38
49
  import {
39
50
  approvedPlanText,
40
51
  ENTER_PLAN_MODE_DESCRIPTION,
@@ -57,7 +68,7 @@ import {
57
68
  selectPlanChoice,
58
69
  } from "./viewer.ts";
59
70
 
60
- import { MODE_CHANNEL, PLAN_FILE_CHANNEL, PLAN_FILE_ENTRY, planFileOnBranch } from "../lib/plan-mode-channels.ts";
71
+ import { MODE_CHANNEL, PLAN_FILE_CHANNEL, PLAN_FILE_ENTRY, planFileOnBranch, planFileRecords, type PlanFileRecord } from "../lib/plan-mode-channels.ts";
61
72
  import { panelUi } from "../lib/panel-focus.ts";
62
73
  export { MODE_CHANNEL, PLAN_FILE_CHANNEL };
63
74
 
@@ -77,48 +88,102 @@ export default function planModeExtension(pi: ExtensionAPI) {
77
88
  /** The last mode broadcast by permissions; undefined until the first status arrives. */
78
89
  let currentMode: string | undefined;
79
90
  /**
80
- * The mode the session was in when it entered plan mode, offered first on
81
- * exit (Claude Code's ExitPlanMode restores `prePlanMode`). Unknown for a
82
- * session that STARTED in plan mode — the first status seen is plan itself —
83
- * so that session gets the fixed list. Until 2026-09-05 the exit could only
84
- * escalate: a default-mode session that planned was offered auto
85
- * (PERMISSIONS-REVIEW-2026-09-05 M4).
91
+ * The live mode the last switch into plan mode came from. It is recorded on
92
+ * the branch only when that switch begins a run (`refresh` finds no run
93
+ * recorded for the branch's plan file). A switch that merely restores a run,
94
+ * permissions following a /tree to a branch in plan mode, keeps the branch's
95
+ * own value, since the live mode before it belongs to the abandoned branch.
86
96
  */
87
- let modeBeforePlan: PermissionMode | undefined;
88
- let planFilePath: string | undefined;
97
+ let enteredFrom: PermissionMode | undefined;
98
+ /** Whether the session can enter bypassPermissions, as permissions last said: the exit offers it back only then. */
99
+ let bypassAvailable = false;
100
+
89
101
  /**
90
- * Whether the plan file existed when plan mode came on, which picks Claude
91
- * Code's "Plan File Info" line. Fixed for the mode's whole run, so the sticky
92
- * block stays byte-identical turn after turn (plan-mode/reminder.ts);
93
- * cleared when the mode ends.
102
+ * The current run as the branch recorded it when plan mode came on: its plan
103
+ * file, whether that file existed then (which picks Claude Code's "Plan File
104
+ * Info" line, fixed for the run so the sticky block stays byte-identical turn
105
+ * after turn, plan-mode/reminder.ts), and the mode it came from, offered
106
+ * first on exit (Claude Code's ExitPlanMode restores `prePlanMode`). Read
107
+ * from the branch every time, never kept here, so a /reload, a resume and a
108
+ * /tree each see the run of the branch they land on. Until 2026-09-05 the
109
+ * exit could only escalate: a default-mode session that planned was offered
110
+ * auto (PERMISSIONS-REVIEW-2026-09-05 M4).
94
111
  */
95
- let planExistedAtEntry: boolean | undefined;
96
-
97
- /** Restore the branch's plan file, else allocate a fresh slug. */
98
- const ensurePlanFile = (ctx: ExtensionContext): string => {
99
- if (planFilePath) return planFilePath;
100
-
101
- planFilePath = planFileOnBranch(ctx.sessionManager.getBranch());
102
- if (planFilePath) return planFilePath;
112
+ const runOnBranch = (ctx: ExtensionContext, path: string): PlanRun | undefined => parsePlanRun(readState(ctx, PLAN_ENTRY_STATE), path);
113
+ /** Record the current run on the branch, for `runOnBranch` to read back. */
114
+ const recordRun = (path: string, run: PlanRun) => recordState(pi, PLAN_ENTRY_STATE, { path, ...run });
103
115
 
116
+ const freshPlanPath = (): string => {
104
117
  let path = join(PLANS_DIR(), `${randomSlug()}.md`);
105
118
  for (let attempt = 0; attempt < 5 && existsSync(path); attempt++) {
106
119
  path = join(PLANS_DIR(), `${randomSlug()}.md`);
107
120
  }
108
- planFilePath = path;
109
- pi.appendEntry(PLAN_FILE_ENTRY, { path });
110
121
  return path;
111
122
  };
112
123
 
113
- /** Re-announce path + reminder; every-turn re-emits replace by key. */
124
+ /**
125
+ * A fork's own copy of the plan another session recorded, as Claude Code
126
+ * gives a forked session, so the two sessions never overwrite each other's plan
127
+ * (decisions/modes.md). The copy this session already made of it, on any
128
+ * of its branches, is reused. The run moves to the copy with the mode it
129
+ * came from, and says whether the copy exists (the parent's run may have
130
+ * begun before its plan did).
131
+ */
132
+ const forkPlanFile = (ctx: ExtensionContext, parent: PlanFileRecord, sessionId: string): string => {
133
+ const made = planFileRecords(ctx.sessionManager.getEntries()).find((r) => r.sessionId === sessionId && r.copiedFrom === parent.path);
134
+ const path = made?.path ?? freshPlanPath();
135
+ if (!made && existsSync(parent.path)) {
136
+ try {
137
+ mkdirSync(dirname(path), { recursive: true });
138
+ copyFileSync(parent.path, path);
139
+ } catch (error) {
140
+ if (ctx.hasUI) ctx.ui.notify(`Could not copy the plan at ${parent.path} for this fork (${(error as Error).message}); its plan starts empty at ${path}.`, "warning");
141
+ }
142
+ }
143
+ const run = runOnBranch(ctx, parent.path);
144
+ pi.appendEntry(PLAN_FILE_ENTRY, { path, sessionId, copiedFrom: parent.path } satisfies PlanFileRecord);
145
+ // The standing block names the new path anyway, so it also says whether the copy exists.
146
+ if (run) recordRun(path, { ...run, existed: existsSync(path) });
147
+ return path;
148
+ };
149
+
150
+ /**
151
+ * The branch's plan file, else a fresh slug recorded on the branch. A
152
+ * record another session made (a fork copies its parent's entries) gets
153
+ * this session's own copy, and so does a record that names no session (an
154
+ * older session's): only a record this session made is its own.
155
+ * Never cached: on /tree, permissions' session_tree switches the mode (and
156
+ * this extension refreshes) before this extension's own session_tree runs,
157
+ * so a cached path would be the abandoned branch's.
158
+ */
159
+ const ensurePlanFile = (ctx: ExtensionContext, onBranch = planFileRecords(ctx.sessionManager.getBranch()).at(-1)): string => {
160
+ const sessionId = ctx.sessionManager.getSessionId();
161
+ if (onBranch?.sessionId === sessionId) return onBranch.path;
162
+ if (onBranch) return forkPlanFile(ctx, onBranch, sessionId);
163
+
164
+ const path = freshPlanPath();
165
+ pi.appendEntry(PLAN_FILE_ENTRY, { path, sessionId } satisfies PlanFileRecord);
166
+ return path;
167
+ };
168
+
169
+ /** A branch that names a plan file owns it before anything reads it: a fork copies its parent's now. */
170
+ const ownPlanFile = (ctx: ExtensionContext) => {
171
+ const onBranch = planFileRecords(ctx.sessionManager.getBranch()).at(-1);
172
+ if (onBranch?.path) ensurePlanFile(ctx, onBranch);
173
+ };
174
+
175
+ /** Re-announce path + reminder; a re-emit replaces the sticky state by key. */
114
176
  const refresh = (ctx: ExtensionContext, toolCallId?: string) => {
115
177
  const path = ensurePlanFile(ctx);
116
- planExistedAtEntry ??= existsSync(path);
117
- pi.events.emit(CONTEXT_BASELINE_CHANNEL, { key: "plan-mode", value: { path, existed: planExistedAtEntry } });
178
+ let run = runOnBranch(ctx, path);
179
+ if (!run) {
180
+ // A run begins: record what it began with.
181
+ run = { existed: existsSync(path), ...(enteredFrom ? { modeBeforePlan: enteredFrom } : {}) };
182
+ recordRun(path, run);
183
+ }
118
184
  pi.events.emit(PLAN_FILE_CHANNEL, { path });
119
185
  pi.events.emit(REMINDER_CHANNEL, {
120
- text: buildPlanModeReminder(path, planExistedAtEntry),
121
- scope: "every-turn",
186
+ text: buildPlanModeReminder(path, run.existed),
122
187
  key: "permission-mode",
123
188
  placement: "sticky-append",
124
189
  toolCallId,
@@ -151,8 +216,12 @@ export default function planModeExtension(pi: ExtensionAPI) {
151
216
  if (typeof status?.mode !== "string") return;
152
217
  const previous = currentMode;
153
218
  currentMode = status.mode;
154
- if (currentMode === "plan" && previous !== "plan") modeBeforePlan = previous as PermissionMode | undefined;
155
- if (currentMode !== "plan") planExistedAtEntry = undefined;
219
+ bypassAvailable = status.bypassAvailable === true;
220
+ // Unknown for a session that STARTED in plan mode (the first status seen
221
+ // is plan itself): a run it begins offers the fixed list on exit.
222
+ if (currentMode === "plan" && previous !== "plan") enteredFrom = previous as PermissionMode | undefined;
223
+ // The run ended: the next entry decides afresh.
224
+ if (previous === "plan" && currentMode !== "plan") recordState(pi, PLAN_ENTRY_STATE, null);
156
225
  // Entering plan mode mid-turn: permissions' setMode has just closed the
157
226
  // shared "permission-mode" key (before broadcasting this status, so this
158
227
  // re-add is not undone) and will announce the change on the next tool
@@ -164,14 +233,14 @@ export default function planModeExtension(pi: ExtensionAPI) {
164
233
 
165
234
  pi.on("session_start", (_event, ctx) => {
166
235
  lastCtx = ctx;
167
- const restored = restoredContext(pi.events);
168
- const baseline = restored?.baselines["plan-mode"] as { path?: unknown; existed?: unknown } | undefined;
169
- const path = planFileOnBranch(ctx.sessionManager.getBranch());
170
- if (currentMode === "plan" && restored?.baselines["permission-mode"] === "plan" &&
171
- path && baseline?.path === path && typeof baseline.existed === "boolean") {
172
- planFilePath = path;
173
- planExistedAtEntry = baseline.existed;
174
- }
236
+ ownPlanFile(ctx);
237
+ if (currentMode === "plan") refresh(ctx);
238
+ });
239
+ // The plan file and its run follow the branch (both are read from it): a
240
+ // /tree switch takes the selected branch's, or allocates one there.
241
+ pi.on("session_tree", (_event, ctx) => {
242
+ lastCtx = ctx;
243
+ ownPlanFile(ctx);
175
244
  if (currentMode === "plan") refresh(ctx);
176
245
  });
177
246
 
@@ -180,6 +249,30 @@ export default function planModeExtension(pi: ExtensionAPI) {
180
249
  if (currentMode === "plan") refresh(ctx);
181
250
  });
182
251
 
252
+ // A compaction summarizes the plan away and opens an epoch, where nothing
253
+ // above has to stay byte-stable (R3-M20). The plan file is re-attached
254
+ // whenever one exists, plan mode or not, as Claude Code does; in plan mode
255
+ // the run is recorded again, so the new epoch's block says whether the file
256
+ // exists now.
257
+ pi.on("session_compact", (_event, ctx) => {
258
+ lastCtx = ctx;
259
+ const path = planFileOnBranch(ctx.sessionManager.getBranch());
260
+ if (!path) return;
261
+ if (currentMode === "plan") {
262
+ recordRun(path, { ...runOnBranch(ctx, path), existed: existsSync(path) });
263
+ refresh(ctx);
264
+ }
265
+ let content = "";
266
+ try {
267
+ content = existsSync(path) ? readFileSync(path, "utf8") : "";
268
+ } catch {
269
+ // The re-attach is best effort, like the other post-compaction restores:
270
+ // an unreadable path (a directory, no permission) attaches nothing.
271
+ }
272
+ if (!content.trim()) return;
273
+ pi.events.emit(REMINDER_CHANNEL, { text: planFileReferenceText(path, content), placement: "user-prepend", orTail: true, untilCompaction: true });
274
+ });
275
+
183
276
  /**
184
277
  * enter_plan_mode's description follows the model's tier: Claude Code's,
185
278
  * which prefers plan mode, on frontier and workhorse, and One Code's, which
@@ -272,11 +365,15 @@ export default function planModeExtension(pi: ExtensionAPI) {
272
365
  // The mode the session was in before planning leads the list (Claude
273
366
  // Code restores it), then auto mode when a classifier model is reachable —
274
367
  // the same gate the mode cycle uses (permissions/index.ts autoInCycle) —
275
- // and the fixed choices. Each approving choice carries the mode it
276
- // switches to; the final "Keep planning" choice has none and leaves the
277
- // session in plan mode.
368
+ // and the fixed choices. A bypass before planning is offered back only
369
+ // when this session can grant it (a resume without the bypass flag
370
+ // cannot, and the request would land in default). Each approving
371
+ // choice carries the mode it switches to; the final "Keep planning"
372
+ // choice has none and leaves the session in plan mode.
278
373
  const autoAvailable = ctx.modelRegistry.getAvailable().length > 0;
279
- const restore = modeBeforePlan && (modeBeforePlan !== "auto" || autoAvailable) ? modeBeforePlan : undefined;
374
+ const modeBeforePlan = runOnBranch(ctx, path)?.modeBeforePlan;
375
+ const offerable = (mode: PermissionMode) => (mode !== "auto" || autoAvailable) && (mode !== "bypassPermissions" || bypassAvailable);
376
+ const restore = modeBeforePlan && offerable(modeBeforePlan) ? modeBeforePlan : undefined;
280
377
  const fixedModes: PermissionMode[] = [...(autoAvailable ? (["auto"] as const) : []), "acceptEdits", "default"];
281
378
  const options: { label: string; mode?: PermissionMode; restore?: boolean }[] = [
282
379
  ...(restore ? [{ label: `Approve — back to ${MODE_NAMES[restore]} (the mode before planning)`, mode: restore, restore: true }] : []),
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A plan-mode run as the branch records it (pure, no pi imports): its plan
3
+ * file's path, whether that file existed when the run began, and the mode the
4
+ * run came from. plan-mode/index.ts reads and writes the record.
5
+ */
6
+ import type { PermissionMode } from "../permissions/matcher.ts";
7
+ import { normalizePermissionMode } from "../permissions/settings.ts";
8
+
9
+ /** The `one-code:state` key of the current plan-mode run. */
10
+ export const PLAN_ENTRY_STATE = "plan-mode:entry";
11
+
12
+ /** A plan-mode run as the branch records it, beside its plan file's path. */
13
+ export type PlanRun = { existed: boolean; modeBeforePlan?: PermissionMode };
14
+
15
+ /**
16
+ * The run `stored` records for the plan file at `path`, or undefined when it
17
+ * records another file or is malformed. A mode it came from that is unknown,
18
+ * or plan itself, is dropped: the exit then offers its fixed list.
19
+ */
20
+ export function parsePlanRun(stored: unknown, path: string): PlanRun | undefined {
21
+ const record = stored as { path?: unknown; existed?: unknown; modeBeforePlan?: unknown } | null | undefined;
22
+ if (record?.path !== path || typeof record.existed !== "boolean") return undefined;
23
+ const mode = normalizePermissionMode(record.modeBeforePlan);
24
+ const before = mode !== "plan" ? mode : undefined;
25
+ return { existed: record.existed, ...(before ? { modeBeforePlan: before } : {}) };
26
+ }
@@ -16,6 +16,7 @@
16
16
  * ~/.claude is read-only here, always.
17
17
  */
18
18
 
19
+ import { randomUUID } from "node:crypto";
19
20
  import { readFileSync, rmSync } from "node:fs";
20
21
  import { dirname, join } from "node:path";
21
22
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
@@ -569,11 +570,13 @@ function registerPluginCommand(pi: ExtensionAPI, plugin: Plugin, name: string, p
569
570
  // user turn: persist it past the cap so the model gets a preview plus
570
571
  // the path, never a wall of text (review L3).
571
572
  const dir = sessionResultsDir(ctx);
573
+ const runId = randomUUID();
572
574
  let expanded: string;
573
575
  try {
574
576
  // Placeholders run where the session works: an entered worktree, not the checkout it left.
575
577
  expanded = await expandTemplate(body, args, workCwd(ctx.cwd), {
576
- persist: (text, index) => persistIfLarge(text, { dir, id: `plugin-cmd-${name}-${index}` }),
578
+ // A unique id per run: the directory is shared across sessions and processes.
579
+ persist: (text, index) => persistIfLarge(text, { dir, id: `plugin-cmd-${name}-${runId}-${index}` }),
577
580
  run: async (command, cwd) => {
578
581
  const decide = getPermissionBridge();
579
582
  if (!decide) throw new Error("The permission gate is unavailable. Load the permissions extension before running shell placeholders.");
@@ -91,4 +91,6 @@ export function powershellTool(pi: ExtensionAPI, { announce }: { announce: boole
91
91
 
92
92
  pi.on("session_start", (_event, ctx) => reconcile(ctx));
93
93
  pi.on("model_select", () => reconcile());
94
+ // pi restores the selected branch's tool loadout at a /tree switch.
95
+ pi.on("session_tree", () => reconcile());
94
96
  }
@@ -15,7 +15,8 @@
15
15
  * compaction extension publishes the capture). Without a capture for the
16
16
  * session model, or on a provider API the replay does not cover, it falls back
17
17
  * to a small standalone call on a cheap same-containment model
18
- * (pickEconomicalContainedModel): the last 30 messages and name-only stubs of
18
+ * (pickEconomicalContainedModel): the current branch's last 30 messages (read
19
+ * from the session when the recap runs) and name-only stubs of
19
20
  * the active tools (some providers reject a history carrying tool_use blocks
20
21
  * with no tools declared). The result is a display-only entry (appendEntry,
21
22
  * not in LLM context). Best-effort throughout: any failure just shows nothing.
@@ -34,7 +35,7 @@ import { recordUsage } from "../lib/usage-bus.ts";
34
35
  import { pickEconomicalContainedModel } from "../lib/model-tier.ts";
35
36
  import { CONVERSATION_CACHE_PLACEMENT } from "../lib/side-call-cache.ts";
36
37
  import { releaseSideCalls, runSideCall } from "../lib/side-call-run.ts";
37
- import { answerText, stripImageBlocks, toolStubs, withoutSystemMessages } from "../lib/side-call.ts";
38
+ import { answerText, branchMessages, stripImageBlocks, toolStubs, withoutSystemMessages } from "../lib/side-call.ts";
38
39
  import { unrefTimers } from "../lib/timer-ops.ts";
39
40
  import { dimMarkedLine } from "../lib/tui-render.ts";
40
41
  import { RECAP_PROMPT, recapLine, recentForRecap, REFERENCE_MARK } from "./prompt.ts";
@@ -64,13 +65,7 @@ export default function recapExtension(pi: ExtensionAPI) {
64
65
  return dimMarkedLine(theme, REFERENCE_MARK, recapLine(content));
65
66
  });
66
67
 
67
- // The exact request messages the session last sent (after every extension's
68
- // mutations), captured like the compaction extension does.
69
- let capturedMessages: AgentMessage[] | undefined;
70
- pi.on("context", (event) => {
71
- capturedMessages = event.messages;
72
- });
73
- // The same request as it went on the wire, and the reply that answered it.
68
+ // The session's last request as it went on the wire, and the reply that answered it.
74
69
  const exchange = followLastExchange(pi);
75
70
 
76
71
  let lastCtx: ExtensionContext | undefined;
@@ -126,8 +121,11 @@ export default function recapExtension(pi: ExtensionAPI) {
126
121
 
127
122
  async function generate() {
128
123
  const ctx = lastCtx;
129
- const messages = capturedMessages;
130
- if (!ctx?.hasUI || !messages?.length || scheduler.hasFiredSinceTurn) return;
124
+ if (!ctx?.hasUI || scheduler.hasFiredSinceTurn) return;
125
+ // The current branch, read now: a /tree or a compaction since the last
126
+ // request changed it, and pi fires no `context` event for either.
127
+ const messages = branchMessages(ctx);
128
+ if (!messages.length) return;
131
129
  abortInFlight();
132
130
  const controller = new AbortController();
133
131
  inFlight = controller;
@@ -159,11 +157,10 @@ export default function recapExtension(pi: ExtensionAPI) {
159
157
  pi.on("session_start", (_event, ctx) => {
160
158
  lastCtx = ctx;
161
159
  // A new session (including after /clear): drop any timer armed by the
162
- // previous session and its captured messages, so a stale recap can never
163
- // surface against the fresh conversation.
160
+ // previous session, so a stale recap can never surface against the
161
+ // fresh conversation.
164
162
  scheduler.reset();
165
163
  abortInFlight();
166
- capturedMessages = undefined;
167
164
  if (!ctx.hasUI) return;
168
165
  // Reset the idle clock on any keystroke — the closest signal to CC's
169
166
  // terminal-blur trigger pi exposes. Registered ONCE (session_start fires
@@ -27,6 +27,8 @@ export default function searchToolsExtension(pi: ExtensionAPI) {
27
27
 
28
28
  pi.on("session_start", apply);
29
29
  pi.on("model_select", apply);
30
+ // pi restores the selected branch's tool loadout at a /tree switch.
31
+ pi.on("session_tree", apply);
30
32
 
31
33
  // Overlapping context windows repeat lines (grep-merge.ts); without context
32
34
  // lines there are no windows to overlap.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * What to tell the model when the skill set changes (pure).
3
+ *
4
+ * The skills listing is a `first-prepend` reminder on message 1, so once a
5
+ * request has gone out it is the record every later request in the epoch
6
+ * replays (extensions/ledger); rewriting it would also break the binding of
7
+ * every earlier signed thinking block (findings §46). As in Claude Code, a
8
+ * skill that becomes listable later is announced on the request's tail under
9
+ * the listing's own header, and a skill that leaves the listing is not
10
+ * announced: the skill tool refuses it by name. A compaction opens an epoch
11
+ * and re-lists every skill.
12
+ */
13
+
14
+ import { skillListingVisibility } from "../lib/skill-overrides.ts";
15
+ import type { ListedSkill } from "./listing.ts";
16
+
17
+ /** The header Claude Code puts over its skills listing, initial or later. */
18
+ export const SKILL_LISTING_HEADER = "The following skills are available for use with the Skill tool:";
19
+
20
+ /**
21
+ * The names the listing shows. `readable` is the read-the-file framing for a
22
+ * session without the skill tool, which also leaves out plugin commands and
23
+ * skills without a file (listing.ts `readableSkillListing`).
24
+ */
25
+ export function listableSkillNames(skills: ReadonlyArray<ListedSkill & { path?: string; kind?: string }>, readable: boolean): string[] {
26
+ return skills
27
+ .filter((skill) => !skill.disableModelInvocation && skillListingVisibility(skill.state) !== "hidden")
28
+ .filter((skill) => !readable || (!skill.kind && skill.path))
29
+ .map((skill) => skill.name);
30
+ }
@@ -25,7 +25,7 @@ import { pluginRoot } from "../lib/plugin-root.ts";
25
25
  import { defaultDiscoverRoots, discoverPlugins } from "../lib/plugins.ts";
26
26
  import { awaitOneShotTurn, sessionOutlivesTurn } from "../lib/notifications.ts";
27
27
  import { PROMPT_OPTIONS_CHANNEL, type PromptOptionsAnnouncement } from "../lib/prompt-options.ts";
28
- import { CONTEXT_ORDER, REMINDER_CHANNEL } from "../lib/reminders.ts";
28
+ import { CONTEXT_ORDER, REMINDER_CHANNEL, RENDER_CHANNEL, type RenderRequest } from "../lib/reminders.ts";
29
29
  import {
30
30
  nextSkillState,
31
31
  readSkillStates,
@@ -45,7 +45,6 @@ import {
45
45
  launchingSkill,
46
46
  missingArgumentsNote,
47
47
  parseSkillCommand,
48
- redactOffSkillMessages,
49
48
  resolveSkill,
50
49
  SKILL_INVOCATION_TYPE,
51
50
  type SkillInvocationDetails,
@@ -58,6 +57,7 @@ import { codeReviewBody } from "./code-review.ts";
58
57
  import { decodeSkillsKey } from "./panel/keys.ts";
59
58
  import { frontmatterFlag, readableSkillListing, skillListingBudget, skillListingText, usageScore, withoutPiSkillsBlock } from "./listing.ts";
60
59
  import { renderSkillsPanel, type SkillsPaint } from "./panel/render.ts";
60
+ import { listableSkillNames, SKILL_LISTING_HEADER } from "./announce.ts";
61
61
  import { applySkillsKey, followRow, initialSkillsState, type SkillsRow, visibleRows } from "./panel/state.ts";
62
62
  import { announceArgumentHint, type CommandHint, frontmatterCommandHint } from "../lib/argument-hints.ts";
63
63
  import { parseSlashCommand, SKILL_BODY_CHANNEL, type SkillBodyQuery, SLASH_EXPAND_CHANNEL, type SlashExpandQuery } from "../lib/skill-body.ts";
@@ -142,6 +142,25 @@ export default function skillExtension(pi: ExtensionAPI) {
142
142
  sessionUsage = undefined;
143
143
  sessionModel = ctx.model;
144
144
  });
145
+ /** The index as the current run's first request read it; a new run, or pi's skills for it, reads it again. */
146
+ let runIndex: IndexedSkill[] | undefined;
147
+ pi.on("agent_start", () => {
148
+ runIndex = undefined;
149
+ });
150
+ // Inside an epoch the listing on message 1 is the record: a skill that became
151
+ // listable since rides the request's tail with only its own line, decided
152
+ // when the request goes out from what the epoch has told.
153
+ pi.events.on(RENDER_CHANNEL, (data) => {
154
+ const request = data as RenderRequest;
155
+ if (request.epoch) return;
156
+ const told = request.told.get("skills") ?? new Set<string>();
157
+ // Read once per run: a run makes many requests, and the index reads every skill file.
158
+ const all = (runIndex ??= index());
159
+ const added = new Set(listableNow(all).filter((name) => !told.has(name)));
160
+ if (added.size === 0) return;
161
+ const text = listingText(all.filter((skill) => added.has(skill.name)));
162
+ if (text) request.blocks.push({ text, facts: [{ kind: "skills", names: [...added] }] });
163
+ });
145
164
  pi.on("model_select", (event) => {
146
165
  sessionModel = event.model;
147
166
  });
@@ -162,32 +181,44 @@ export default function skillExtension(pi: ExtensionAPI) {
162
181
  disableModelInvocation: record.disableModelInvocation === true || (path ? readModelInvocationDisabled(path) : false),
163
182
  };
164
183
  });
165
- // Claude Code lists skills as a <system-reminder> on the first user message
166
- // (its block 3), framed for the Skill tool — not in the system prompt. A
167
- // session without that tool (an agent limited to read and bash, a main
168
- // session started with fewer tools) gets the same block framed for reading
169
- // the file, since pi's own section is dropped from every prompt below.
184
+ announceSkills();
185
+ };
186
+ /**
187
+ * Claude Code lists skills as a <system-reminder> on the first user message
188
+ * (its block 3), framed for the Skill tool — not in the system prompt. A
189
+ * session without that tool (an agent limited to read and bash, a main
190
+ * session started with fewer tools) gets the same block framed for reading
191
+ * the file, since pi's own section is dropped from every prompt below.
192
+ * `skills` is the whole index or, for a later notice, only the new ones.
193
+ */
194
+ const listingText = (skills: IndexedSkill[]): string | undefined => {
170
195
  const tools = pi.getActiveTools();
171
- if (!tools.includes("skill")) {
172
- const readable = readableSkillListing(index(), tools.includes("read") ? "the read tool" : "the shell");
173
- if (readable) pi.events.emit(REMINDER_CHANNEL, { text: readable, scope: "every-turn", key: "skills", placement: "first-prepend", order: CONTEXT_ORDER.skills });
196
+ if (!tools.includes("skill")) return readableSkillListing(skills, tools.includes("read") ? "the read tool" : "the shell");
197
+ const listing = describe(skills);
198
+ return listing === "(no skills available)" ? undefined : `${SKILL_LISTING_HEADER}\n\n${listing}`;
199
+ };
200
+ const listableNow = (skills: IndexedSkill[]) => listableSkillNames(skills, !pi.getActiveTools().includes("skill"));
201
+ /** The listing as live state: what an epoch's message 1 says (the render step above announces later skills). */
202
+ const announceSkills = () => {
203
+ const all = index();
204
+ const text = listingText(all);
205
+ if (!text) {
206
+ pi.events.emit(REMINDER_CHANNEL, { key: "skills", remove: true });
174
207
  return;
175
208
  }
176
- const listing = describe();
177
- if (listing !== "(no skills available)") {
178
- pi.events.emit(REMINDER_CHANNEL, {
179
- text: `The following skills are available for use with the Skill tool:\n\n${listing}`,
180
- scope: "every-turn",
181
- key: "skills",
182
- placement: "first-prepend",
183
- order: CONTEXT_ORDER.skills,
184
- });
185
- }
209
+ pi.events.emit(REMINDER_CHANNEL, {
210
+ text,
211
+ key: "skills",
212
+ placement: "first-prepend",
213
+ order: CONTEXT_ORDER.skills,
214
+ facts: [{ kind: "skills", names: listableNow(all) }],
215
+ });
186
216
  };
187
217
  pi.on("before_agent_start", (event, ctx) => {
188
218
  prompted = true;
189
219
  sessionModel = ctx.model ?? sessionModel;
190
220
  adoptPiSkills(event.systemPromptOptions.skills ?? [], ctx.cwd);
221
+ runIndex = undefined;
191
222
  // The listing above is the one skills instruction: drop pi's own section
192
223
  // from the prompt (a child agent's own prompt carries it; the main
193
224
  // session's prompt is rebuilt without it).
@@ -349,10 +380,10 @@ export default function skillExtension(pi: ExtensionAPI) {
349
380
  let sessionUsage: { usage: ReturnType<typeof readUsage>; now: Date } | undefined;
350
381
 
351
382
  /** The listing, within Claude Code's budget for the session model's context window. */
352
- const describe = () => {
383
+ const describe = (source: IndexedSkill[] = index()) => {
353
384
  sessionUsage ??= { usage: readUsage(pluginRoot(getAgentDir())), now: new Date() };
354
385
  const { usage, now } = sessionUsage;
355
- const skills = index().map((skill) => ({
386
+ const skills = source.map((skill) => ({
356
387
  ...skill,
357
388
  bundled: isBundled(skill.path),
358
389
  usage: usageScore(usage[usageKey(skill.kind ?? "skill", skill.name)], now),
@@ -421,10 +452,11 @@ export default function skillExtension(pi: ExtensionAPI) {
421
452
  };
422
453
  }
423
454
 
424
- // "off" is the only state that refuses invocation (matching Claude Code,
425
- // where invoking an off skill by name returns the skillOverrides error).
426
- // "user-only" is invocable — it's just hidden from the model's listing,
427
- // so the model won't auto-trigger it, but /skill-name still runs it.
455
+ // An off skill is refused (matching Claude Code, where invoking an off
456
+ // skill by name returns the skillOverrides error). A user-only skill is
457
+ // hidden from the model and runs only as the user's `/<name>`, which does
458
+ // not come through this tool; a listing frozen before the user hid it can
459
+ // still name it, so the tool refuses it too (./announce.ts).
428
460
  if (found.state === "off") {
429
461
  const where = found.source === "plugin" ? "/plugins" : "/skills";
430
462
  return {
@@ -433,6 +465,13 @@ export default function skillExtension(pi: ExtensionAPI) {
433
465
  isError: true,
434
466
  };
435
467
  }
468
+ if (found.state === "user-only") {
469
+ return {
470
+ content: [{ type: "text", text: `Skill "${found.name}" is set to user-only — only the user can run it, by typing /${found.name}.` }],
471
+ details: { skill: found.name, state: found.state } as Record<string, unknown>,
472
+ isError: true,
473
+ };
474
+ }
436
475
 
437
476
  // Only the user may start a skill marked `disable-model-invocation`
438
477
  // (a typed `/<name>` runs through deliverSkill, not this tool).
@@ -719,28 +758,6 @@ export default function skillExtension(pi: ExtensionAPI) {
719
758
  if (piSkills.some((skill) => !registeredSkillCommands.has(skill.name) && !oneShotSkillNames.has(skill.name))) registerSkillCommands(ctx.cwd);
720
759
  });
721
760
 
722
- // FALLBACK for pi < 0.86.0 (plan.md "Fallbacks for older pi"; delete it
723
- // when the peer floor reaches 0.86.0). Before faa9863cb, pi's steer() and
724
- // followUp() expanded `/skill:<name>` natively WITHOUT firing an `input`
725
- // event (queued interactive messages after the first, RPC steer/followUp),
726
- // so a turned-off skill's instructions could land in the session history.
727
- // On 0.86+ the `input` handler above refuses them first, so this finds
728
- // nothing to redact and returns undefined.
729
- // Strip them from every outgoing request instead — the wire copy carries the
730
- // refusal notice, the session file keeps the original bytes, and untouched
731
- // requests return undefined so the message array stays byte-identical
732
- // (prompt-cache stable). Override states are read lazily, only when a
733
- // message actually contains a `<skill>` block.
734
- pi.on("context", (event) => {
735
- let states: Map<string, string> | undefined;
736
- const isOff = (name: string) => {
737
- states ??= new Map(index().map((skill) => [skill.name, skill.state]));
738
- return states.get(name) === "off";
739
- };
740
- const redacted = redactOffSkillMessages(event.messages, isOff);
741
- return redacted ? { messages: redacted } : undefined;
742
- });
743
-
744
761
  const buildSkillsRows = (cwd: string | undefined): SkillsRow[] =>
745
762
  skillsOnly(cwd).map((skill) => ({
746
763
  key: skillOverrideKey(skill.scope, skill.name),