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.
- package/README.md +88 -10
- package/docs/guide/README.md +1 -0
- package/docs/guide/configuration.md +2 -2
- package/docs/guide/differences-from-claude-code.md +13 -4
- package/docs/guide/hooks.md +14 -2
- package/docs/guide/installation.md +54 -6
- package/docs/guide/permissions-modes-and-auto-mode.md +23 -7
- package/docs/guide/reference.md +5 -2
- package/docs/guide/sessions-and-context.md +18 -0
- package/docs/guide/terminal-interface.md +1 -1
- package/docs/guide/tools.md +24 -2
- package/docs/guide/troubleshooting.md +14 -4
- package/docs/guide/windows.md +184 -0
- package/extensions/auto-mode/actions.ts +3 -1
- package/extensions/auto-mode/paths.ts +21 -23
- package/extensions/auto-mode/safety-floor.ts +44 -6
- package/extensions/auto-mode/shell-analysis.ts +25 -10
- package/extensions/auto-mode/transcript.ts +15 -5
- package/extensions/background/index.ts +83 -34
- package/extensions/background/monitor-batch.ts +7 -5
- package/extensions/background/wakeup.ts +18 -24
- package/extensions/bash/background.ts +19 -10
- package/extensions/bash/guards.ts +36 -16
- package/extensions/bash/index.ts +36 -166
- package/extensions/branding/index.ts +35 -4
- package/extensions/btw/index.ts +207 -0
- package/extensions/btw/panel.ts +179 -0
- package/extensions/btw/prompt.ts +45 -0
- package/extensions/clear/index.ts +15 -0
- package/extensions/compaction/fit.ts +9 -6
- package/extensions/doctor/build.ts +22 -2
- package/extensions/doctor/dependencies.ts +63 -31
- package/extensions/doctor/fix-prompt.ts +12 -4
- package/extensions/doctor/index.ts +1 -0
- package/extensions/doctor/models.ts +2 -1
- package/extensions/doctor/report.ts +2 -3
- package/extensions/doctor/update-lookup.ts +19 -11
- package/extensions/effort/index.ts +2 -1
- package/extensions/footer/footer-line.ts +19 -0
- package/extensions/footer/index.ts +25 -5
- package/extensions/hooks/executor.ts +84 -18
- package/extensions/hooks/index.ts +1 -0
- package/extensions/hooks/matcher.ts +23 -9
- package/extensions/hooks/settings.ts +15 -0
- package/extensions/lib/branch-restore.ts +12 -0
- package/extensions/lib/claude-context.ts +2 -3
- package/extensions/lib/claude-settings.ts +19 -0
- package/extensions/lib/command-launch.ts +71 -0
- package/extensions/lib/deferred.ts +173 -33
- package/extensions/lib/git.ts +16 -0
- package/extensions/lib/keys.ts +38 -0
- package/extensions/lib/local-command.ts +112 -0
- package/extensions/lib/model-facts.generated.json +263 -68
- package/extensions/lib/model-policy.ts +13 -0
- package/extensions/lib/model-tier.ts +16 -5
- package/extensions/lib/notifications.ts +547 -53
- package/extensions/lib/paths.ts +130 -2
- package/extensions/lib/pi-ai-estimate.ts +57 -26
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/process-tree.ts +131 -5
- package/extensions/lib/reminders.ts +80 -27
- package/extensions/lib/scratchpad.ts +11 -10
- package/extensions/lib/shell-spawn.ts +298 -0
- package/extensions/lib/shell-tool.ts +252 -0
- package/extensions/lib/shell-tools.ts +13 -0
- package/extensions/lib/side-call.ts +76 -0
- package/extensions/lib/tui-render.ts +62 -11
- package/extensions/lib/update-check.mjs +155 -0
- package/extensions/lib/usage-bus.ts +1 -1
- package/extensions/lib/which.ts +62 -0
- package/extensions/lib/worktree-channel.ts +18 -0
- package/extensions/lib/worktree-isolation.ts +2 -1
- package/extensions/lsp/client.ts +14 -3
- package/extensions/lsp/index.ts +12 -5
- package/extensions/lsp/install-hints.ts +33 -3
- package/extensions/lsp/plugin-servers.ts +2 -2
- package/extensions/lsp/servers.ts +29 -7
- package/extensions/mcp/index.ts +3 -2
- package/extensions/memory/entries.ts +9 -6
- package/extensions/memory/index.ts +3 -2
- package/extensions/memory/open-external.ts +4 -1
- package/extensions/model-default/index.ts +21 -2
- package/extensions/permissions/index.ts +57 -28
- package/extensions/permissions/matcher.ts +120 -33
- package/extensions/permissions/modes.ts +14 -6
- package/extensions/permissions/powershell-rules.ts +549 -0
- package/extensions/permissions/session-grant.ts +9 -9
- package/extensions/plugins/index.ts +3 -2
- package/extensions/plugins/install/install.ts +1 -1
- package/extensions/powershell/description.ts +139 -0
- package/extensions/powershell/guards.ts +91 -0
- package/extensions/powershell/index.ts +89 -0
- package/extensions/powershell/policy.ts +91 -0
- package/extensions/recap/index.ts +10 -24
- package/extensions/recap/prompt.ts +3 -3
- package/extensions/session-title/index.ts +120 -0
- package/extensions/session-title/title.ts +111 -0
- package/extensions/skill/index.ts +40 -18
- package/extensions/skill/invoke.ts +1 -0
- package/extensions/subagents/hand-back-review.ts +10 -12
- package/extensions/subagents/index.ts +221 -78
- package/extensions/subagents/model-select.ts +75 -7
- package/extensions/system-prompt/environment.ts +14 -1
- package/extensions/system-reminder/index.ts +9 -0
- package/extensions/tasks/index.ts +2 -1
- package/extensions/tool-search/index.ts +41 -20
- package/extensions/workflow/agent-session.ts +6 -4
- package/extensions/workflow/index.ts +28 -5
- package/extensions/worktree/guards.ts +5 -4
- package/extensions/worktree/index.ts +15 -2
- package/extensions/worktree/rewrite.ts +17 -0
- package/package.json +7 -4
- package/types/app.d.ts +18 -2
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* `{ name, keywords? }` while extensions are loading (before session_start).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { toolResultsOnBranch } from "./branch-restore.ts";
|
|
13
15
|
import { parseClaudeVersion } from "./model-tier.ts";
|
|
14
16
|
import { normalizeToolName } from "../permissions/matcher.ts";
|
|
15
17
|
|
|
@@ -106,51 +108,189 @@ export interface DeferrableToolInfo {
|
|
|
106
108
|
parameters?: { properties?: unknown; required?: unknown };
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
/** The `tool_search` calls that loaded tools: each call's id → the names it loaded. */
|
|
112
|
+
export type ToolSearchLoads = ReadonlyMap<string, readonly string[]>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Every load recorded on a session branch: a `tool_search` result whose
|
|
116
|
+
* persisted `details.added` names the tools it activated. Read once at
|
|
117
|
+
* session start (a resumed session's earlier loads); loads made during the
|
|
118
|
+
* session are captured at the call itself.
|
|
119
|
+
*/
|
|
120
|
+
export function toolSearchLoads(branch: readonly SessionEntry[]): Map<string, string[]> {
|
|
121
|
+
const loads = new Map<string, string[]>();
|
|
122
|
+
for (const message of toolResultsOnBranch(branch, TOOL_SEARCH_NAMES)) {
|
|
123
|
+
const added = (message.details as { added?: unknown } | undefined)?.added;
|
|
124
|
+
if (!Array.isArray(added)) continue;
|
|
125
|
+
const names = added.filter((n): n is string => typeof n === "string");
|
|
126
|
+
if (names.length > 0) loads.set(message.toolCallId, names);
|
|
127
|
+
}
|
|
128
|
+
return loads;
|
|
129
|
+
}
|
|
130
|
+
const TOOL_SEARCH_NAMES: ReadonlySet<string> = new Set(["tool_search"]);
|
|
131
|
+
|
|
132
|
+
type WireTool = Record<string, unknown> & { name: string };
|
|
133
|
+
type WireBlock = Record<string, unknown> & { type?: unknown };
|
|
134
|
+
type WireMessage = { role?: unknown; content?: unknown };
|
|
135
|
+
|
|
136
|
+
/** A deferred definition in pi's own `convertTools` shape (findings §7). */
|
|
137
|
+
function deferredDefinition(tool: DeferrableToolInfo): WireTool {
|
|
138
|
+
return {
|
|
139
|
+
name: tool.name,
|
|
140
|
+
description: tool.description ?? "",
|
|
141
|
+
input_schema: {
|
|
142
|
+
type: "object",
|
|
143
|
+
properties: tool.parameters?.properties ?? {},
|
|
144
|
+
required: tool.parameters?.required ?? [],
|
|
145
|
+
},
|
|
146
|
+
defer_loading: true,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The list with pi's cache breakpoint on its last item (a no-op without one). */
|
|
151
|
+
function withBreakpointOnLast<T extends Record<string, unknown>>(items: T[], cache_control: unknown): T[] {
|
|
152
|
+
if (cache_control === undefined || items.length === 0) return items;
|
|
153
|
+
return [...items.slice(0, -1), { ...items[items.length - 1], cache_control }];
|
|
154
|
+
}
|
|
155
|
+
|
|
109
156
|
/**
|
|
110
|
-
* An Anthropic request
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
157
|
+
* An Anthropic request whose `tools` array is byte-identical to request 1's,
|
|
158
|
+
* whatever `tool_search` has loaded since — the client side of Anthropic's
|
|
159
|
+
* deferred-tool contract, which pi 0.86 no longer supplies for One Code
|
|
160
|
+
* (rationale and measurements: docs/decisions/caching.md "Loaded deferred
|
|
161
|
+
* tools stay deferred on the wire", findings §7).
|
|
162
|
+
*
|
|
163
|
+
* - Every registry tool `isDeferred` that is not eager on the wire is appended
|
|
164
|
+
* as a `defer_loading: true` definition, sorted by name; pi's own rendering
|
|
165
|
+
* of a later-deferred registry tool is replaced by ours.
|
|
166
|
+
* - A loaded tool pi promoted to eager is demoted back to deferred, the tools
|
|
167
|
+
* cache breakpoint moves to the last remaining eager tool, and the
|
|
168
|
+
* `tool_search` result that loaded it (`loads`) is rewritten to
|
|
169
|
+
* `tool_reference` blocks, its own content moving to trailing sibling blocks
|
|
170
|
+
* that take the result's breakpoint (Anthropic rejects references mixed with
|
|
171
|
+
* ordinary result content). Demotion happens only when that result is on the
|
|
172
|
+
* wire and precedes every `tool_use` of the tool; otherwise the tool stays
|
|
173
|
+
* eager (one accepted cache miss, never a request Anthropic rejects).
|
|
174
|
+
* - pi's own deferred entries that are not registry tools (its
|
|
175
|
+
* `__pi_deferred_placeholder__`) stay in place.
|
|
118
176
|
*
|
|
119
|
-
* Returns undefined (leave the payload alone) when
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
* `convertTools` shape. Never mutates its input.
|
|
177
|
+
* Returns undefined (leave the payload alone) when the request carries no
|
|
178
|
+
* tools or the wire names are not the registry's (pi's OAuth "stealth" mode
|
|
179
|
+
* renames them to Claude Code casing, and an unmatched name must not be
|
|
180
|
+
* duplicated). Never mutates its input.
|
|
124
181
|
*/
|
|
125
|
-
export function
|
|
182
|
+
export function stabilizeDeferredTools(
|
|
126
183
|
payload: Record<string, unknown>,
|
|
127
184
|
tools: readonly DeferrableToolInfo[],
|
|
128
185
|
isDeferred: (name: string) => boolean,
|
|
186
|
+
loads: ToolSearchLoads = new Map(),
|
|
129
187
|
): Record<string, unknown> | undefined {
|
|
130
188
|
const existing = payload.tools;
|
|
131
189
|
if (!Array.isArray(existing) || existing.length === 0) return undefined;
|
|
132
190
|
const registry = new Set(tools.map((t) => t.name));
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
191
|
+
|
|
192
|
+
const eager: WireTool[] = [];
|
|
193
|
+
const anchors: WireTool[] = [];
|
|
194
|
+
for (const raw of existing) {
|
|
195
|
+
const tool = raw as WireTool | null;
|
|
196
|
+
if (typeof tool?.name !== "string") return undefined;
|
|
197
|
+
if (!registry.has(tool.name)) {
|
|
198
|
+
if (tool.defer_loading === true) anchors.push(tool);
|
|
199
|
+
else return undefined;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (tool.defer_loading !== true) eager.push(tool);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const messages: WireMessage[] = Array.isArray(payload.messages) ? (payload.messages as WireMessage[]) : [];
|
|
206
|
+
const referencesAt = new Map<string, string[]>();
|
|
207
|
+
const demoted = new Set<string>();
|
|
208
|
+
if (loads.size > 0 && eager.some((tool) => isDeferred(tool.name))) {
|
|
209
|
+
// Message index of each tool's first use and of each tool result: a
|
|
210
|
+
// reference is valid only ahead of every use of the tool.
|
|
211
|
+
const firstUse = new Map<string, number>();
|
|
212
|
+
const resultAt = new Map<string, number>();
|
|
213
|
+
messages.forEach((message, index) => {
|
|
214
|
+
if (!Array.isArray(message?.content)) return;
|
|
215
|
+
for (const block of message.content as WireBlock[]) {
|
|
216
|
+
if (block?.type === "tool_use" && typeof block.name === "string" && !firstUse.has(block.name)) {
|
|
217
|
+
firstUse.set(block.name, index);
|
|
218
|
+
} else if (block?.type === "tool_result" && typeof block.tool_use_id === "string") {
|
|
219
|
+
resultAt.set(block.tool_use_id, index);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
// The earliest on-wire load of each tool.
|
|
224
|
+
const loaderOf = new Map<string, { callId: string; at: number }>();
|
|
225
|
+
for (const [callId, names] of loads) {
|
|
226
|
+
const at = resultAt.get(callId);
|
|
227
|
+
if (at === undefined) continue;
|
|
228
|
+
for (const name of names) {
|
|
229
|
+
const current = loaderOf.get(name);
|
|
230
|
+
if (current === undefined || at < current.at) loaderOf.set(name, { callId, at });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const tool of eager) {
|
|
234
|
+
if (!isDeferred(tool.name)) continue;
|
|
235
|
+
const loader = loaderOf.get(tool.name);
|
|
236
|
+
if (loader === undefined) continue;
|
|
237
|
+
const use = firstUse.get(tool.name);
|
|
238
|
+
if (use !== undefined && use < loader.at) continue;
|
|
239
|
+
demoted.add(tool.name);
|
|
240
|
+
referencesAt.set(loader.callId, [...(referencesAt.get(loader.callId) ?? []), tool.name]);
|
|
241
|
+
}
|
|
138
242
|
}
|
|
139
|
-
|
|
140
|
-
|
|
243
|
+
|
|
244
|
+
// pi puts the tools breakpoint on the last eager tool; if that one is demoted
|
|
245
|
+
// the breakpoint moves to the new last one, otherwise nothing moves.
|
|
246
|
+
const last = eager.at(-1);
|
|
247
|
+
const keptEager = withBreakpointOnLast(
|
|
248
|
+
eager.filter((tool) => !demoted.has(tool.name)),
|
|
249
|
+
last !== undefined && demoted.has(last.name) ? last.cache_control : undefined,
|
|
250
|
+
);
|
|
251
|
+
if (keptEager.length === 0) return undefined;
|
|
252
|
+
const eagerNames = new Set(keptEager.map((tool) => tool.name));
|
|
253
|
+
const deferred = tools
|
|
254
|
+
.filter((t) => isDeferred(t.name) && !eagerNames.has(t.name))
|
|
141
255
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
142
|
-
.map(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
256
|
+
.map(deferredDefinition);
|
|
257
|
+
return {
|
|
258
|
+
...payload,
|
|
259
|
+
tools: [...keptEager, ...anchors, ...deferred],
|
|
260
|
+
messages: referencesAt.size === 0 ? payload.messages : messages.map((m) => withToolReferences(m, referencesAt)),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The user message with each loading `tool_result` rewritten to carry its
|
|
266
|
+
* `tool_reference` blocks and its original content moved to trailing sibling
|
|
267
|
+
* blocks; a breakpoint on the rewritten result moves to the new last block so
|
|
268
|
+
* the whole message stays inside the cached prefix.
|
|
269
|
+
*/
|
|
270
|
+
function withToolReferences(message: WireMessage, referencesAt: ReadonlyMap<string, readonly string[]>): WireMessage {
|
|
271
|
+
if (message?.role !== "user" || !Array.isArray(message.content)) return message;
|
|
272
|
+
const content = message.content as WireBlock[];
|
|
273
|
+
if (!content.some((block) => block?.type === "tool_result" && referencesAt.has(block.tool_use_id as string))) return message;
|
|
274
|
+
|
|
275
|
+
const blocks: WireBlock[] = [];
|
|
276
|
+
const siblings: WireBlock[] = [];
|
|
277
|
+
let movedBreakpoint: unknown;
|
|
278
|
+
for (const block of content) {
|
|
279
|
+
const names = block?.type === "tool_result" ? referencesAt.get(block.tool_use_id as string) : undefined;
|
|
280
|
+
if (!names) {
|
|
281
|
+
blocks.push(block);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const { content: original, cache_control, ...rest } = block;
|
|
285
|
+
if (cache_control !== undefined) movedBreakpoint = cache_control;
|
|
286
|
+
blocks.push({ ...rest, content: names.map((name) => ({ type: "tool_reference", tool_name: name })) });
|
|
287
|
+
if (typeof original === "string") {
|
|
288
|
+
if (original.trim().length > 0) siblings.push({ type: "text", text: original });
|
|
289
|
+
} else if (Array.isArray(original)) {
|
|
290
|
+
siblings.push(...(original as WireBlock[]));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return { ...message, content: withBreakpointOnLast([...blocks, ...siblings], movedBreakpoint) };
|
|
154
294
|
}
|
|
155
295
|
|
|
156
296
|
/**
|
package/extensions/lib/git.ts
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
/** Shared git helpers — pure functions only (safe to import across extensions). */
|
|
2
2
|
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
3
4
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
5
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Whether the checkout at `cwd` has no uncommitted or untracked changes, or
|
|
9
|
+
* undefined when git could not say (not a repo, git missing). One short
|
|
10
|
+
* `git status --porcelain`, asynchronous so the tool_call hook it runs from
|
|
11
|
+
* does not stall the event loop; used for the classifier transcript's
|
|
12
|
+
* ground-truth line after a `git status` call (permissions/index.ts).
|
|
13
|
+
*/
|
|
14
|
+
export function gitStatusClean(cwd: string): Promise<boolean | undefined> {
|
|
15
|
+
return new Promise((resolvePromise) => {
|
|
16
|
+
execFile("git", ["status", "--porcelain"], { cwd, encoding: "utf8", timeout: 5_000, windowsHide: true }, (error, stdout) => {
|
|
17
|
+
resolvePromise(error ? undefined : stdout.trim().length === 0);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
6
22
|
/**
|
|
7
23
|
* The nearest checkout root at or above `startDir`: the directory holding a
|
|
8
24
|
* `.git` entry (a directory for a main checkout, a file for a linked worktree
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keys that depend on the platform.
|
|
3
|
+
*
|
|
4
|
+
* pi rebinds a handful of its own shortcuts on Windows and WSL, where
|
|
5
|
+
* alt+enter and ctrl+shift+letter do not reach the process reliably: queue
|
|
6
|
+
* follow-up moves from alt+enter to ctrl+q, fork from ctrl+shift+f to ctrl+f,
|
|
7
|
+
* paste to alt+v, model select to alt+p (pi-coding-agent core/keybindings.js,
|
|
8
|
+
* `useWindowsKeybindings`, which pi does not export — the rule is copied
|
|
9
|
+
* here). So the ctrl+q that cycles permission modes everywhere else collides
|
|
10
|
+
* there: pi skips a `registerShortcut` on a built-in key with a startup
|
|
11
|
+
* warning, and mode cycling had no key at all on Windows (seen on the Vultr
|
|
12
|
+
* VM, 2026-09-19). No ctrl+letter is free on Windows once pi's app and editor
|
|
13
|
+
* bindings are counted, and pi itself reaches for alt there, so mode cycling
|
|
14
|
+
* is alt+m on Windows and WSL — docs/decisions/windows.md.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** pi's rule for its Windows key table: native Windows, or a WSL distro. */
|
|
18
|
+
export function useWindowsKeybindings(platform: string = process.platform, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
19
|
+
return platform === "win32" || (platform === "linux" && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The key that cycles permission modes: ctrl+q, or alt+m where pi owns ctrl+q. */
|
|
23
|
+
export function modeCycleKey(platform: string = process.platform, env: NodeJS.ProcessEnv = process.env): "ctrl+q" | "alt+m" {
|
|
24
|
+
return useWindowsKeybindings(platform, env) ? "alt+m" : "ctrl+q";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The footer badge's pause icon. Claude Code's is U+23F8 (⏸); Windows Terminal
|
|
29
|
+
* draws that code point with its emoji font, two cells wide and coloured,
|
|
30
|
+
* while pi measures one cell, so the icon overlapped the mode name — and the
|
|
31
|
+
* text-presentation selector (U+FE0E) is ignored there (verified on the Vultr
|
|
32
|
+
* VM, 2026-09-19: rows A and B of the width test). U+2016 (‖) is a plain
|
|
33
|
+
* single-cell glyph in Windows Terminal, so it stands in on Windows and WSL,
|
|
34
|
+
* where the terminal is Windows Terminal either way.
|
|
35
|
+
*/
|
|
36
|
+
export function pauseGlyph(platform: string = process.platform, env: NodeJS.ProcessEnv = process.env): "⏸" | "‖" {
|
|
37
|
+
return useWindowsKeybindings(platform, env) ? "‖" : "⏸";
|
|
38
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-command breadcrumbs — what Claude Code shows the model when the user
|
|
3
|
+
* runs a slash command that the harness handles locally (`/clear`, `/model`, a
|
|
4
|
+
* panel command). Captured from CC 2.1.278 (`captures/sonnet.json`, and this
|
|
5
|
+
* repo's own sessions after a `/clear`; findings §14, 2026-09-19): three
|
|
6
|
+
* synthetic user messages that reach the model as consecutive bare text blocks
|
|
7
|
+
* of the next user message, after the reminder stack and before the user's own
|
|
8
|
+
* text —
|
|
9
|
+
*
|
|
10
|
+
* <local-command-caveat>Caveat: …</local-command-caveat>\n
|
|
11
|
+
* <command-name>/model</command-name>\n
|
|
12
|
+
* <command-message>model</command-message>\n
|
|
13
|
+
* <command-args></command-args>\n
|
|
14
|
+
* <local-command-stdout>Set model to `Sonnet 5` …</local-command-stdout>\n
|
|
15
|
+
*
|
|
16
|
+
* (twelve-space indent on the second and third command lines, verbatim from
|
|
17
|
+
* CC's `formatCommandInputTags`; stdout XML-escaped like CC's `escapeXml`, `&
|
|
18
|
+
* < >` only). `/clear` carries an EMPTY stdout block and opens the new
|
|
19
|
+
* conversation. The caveat tells the model not to act on any of it.
|
|
20
|
+
*
|
|
21
|
+
* One Code emits these through the reminder queue's `user-prepend` placement
|
|
22
|
+
* (`lib/reminders.ts`): pi dispatches an extension command BEFORE its `input`
|
|
23
|
+
* event fires and handles its own built-ins (`/model`, `/name`, …) before
|
|
24
|
+
* either, so there is no single interception point — every One Code local
|
|
25
|
+
* command is registered through `registerLocalCommand`, which announces before
|
|
26
|
+
* the handler runs, and `/model` is announced from the `model_select` event by
|
|
27
|
+
* the extension that persists the choice. pi built-ins we cannot see
|
|
28
|
+
* (`/compact`, `/settings`, …) stay silent. The blocks are pinned in process
|
|
29
|
+
* memory, not written to the session file as CC does — a `--resume` drops them.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { REMINDER_CHANNEL, type ReminderPayload } from "./reminders.ts";
|
|
33
|
+
|
|
34
|
+
/** The slice of pi's ExtensionAPI the announcer needs (its event bus). */
|
|
35
|
+
export interface ReminderEmitter {
|
|
36
|
+
events: { emit(channel: string, data: unknown): unknown };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How each breadcrumb block begins — what a first message that is NOT the
|
|
41
|
+
* user's topic starts with (the session-title extension skips these, as CC's
|
|
42
|
+
* REPL does before naming a session).
|
|
43
|
+
*/
|
|
44
|
+
export const LOCAL_COMMAND_TAG_PREFIXES = ["<local-command-caveat>", "<command-name>", "<command-message>", "<local-command-stdout>"] as const;
|
|
45
|
+
|
|
46
|
+
export const LOCAL_COMMAND_CAVEAT =
|
|
47
|
+
"<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>\n";
|
|
48
|
+
|
|
49
|
+
/** CC's `escapeXml` (utils/xml.ts): `& < >` only. */
|
|
50
|
+
export function escapeXml(s: string): string {
|
|
51
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface LocalCommand {
|
|
55
|
+
/** Command name without the slash (`clear`, `model`). */
|
|
56
|
+
name: string;
|
|
57
|
+
/** The argument text the user typed after the name; "" when none. */
|
|
58
|
+
args?: string;
|
|
59
|
+
/** The command's printed result; "" (the default) is what `/clear` shows. */
|
|
60
|
+
stdout?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** CC's `formatCommandInputTags` — the command breadcrumb, indentation included. */
|
|
64
|
+
export function commandInputBlock(name: string, args = ""): string {
|
|
65
|
+
return `<command-name>/${name}</command-name>\n <command-message>${name}</command-message>\n <command-args>${args}</command-args>\n`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** CC's `<local-command-stdout>` message; the content is XML-escaped. */
|
|
69
|
+
export function commandStdoutBlock(stdout = ""): string {
|
|
70
|
+
return `<local-command-stdout>${escapeXml(stdout)}</local-command-stdout>\n`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The three bare text blocks a local command leaves for the model, in CC's order. */
|
|
74
|
+
export function localCommandBlocks(command: LocalCommand): string[] {
|
|
75
|
+
return [LOCAL_COMMAND_CAVEAT, commandInputBlock(command.name, (command.args ?? "").trim()), commandStdoutBlock(command.stdout ?? "")];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Tell the model a local command ran. Call it from the command's handler
|
|
80
|
+
* (before a `/clear` resets the session — the queue survives `newSession()`,
|
|
81
|
+
* so the breadcrumb opens the new conversation exactly as in CC).
|
|
82
|
+
*/
|
|
83
|
+
export function announceLocalCommand(pi: ReminderEmitter, command: LocalCommand): void {
|
|
84
|
+
localCommandBlocks(command).forEach((text, index) => {
|
|
85
|
+
// The caveat heads a RUN of commands: a second command before the next
|
|
86
|
+
// prompt joins the first caveat instead of repeating it.
|
|
87
|
+
const payload: ReminderPayload = { text, placement: "user-prepend", raw: true, ...(index === 0 ? { once: true } : {}) };
|
|
88
|
+
pi.events.emit(REMINDER_CHANNEL, payload);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type CommandOptions = { handler: (args: string, ctx: any) => Promise<void> | void } & Record<string, unknown>;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `pi.registerCommand` for a One Code local command: the breadcrumb is
|
|
96
|
+
* announced before the handler runs, so no command can forget it. `/clear`
|
|
97
|
+
* is the one exception — it announces from the NEW session's `session_start`
|
|
98
|
+
* (see clear/index.ts) and registers plainly.
|
|
99
|
+
*/
|
|
100
|
+
export function registerLocalCommand<O extends CommandOptions>(
|
|
101
|
+
pi: ReminderEmitter & { registerCommand(name: string, options: O): void },
|
|
102
|
+
name: string,
|
|
103
|
+
options: O,
|
|
104
|
+
): void {
|
|
105
|
+
pi.registerCommand(name, {
|
|
106
|
+
...options,
|
|
107
|
+
handler: (args: string, ctx: unknown) => {
|
|
108
|
+
announceLocalCommand(pi, { name, args });
|
|
109
|
+
return options.handler(args, ctx);
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|