one-code-extension 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/guide/differences-from-claude-code.md +9 -6
- package/docs/guide/installation.md +3 -4
- package/docs/guide/troubleshooting.md +1 -1
- package/extensions/auto-mode/denials.ts +3 -2
- package/extensions/background/cron.ts +127 -0
- package/extensions/background/index.ts +135 -47
- package/extensions/background/loop-fire.ts +39 -2
- package/extensions/background/switch-guard.ts +10 -6
- package/extensions/background/wakeup.ts +21 -2
- package/extensions/bash/index.ts +8 -30
- package/extensions/btw/index.ts +32 -18
- package/extensions/cache-marks/index.ts +42 -0
- package/extensions/claude-context/index.ts +157 -172
- package/extensions/compaction/index.ts +11 -8
- package/extensions/context-budget/budget.ts +16 -2
- package/extensions/context-budget/index.ts +15 -14
- package/extensions/context-management/index.ts +11 -28
- package/extensions/effort/index.ts +70 -7
- package/extensions/file-tracker/index.ts +111 -44
- package/extensions/file-tracker/replay.ts +76 -49
- package/extensions/file-tracker/restore.ts +12 -9
- package/extensions/file-tracker/tracker.ts +14 -1
- package/extensions/hooks/index.ts +60 -76
- package/extensions/hooks/prompt-context.ts +59 -0
- package/extensions/hooks/trust.ts +13 -7
- package/extensions/ledger/index.ts +46 -0
- package/extensions/ledger/wire.ts +154 -0
- package/extensions/lib/agent-cron.ts +17 -1
- package/extensions/lib/agent-loader.ts +3 -2
- package/extensions/lib/anthropic-auth.ts +98 -0
- package/extensions/lib/child-extensions.ts +14 -4
- package/extensions/lib/context-facts.ts +75 -56
- package/extensions/lib/covered-messages.ts +23 -0
- package/extensions/lib/deferred.ts +152 -12
- package/extensions/lib/ledger/fold-memo.ts +49 -0
- package/extensions/lib/ledger/fold.ts +265 -0
- package/extensions/lib/ledger/layout.ts +72 -0
- package/extensions/lib/ledger/plan.ts +102 -0
- package/extensions/lib/ledger/records.ts +276 -0
- package/extensions/lib/ledger/state.ts +47 -0
- package/extensions/lib/ledger/validate.ts +141 -0
- package/extensions/lib/ledger/wire.ts +632 -0
- package/extensions/lib/local-command.ts +24 -6
- package/extensions/lib/model-catalog/huggingface.json +5 -1
- package/extensions/lib/model-catalog/models-dev.json +10535 -10299
- package/extensions/lib/model-catalog/openrouter.json +191 -393
- package/extensions/lib/notifications.ts +57 -17
- package/extensions/lib/pi-version.ts +25 -8
- package/extensions/lib/plan-mode-channels.ts +29 -7
- package/extensions/lib/previous-mark.ts +101 -0
- package/extensions/lib/process-state.ts +48 -0
- package/extensions/lib/process-tree.ts +3 -2
- package/extensions/lib/reminders.ts +327 -621
- package/extensions/lib/request-replay.ts +186 -78
- package/extensions/lib/side-call.ts +11 -0
- package/extensions/lib/subagent-handback.ts +0 -1
- package/extensions/lib/tool-additions.ts +2 -22
- package/extensions/lib/turn-budget-layout.ts +18 -2
- package/extensions/lsp/index.ts +6 -8
- package/extensions/mcp/announce.ts +63 -98
- package/extensions/mcp/index.ts +51 -90
- package/extensions/mcp/trust.ts +13 -6
- package/extensions/model-default/index.ts +86 -19
- package/extensions/permissions/index.ts +157 -48
- package/extensions/permissions/modes.ts +5 -0
- package/extensions/plan-mode/index.ts +142 -45
- package/extensions/plan-mode/run-state.ts +26 -0
- package/extensions/plugins/index.ts +4 -1
- package/extensions/powershell/index.ts +2 -0
- package/extensions/recap/index.ts +11 -14
- package/extensions/search-tools/index.ts +2 -0
- package/extensions/skill/announce.ts +30 -0
- package/extensions/skill/index.ts +65 -48
- package/extensions/skill/invoke.ts +0 -67
- package/extensions/subagents/agents.ts +30 -8
- package/extensions/subagents/fork-cache.ts +9 -3
- package/extensions/subagents/index.ts +180 -107
- package/extensions/subagents/outcome.ts +3 -0
- package/extensions/subagents/parent-mode.ts +50 -0
- package/extensions/subagents/pending-claim.ts +18 -1
- package/extensions/subagents/runner.ts +29 -2
- package/extensions/subagents/runs.ts +5 -0
- package/extensions/system-prompt/index.ts +40 -6
- package/extensions/system-prompt/template.ts +10 -1
- package/extensions/system-reminder/index.ts +174 -202
- package/extensions/tool-search/index.ts +241 -167
- package/extensions/workflow/agent-session.ts +4 -1
- package/extensions/workflow/index.ts +16 -4
- package/extensions/workflow/run-manager.ts +4 -0
- package/extensions/worktree/index.ts +50 -14
- package/package.json +5 -2
- package/extensions/lib/context-stack.ts +0 -81
- package/extensions/subagents/announce.ts +0 -69
- package/extensions/tool-search/announce.ts +0 -58
|
@@ -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
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
88
|
-
|
|
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
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* block stays byte-identical turn
|
|
93
|
-
*
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
117
|
-
|
|
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,
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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.
|
|
276
|
-
//
|
|
277
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
163
|
-
//
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 =
|
|
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
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
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),
|