@adeildo/pi-harness 4.0.0 → 4.1.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.
@@ -1,212 +0,0 @@
1
- // The start screen. A card with the π drawn in blocks, coloured along the theme's effort ramp, next to
2
- // what the session starts with: the model, the folder, the machine, what loaded, and the keys worth
3
- // knowing. Narrow terminals get two plain lines instead.
4
- import { keyText } from "@earendil-works/pi-coding-agent";
5
- import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
6
-
7
- import { BOXES, border } from "../render/frame.ts";
8
- import { EFFORT_LEVELS, type Paint } from "../render/paint.ts";
9
- import { renderSegments, type SegmentId } from "../render/segments.ts";
10
- import type { HeaderStyle } from "../settings.ts";
11
- import type { Screen } from "./screen.ts";
12
-
13
- /** The π, one character per pixel, drawn two pixels per row with half blocks. */
14
- const PIXELS = [
15
- "..............",
16
- ".############.",
17
- "##############",
18
- "...##....##...",
19
- "...##....##...",
20
- "...##....##...",
21
- "...##....##...",
22
- "...##....##...",
23
- "..##.....###..",
24
- ".##.......####",
25
- ];
26
- const LOGO = halfBlocks(PIXELS);
27
- /** One effort level per row, from cool at the top to hot at the bottom. */
28
- const LOGO_RAMP = EFFORT_LEVELS.slice(1);
29
- const LOGO_COLUMN = 20;
30
- /** Under this the right column is too cramped to share with the logo. */
31
- const MIN_RIGHT = 34;
32
- const MIN_CARD = 48;
33
-
34
- export interface LoadedCounts {
35
- tools: number;
36
- skills: number;
37
- prompts: number;
38
- extensions: number;
39
- }
40
-
41
- export interface HeaderSource {
42
- style(): HeaderStyle;
43
- counts(): LoadedCounts;
44
- }
45
-
46
- interface Hint {
47
- key: string;
48
- does: string;
49
- }
50
-
51
- export class HeaderComponent implements Component {
52
- readonly #screen: Screen;
53
- readonly #source: HeaderSource;
54
-
55
- constructor(screen: Screen, source: HeaderSource) {
56
- this.#screen = screen;
57
- this.#source = source;
58
- }
59
-
60
- render(width: number): string[] {
61
- if (width < MIN_CARD || this.#source.style() === "compact") return this.#compact(width);
62
- return this.#card(width);
63
- }
64
-
65
- invalidate(): void {}
66
-
67
- #card(width: number): string[] {
68
- const screen = this.#screen;
69
- const paint = screen.paint();
70
- const box = BOXES.rounded;
71
- const frame = paint.frame;
72
- const title = this.#title(paint);
73
- const inner = width - 4;
74
- const withLogo = inner - LOGO_COLUMN - 3 >= MIN_RIGHT;
75
- const rightWidth = withLogo ? inner - LOGO_COLUMN - 3 : inner;
76
-
77
- const right = [
78
- this.#segments(["model", "effort"], paint),
79
- this.#segments(["path", "branch"], paint),
80
- this.#loaded(paint),
81
- paint.dim("─".repeat(Math.max(0, rightWidth))),
82
- ...this.#hints(paint, rightWidth),
83
- ];
84
- const left = [
85
- ...LOGO.map((row, index) => paint.effort(LOGO_RAMP[index] ?? "max", row)),
86
- "",
87
- paint.italic(paint.dim("ready when you are")),
88
- ];
89
-
90
- const rows = Math.max(left.length, right.length);
91
- const lines = [
92
- border(width, [{ text: title, priority: 1 }], [], {
93
- box,
94
- edge: "top",
95
- paint: frame,
96
- separator: " ",
97
- }),
98
- ];
99
- for (let row = 0; row < rows; row++) {
100
- const content = withLogo
101
- ? `${center(left[row] ?? "", LOGO_COLUMN)} ${frame("│")} ${pad(right[row] ?? "", rightWidth)}`
102
- : pad(right[row] ?? "", rightWidth);
103
- lines.push(`${frame(box.vertical)} ${content} ${frame(box.vertical)}`);
104
- }
105
- lines.push(border(width, [], [], { box, edge: "bottom", paint: frame, separator: " " }));
106
- lines.push("");
107
- return lines.map((line) => truncateToWidth(line, width, ""));
108
- }
109
-
110
- #compact(width: number): string[] {
111
- const paint = this.#screen.paint();
112
- const separator = paint.dim(this.#screen.separator());
113
- const first = [this.#title(paint), this.#segments(["model", "effort", "path", "branch"], paint)]
114
- .filter((part) => part !== "")
115
- .join(separator);
116
- const hints = HINTS()
117
- .map((hint) => `${paint.role("brand", hint.key)} ${paint.dim(hint.does)}`)
118
- .join(separator);
119
- return [first, hints, ""].map((line) => truncateToWidth(` ${line}`, width, "…"));
120
- }
121
-
122
- #title(paint: Paint): string {
123
- const glyph = paint.bold(paint.role("brand", "π"));
124
- return `${glyph} ${paint.bold("pi")} ${paint.dim(`v${this.#screen.snapshot().version}`)}`;
125
- }
126
-
127
- #segments(ids: readonly SegmentId[], paint: Paint): string {
128
- const pieces = renderSegments(ids, {
129
- snapshot: this.#screen.snapshot(),
130
- glyphs: this.#screen.glyphs(),
131
- paint,
132
- options: this.#screen.options(),
133
- });
134
- return pieces.map((piece) => piece.text).join(paint.dim(this.#screen.separator()));
135
- }
136
-
137
- #loaded(paint: Paint): string {
138
- const counts = this.#source.counts();
139
- const parts = [
140
- this.#segments(["host"], paint),
141
- ...[
142
- [counts.tools, "tools"],
143
- [counts.skills, "skills"],
144
- [counts.prompts, "prompts"],
145
- [counts.extensions, "extensions"],
146
- ]
147
- .filter(([value]) => (value as number) > 0)
148
- .map(([value, label]) => `${paint.text(String(value))} ${paint.dim(String(label))}`),
149
- ];
150
- return parts.filter((part) => part !== "").join(paint.dim(this.#screen.separator()));
151
- }
152
-
153
- /** Three rows of two hints each, or one column when narrow. */
154
- #hints(paint: Paint, width: number): string[] {
155
- const hints = HINTS();
156
- const keyWidth = Math.max(...hints.map((hint) => visibleWidth(hint.key)));
157
- const cell = (hint: Hint) =>
158
- `${paint.role("brand", hint.key.padEnd(keyWidth))} ${paint.dim(hint.does)}`;
159
- const cellWidth = keyWidth + 1 + Math.max(...hints.map((hint) => visibleWidth(hint.does)));
160
- if (width < cellWidth * 2 + 3) return hints.slice(0, 3).map(cell);
161
- const rows: string[] = [];
162
- for (let index = 0; index < hints.length; index += 2) {
163
- const first = hints[index];
164
- const second = hints[index + 1];
165
- if (first === undefined) break;
166
- rows.push(
167
- second === undefined ? cell(first) : `${pad(cell(first), cellWidth)} ${cell(second)}`,
168
- );
169
- }
170
- return rows;
171
- }
172
- }
173
-
174
- /** Read on every render, so a remapped key shows the new binding. */
175
- function HINTS(): Hint[] {
176
- return [
177
- { key: keyText("app.model.select") || "/model", does: "model" },
178
- { key: keyText("app.thinking.cycle") || "/thinking", does: "effort" },
179
- { key: "/", does: "commands" },
180
- { key: "!", does: "shell" },
181
- { key: keyText("app.editor.external") || "/editor", does: "external editor" },
182
- { key: "/tree", does: "session history" },
183
- ];
184
- }
185
-
186
- export function halfBlocks(pixels: readonly string[]): string[] {
187
- const rows: string[] = [];
188
- for (let row = 0; row < pixels.length; row += 2) {
189
- const top = pixels[row] ?? "";
190
- const bottom = pixels[row + 1] ?? "";
191
- let line = "";
192
- for (let column = 0; column < Math.max(top.length, bottom.length); column++) {
193
- const up = top[column] === "#";
194
- const down = bottom[column] === "#";
195
- line += up && down ? "█" : up ? "▀" : down ? "▄" : " ";
196
- }
197
- rows.push(line);
198
- }
199
- return rows;
200
- }
201
-
202
- function pad(text: string, width: number): string {
203
- const clipped = truncateToWidth(text, Math.max(0, width), "…");
204
- return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
205
- }
206
-
207
- function center(text: string, width: number): string {
208
- const clipped = truncateToWidth(text, width, "");
209
- const room = Math.max(0, width - visibleWidth(clipped));
210
- const before = Math.floor(room / 2);
211
- return `${" ".repeat(before)}${clipped}${" ".repeat(room - before)}`;
212
- }
@@ -1,39 +0,0 @@
1
- // What the components read. The feature implements it over the live session and the settings; tests
2
- // implement it over a fixed snapshot. Everything is read at render time, so a setting or theme change
3
- // shows on the next frame with nothing to invalidate.
4
- import type { Snapshot } from "../data/snapshot.ts";
5
- import type { Piece } from "../render/fit.ts";
6
- import type { FrameStyle } from "../render/frame.ts";
7
- import type { Glyphs } from "../render/glyphs.ts";
8
- import type { Paint } from "../render/paint.ts";
9
- import { renderSegments, type SegmentId, type SegmentOptions } from "../render/segments.ts";
10
- import type { CursorStyle } from "../settings.ts";
11
-
12
- export type SlotName = "strip" | "topLeft" | "topRight" | "bottomLeft" | "bottomRight" | "below";
13
-
14
- export interface Screen {
15
- snapshot(): Snapshot;
16
- glyphs(): Glyphs;
17
- /** Paints with the active theme. `frame` overrides the border colour, which the editor owns. */
18
- paint(frame?: (text: string) => string): Paint;
19
- separator(): string;
20
- options(): SegmentOptions;
21
- slot(name: SlotName): readonly SegmentId[];
22
- frameStyle(): FrameStyle | "off";
23
- cursor(): CursorStyle;
24
- }
25
-
26
- /** The rendered pieces of one slot. The separator is painted here, once, for everyone. */
27
- export function slotPieces(
28
- screen: Screen,
29
- name: SlotName,
30
- snapshot: Snapshot,
31
- paint: Paint,
32
- ): Piece[] {
33
- return renderSegments(screen.slot(name), {
34
- snapshot,
35
- glyphs: screen.glyphs(),
36
- paint,
37
- options: screen.options(),
38
- });
39
- }
@@ -1,73 +0,0 @@
1
- // What the spinner says. Pi's says "Working" for the whole run; this one says what is happening: the
2
- // newest line of the model's thinking, the tool call it is writing, the command running. It only
3
- // speaks through pi's public `setWorkingMessage`, so pi still owns the spinner and where it sits.
4
-
5
- export type Activity =
6
- | { kind: "waiting" }
7
- | { kind: "thinking"; tail: string }
8
- | { kind: "writing" }
9
- | { kind: "calling"; tool: string }
10
- | { kind: "running"; tool: string; detail: string };
11
-
12
- /** One line, plain text: pi paints the message itself. */
13
- export function describe(activity: Activity): string {
14
- switch (activity.kind) {
15
- case "waiting":
16
- return "Waiting";
17
- case "thinking":
18
- return activity.tail === "" ? "Thinking" : `Thinking · ${activity.tail}`;
19
- case "writing":
20
- return "Writing";
21
- case "calling":
22
- return `Calling ${activity.tool}`;
23
- case "running":
24
- return activity.detail === "" ? activity.tool : `${activity.tool} · ${activity.detail}`;
25
- }
26
- }
27
-
28
- interface ContentPart {
29
- type?: unknown;
30
- thinking?: unknown;
31
- name?: unknown;
32
- }
33
-
34
- /** The newest line of the thinking so far. Model output, so control characters go. */
35
- export function thinkingTail(content: unknown): string {
36
- if (!Array.isArray(content)) return "";
37
- for (let index = content.length - 1; index >= 0; index--) {
38
- const part = content[index] as ContentPart | undefined;
39
- if (part?.type !== "thinking" || typeof part.thinking !== "string") continue;
40
- const lines = part.thinking.split("\n").map(clean).filter(Boolean);
41
- return lines.at(-1) ?? "";
42
- }
43
- return "";
44
- }
45
-
46
- /** The name of the tool call being written, when the model is writing one. */
47
- export function callingTool(content: unknown): string | undefined {
48
- if (!Array.isArray(content)) return undefined;
49
- const part = content.at(-1) as ContentPart | undefined;
50
- return part?.type === "toolCall" && typeof part.name === "string" ? part.name : undefined;
51
- }
52
-
53
- const DETAIL_KEYS = ["command", "path", "file_path", "pattern", "query", "url", "prompt"];
54
-
55
- /** The one argument that says what a tool is doing: the command, the file, the pattern. */
56
- export function toolDetail(args: unknown): string {
57
- if (typeof args !== "object" || args === null) return "";
58
- const record = args as Record<string, unknown>;
59
- for (const key of DETAIL_KEYS) {
60
- const value = record[key];
61
- if (typeof value === "string" && value.trim() !== "") {
62
- return clean(value.split("\n").find((line) => line.trim() !== "") ?? "");
63
- }
64
- }
65
- return "";
66
- }
67
-
68
- // oxlint-disable-next-line no-control-regex -- escape sequences and control characters are the target
69
- const CONTROL = /\x1b\[[0-?]*[ -/]*[@-~]|[\x00-\x1f\x7f]/g;
70
-
71
- function clean(text: string): string {
72
- return text.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
73
- }
@@ -1,10 +0,0 @@
1
- import { createApp } from "@adeildo/pi-kit";
2
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
-
4
- import { subscription } from "./subscription/feature.ts";
5
-
6
- export { claudeCodeVersion, subscription } from "./subscription/feature.ts";
7
-
8
- export default function piProviders(pi: ExtensionAPI): void {
9
- createApp(pi, { name: "pi-harness" }).use(subscription).build();
10
- }
@@ -1,27 +0,0 @@
1
- // How Claude Code tells Anthropic who it is. A request is billed to the Claude plan when it
2
- // carries Claude Code's user agent and a first system block with a billing attribution, which ends
3
- // in three characters of the first user message hashed with the version and a salt.
4
- import { createHash } from "node:crypto";
5
-
6
- export const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
7
-
8
- const FINGERPRINT_SALT = "59cf53e54c78";
9
- const SAMPLED_POSITIONS = [4, 7, 20];
10
-
11
- export function userAgent(version: string): string {
12
- return `claude-cli/${version} (external, cli)`;
13
- }
14
-
15
- /** Positions past the end of the message count as "0", the way Claude Code pads them. */
16
- export function fingerprint(firstUserText: string, version: string): string {
17
- const sampled = SAMPLED_POSITIONS.map((position) => firstUserText[position] ?? "0").join("");
18
- return createHash("sha256")
19
- .update(`${FINGERPRINT_SALT}${sampled}${version}`)
20
- .digest("hex")
21
- .slice(0, 3);
22
- }
23
-
24
- export function billingAttribution(firstUserText: string, version: string): string {
25
- const stamp = `${version}.${fingerprint(firstUserText, version)}`;
26
- return `x-anthropic-billing-header: cc_version=${stamp}; cc_entrypoint=cli;`;
27
- }
@@ -1,43 +0,0 @@
1
- // Bills Anthropic OAuth requests to the Claude Pro/Max plan instead of extra usage. It acts on any
2
- // provider that speaks the Anthropic Messages API with an OAuth token, not only the one named
3
- // `anthropic`, so a second account under another name gets the same treatment.
4
- import { defineFeature, isObject, matching, setting } from "@adeildo/pi-kit";
5
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
-
7
- import { userAgent } from "./billing.ts";
8
- import { billToPlan } from "./payload.ts";
9
-
10
- export const claudeCodeVersion = setting({
11
- id: "subscription.claudeCodeVersion",
12
- default: "2.1.280",
13
- decoder: matching(/^\d+\.\d+\.\d+$/, "a Claude Code version like 2.1.280"),
14
- ui: {
15
- group: "Providers",
16
- label: "Claude Code version",
17
- description:
18
- "The Claude Code version pi reports to Anthropic. Anthropic refuses newer models to versions it considers too old.",
19
- },
20
- });
21
-
22
- export const subscription = defineFeature({
23
- id: "subscription",
24
- description: "Bill Anthropic OAuth requests to the Claude plan instead of extra usage",
25
- settings: [claudeCodeVersion],
26
- setup(scope) {
27
- scope.on("before_provider_headers", (event, ctx) => {
28
- if (usesSubscription(ctx))
29
- event.headers["user-agent"] = userAgent(claudeCodeVersion.get(scope));
30
- });
31
-
32
- scope.on("before_provider_request", (event, ctx) => {
33
- if (!usesSubscription(ctx) || !isObject(event.payload)) return undefined;
34
- return billToPlan(event.payload, claudeCodeVersion.get(scope));
35
- });
36
- },
37
- });
38
-
39
- function usesSubscription(ctx: ExtensionContext): boolean {
40
- const model = ctx.model;
41
- if (model === undefined || model.api !== "anthropic-messages") return false;
42
- return ctx.modelRegistry.isUsingOAuth(model);
43
- }
@@ -1,87 +0,0 @@
1
- // Rewrites the request pi builds for an Anthropic OAuth token. Anthropic's billing classifier reads
2
- // `system`, and any text there that Claude Code would not send costs extra usage. So `system` keeps
3
- // the attribution and the identity, and pi's prompt moves into the first user message, where the
4
- // model still follows it.
5
- import { isObject } from "@adeildo/pi-kit";
6
-
7
- import { billingAttribution, CLAUDE_CODE_IDENTITY } from "./billing.ts";
8
-
9
- export interface TextBlock {
10
- type: "text";
11
- text: string;
12
- cache_control?: unknown;
13
- }
14
-
15
- type Payload = Record<string, unknown>;
16
-
17
- export function billToPlan(payload: Payload, version: string): Payload | undefined {
18
- const system = claudeCodeSystem(payload.system);
19
- if (system === undefined) return undefined;
20
-
21
- const [identity, ...moved] = system;
22
- const messages = Array.isArray(payload.messages) ? payload.messages : [];
23
- const attribution: TextBlock = {
24
- type: "text",
25
- text: billingAttribution(firstUserText(messages), version),
26
- };
27
-
28
- return {
29
- ...payload,
30
- system: [attribution, identity],
31
- messages: prependInstructions(messages, moved),
32
- };
33
- }
34
-
35
- function claudeCodeSystem(system: unknown): [TextBlock, ...TextBlock[]] | undefined {
36
- if (!Array.isArray(system) || !system.every(isTextBlock)) return undefined;
37
- const [first, ...rest] = system;
38
- if (first === undefined || !first.text.startsWith(CLAUDE_CODE_IDENTITY)) return undefined;
39
- return [first, ...rest];
40
- }
41
-
42
- function isTextBlock(block: unknown): block is TextBlock {
43
- return isObject(block) && block.type === "text" && typeof block.text === "string";
44
- }
45
-
46
- function firstUserText(messages: readonly unknown[]): string {
47
- const message = messages.find(isUserMessage);
48
- if (message === undefined) return "";
49
- if (typeof message.content === "string") return message.content;
50
- if (!Array.isArray(message.content)) return "";
51
- return message.content.find(isTextBlock)?.text ?? "";
52
- }
53
-
54
- function isUserMessage(message: unknown): message is Payload {
55
- return isObject(message) && message.role === "user";
56
- }
57
-
58
- function prependInstructions(messages: readonly unknown[], moved: readonly TextBlock[]): unknown[] {
59
- const text = moved
60
- .map((block) => block.text)
61
- .filter((part) => part.trim() !== "")
62
- .join("\n\n");
63
- if (text === "") return [...messages];
64
-
65
- // The moved prompt takes over the cache breakpoint pi put on it, so the request keeps the same
66
- // number of breakpoints and the prompt stays cached on its own.
67
- const cacheControl = moved.findLast((block) => block.cache_control !== undefined)?.cache_control;
68
- const instructions: TextBlock = {
69
- type: "text",
70
- text: `<system-instructions>\n${text}\n</system-instructions>`,
71
- ...(cacheControl === undefined ? {} : { cache_control: cacheControl }),
72
- };
73
-
74
- const index = messages.findIndex(isUserMessage);
75
- const target = messages[index];
76
- if (!isUserMessage(target)) return [{ role: "user", content: [instructions] }, ...messages];
77
-
78
- const updated = [...messages];
79
- updated[index] = { ...target, content: [instructions, ...contentBlocks(target.content)] };
80
- return updated;
81
- }
82
-
83
- function contentBlocks(content: unknown): unknown[] {
84
- if (Array.isArray(content)) return content;
85
- if (typeof content === "string" && content !== "") return [{ type: "text", text: content }];
86
- return [];
87
- }