@clawdbuilder/jig-bedrock 0.0.0-stage → 0.1.1

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/src/doctor.ts ADDED
@@ -0,0 +1,172 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { ResolvedConfig } from "./config.ts";
3
+ import { executableExists, usageExecutable } from "./usage.ts";
4
+
5
+ type CheckStatus = "ready" | "old" | "missing" | "timeout" | "cancelled" | "overflow" | "unrecognized" | "unavailable";
6
+ export interface DependencyCheck {
7
+ status: CheckStatus;
8
+ version?: string;
9
+ }
10
+ export interface LocalDependencyHealth {
11
+ node: DependencyCheck;
12
+ aws: DependencyCheck;
13
+ bash: DependencyCheck;
14
+ jq: DependencyCheck;
15
+ usage: boolean;
16
+ platform: NodeJS.Platform;
17
+ }
18
+ interface CheckOptions {
19
+ signal?: AbortSignal;
20
+ env?: NodeJS.ProcessEnv;
21
+ nodeVersion?: string;
22
+ platform?: NodeJS.Platform;
23
+ timeout?: number;
24
+ }
25
+ type Tool = "aws" | "bash" | "jq";
26
+ const commands: Record<Tool, string[]> = {
27
+ aws: ["--version"],
28
+ bash: ["--noprofile", "--norc", "--version"],
29
+ jq: ["--version"],
30
+ };
31
+ const patterns: Record<Tool, RegExp> = {
32
+ aws: /\baws-cli\/(\d{1,6}\.\d{1,6}\.\d{1,6})(?![\d.])/,
33
+ bash: /\bGNU bash, version (\d{1,6}\.\d{1,6}\.\d{1,6})(?![\d.])/,
34
+ jq: /\bjq-(\d{1,6}\.\d{1,6}(?:\.\d{1,6})?)(?![\d.])/,
35
+ };
36
+ const atLeast = (version: string, minimum: number[]) => {
37
+ const parts = version.split(".").map(Number);
38
+ for (let i = 0; i < minimum.length; i++) {
39
+ const wanted = minimum[i] ?? 0;
40
+ if ((parts[i] ?? 0) !== wanted) return (parts[i] ?? 0) > wanted;
41
+ }
42
+ return true;
43
+ };
44
+ function versionCheck(version: string | undefined, minimum?: number[]): DependencyCheck {
45
+ return version
46
+ ? { status: !minimum || atLeast(version, minimum) ? "ready" : "old", version }
47
+ : { status: "unrecognized" };
48
+ }
49
+ /** Fixed local version calls. Only numeric versions or closed failure categories leave this function. */
50
+ function probe(tool: Tool, minimum: number[] | undefined, options: CheckOptions): Promise<DependencyCheck> {
51
+ return new Promise((resolve) => {
52
+ if (options.signal?.aborted) {
53
+ resolve({ status: "cancelled" });
54
+ return;
55
+ }
56
+ const source = options.env ?? process.env;
57
+ const env: NodeJS.ProcessEnv = {
58
+ PATH: source.PATH ?? "",
59
+ LANG: "C",
60
+ LC_ALL: "C",
61
+ AWS_CLI_AUTO_PROMPT: "off",
62
+ AWS_PAGER: "",
63
+ NO_COLOR: "1",
64
+ };
65
+ if (source.SystemRoot) env.SystemRoot = source.SystemRoot;
66
+ let child: ReturnType<typeof spawn>,
67
+ bytes = 0,
68
+ settled = false;
69
+ const output: Buffer[] = [];
70
+ let timer: ReturnType<typeof setTimeout> | undefined;
71
+ const stop = () => {
72
+ if (!child?.pid) return;
73
+ try {
74
+ process.kill(process.platform === "win32" ? child.pid : -child.pid, "SIGKILL");
75
+ } catch {}
76
+ };
77
+ const finish = (result: DependencyCheck) => {
78
+ if (settled) return;
79
+ settled = true;
80
+ if (timer) clearTimeout(timer);
81
+ options.signal?.removeEventListener("abort", abort);
82
+ if (result.status !== "ready" && result.status !== "old") stop();
83
+ resolve(result);
84
+ };
85
+ const abort = () => finish({ status: "cancelled" });
86
+ try {
87
+ child = spawn(tool, commands[tool], {
88
+ shell: false,
89
+ env,
90
+ stdio: ["ignore", "pipe", "pipe"],
91
+ detached: process.platform !== "win32",
92
+ windowsHide: true,
93
+ });
94
+ } catch {
95
+ finish({ status: "unavailable" });
96
+ return;
97
+ }
98
+ const collect = (chunk: Buffer) => {
99
+ if (settled) return;
100
+ bytes += chunk.length;
101
+ if (bytes > 16_384) finish({ status: "overflow" });
102
+ else output.push(chunk);
103
+ };
104
+ child.stdout!.on("data", collect);
105
+ child.stderr!.on("data", collect);
106
+ child.on("error", (error: NodeJS.ErrnoException) =>
107
+ finish({ status: error.code === "ENOENT" || error.code === "EACCES" ? "missing" : "unavailable" }),
108
+ );
109
+ child.on("close", (code) =>
110
+ finish(
111
+ code === 0
112
+ ? versionCheck(patterns[tool].exec(Buffer.concat(output).toString("utf8"))?.[1], minimum)
113
+ : { status: "unavailable" },
114
+ ),
115
+ );
116
+ options.signal?.addEventListener("abort", abort, { once: true });
117
+ timer = setTimeout(() => finish({ status: "timeout" }), options.timeout ?? 3_000);
118
+ timer.unref();
119
+ if (options.signal?.aborted) abort();
120
+ });
121
+ }
122
+ export async function checkLocalDependencies(
123
+ config: ResolvedConfig,
124
+ options: CheckOptions = {},
125
+ ): Promise<LocalDependencyHealth> {
126
+ const platform = options.platform ?? process.platform;
127
+ const nodeVersion = /^(\d{1,6}\.\d{1,6}\.\d{1,6})$/.exec(options.nodeVersion ?? process.versions.node)?.[1];
128
+ const [aws, bash, jq] = await Promise.all([
129
+ probe("aws", [2, 0, 0], options),
130
+ probe("bash", [4, 0, 0], options),
131
+ probe("jq", platform === "darwin" ? [1, 8] : undefined, options),
132
+ ]);
133
+ return {
134
+ node: versionCheck(nodeVersion, [22, 19, 0]),
135
+ aws,
136
+ bash,
137
+ jq,
138
+ usage: executableExists(usageExecutable(config)),
139
+ platform,
140
+ };
141
+ }
142
+ const statusText: Record<CheckStatus, string> = {
143
+ ready: "ready",
144
+ old: "upgrade required",
145
+ missing: "missing",
146
+ timeout: "version check timed out",
147
+ cancelled: "version check cancelled",
148
+ overflow: "version output exceeded limit",
149
+ unrecognized: "version not recognized",
150
+ unavailable: "version check failed",
151
+ };
152
+ export function dependencyLines(health: LocalDependencyHealth): string[] {
153
+ const line = (name: string, check: DependencyCheck, requirement: string) =>
154
+ `${name}: ${check.version ?? "unknown"} · ${statusText[check.status]}${requirement ? ` (${requirement})` : ""}`;
155
+ return [
156
+ line("Node (this Pi process)", health.node, "requires 22.19+"),
157
+ line("AWS CLI", health.aws, "requires v2"),
158
+ line("Bash", health.bash, "usage program statistics require 4+"),
159
+ line(
160
+ "jq",
161
+ health.jq,
162
+ health.platform === "darwin"
163
+ ? "usage program on macOS requires 1.8+"
164
+ : "usage program statistics dependency; no documented Linux version floor",
165
+ ),
166
+ `usage program executable: ${health.usage ? "found" : "missing"} (optional; not executed by this check)`,
167
+ ...(health.aws.version && atLeast(health.aws.version, [2, 0, 0]) && !atLeast(health.aws.version, [2, 22, 0])
168
+ ? ["usage program's tooling check requires AWS CLI 2.22 or later."]
169
+ : []),
170
+ "Only local --version output is checked; IAM access and the full usage program toolchain are not verified.",
171
+ ];
172
+ }
package/src/events.ts ADDED
@@ -0,0 +1,46 @@
1
+ // What the provider tells other plugins on Pi's event bus, in the shapes optional Jig display plugins read. Neither is imported:
2
+ // an event nobody listens to does nothing.
3
+ //
4
+ // jig-theme:segment { id, label, tone? } an optional footer part
5
+ // jig-lens:model-names { models: [{ recorded, name, provider }] } names for recorded models
6
+ import type { Budget } from "./budget.ts";
7
+ import type { TeamBedrockModel } from "./catalog.ts";
8
+ import { JIG_BEDROCK } from "./identity.ts";
9
+
10
+ export const SEGMENT_EVENT = "jig-theme:segment";
11
+ export const MODEL_NAMES_EVENT = "jig-lens:model-names";
12
+ export const SEGMENT_ID = "bedrock-spend";
13
+ /** The share of the budget from which the part turns to a warning. */
14
+ export const AMBER_FROM = 0.8;
15
+
16
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
17
+ const money = (value: number): string => `$${value >= 10 ? String(Math.round(value)) : value.toFixed(2)}`;
18
+
19
+ export interface Segment {
20
+ id: string;
21
+ label: string;
22
+ tone?: "normal" | "warning" | "error";
23
+ }
24
+
25
+ /**
26
+ * `Oct $203 / $200`; a trailing + on the spend means some usage had no price, so it is a lower bound. A warning from 80% of the
27
+ * budget, an error at or above it. A figure from another month is not shown (the month rolls over in UTC, as the extension
28
+ * counts it), and a part with nothing to say has an empty label, which removes it.
29
+ */
30
+ export function spendSegment(budget: Budget | undefined, now: number): Segment {
31
+ if (budget === undefined || new Date(now).toISOString().slice(0, 7) !== budget.monthUtc) {
32
+ return { id: SEGMENT_ID, label: "" };
33
+ }
34
+ const month = MONTHS[Number(budget.monthUtc.slice(5)) - 1] ?? budget.monthUtc;
35
+ const limit = budget.limitUsd !== undefined && budget.limitUsd > 0 ? budget.limitUsd : undefined;
36
+ const label = `${month} ${money(budget.spentUsd)}${budget.unpriced ? "+" : ""}${limit === undefined ? "" : ` / ${money(limit)}`}`;
37
+ const used = limit === undefined ? 0 : budget.spentUsd / limit;
38
+ return { id: SEGMENT_ID, label, tone: used >= 1 ? "error" : used >= AMBER_FROM ? "warning" : "normal" };
39
+ }
40
+
41
+ /** Pi records a reply under the inference profile's ARN, which says nothing to a reader: this names it by the model's id. */
42
+ export function modelNames(models: readonly TeamBedrockModel[]): {
43
+ models: Array<{ recorded: string; name: string; provider: string }>;
44
+ } {
45
+ return { models: models.map((model) => ({ recorded: model.arn, name: model.id, provider: JIG_BEDROCK })) };
46
+ }
@@ -0,0 +1,3 @@
1
+ export const JIG_BEDROCK = "jig-bedrock";
2
+ export const isJigBedrock = (model: { provider?: string } | undefined) =>
3
+ model?.provider === JIG_BEDROCK || model?.provider === "amazon-bedrock";
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ // Pi entry point of the Bedrock provider. The factory only registers: no network, no processes, no timers and no file
2
+ // writes. jig-core must load before this extension for its `bedrock` section to apply (Jig's own order); in plain Pi the
3
+ // provider reads ~/.pi/agent/bedrock.json and the JIG_BEDROCK_* environment. AWS, the usage program and the browser are
4
+ // touched once a session starts, on a request, or by a command the user types.
5
+ import type { ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";
6
+ import { validateBedrock } from "./config.ts";
7
+ import { jigApi, withJig } from "./jig/client.ts";
8
+ import type { BedrockOptions } from "./provider.ts";
9
+ import { createJigBedrock } from "./provider.ts";
10
+
11
+ export type { BedrockOptions };
12
+
13
+ /** The extension. `options` replace the parts of the machine a test fakes: the catalog, the budget, the SSO recovery. */
14
+ export function jigBedrock(options: BedrockOptions = {}): ExtensionFactory {
15
+ return async (pi: ExtensionAPI) => {
16
+ // jig-core must already be loaded for its section to apply; a later load only registers the section, because
17
+ // the provider has read its settings by then. Plain Pi uses bedrock.json and the JIG_BEDROCK_* environment.
18
+ const api = jigApi();
19
+ withJig((loaded) => {
20
+ loaded.config.registerSection("bedrock", validateBedrock);
21
+ if (api === undefined)
22
+ pi.on("session_start", (_event, ctx) =>
23
+ ctx.ui.notify(
24
+ "jig-core loaded after the Bedrock provider, so its bedrock section is ignored this session; reload Pi to apply it.",
25
+ "warning",
26
+ ),
27
+ );
28
+ });
29
+ await createJigBedrock({ ...options, ...(api === undefined ? {} : { api }) })(pi).catch(() => undefined);
30
+ };
31
+ }
32
+
33
+ export default jigBedrock();
@@ -0,0 +1,63 @@
1
+ // How the Bedrock provider reaches jig-core at run time. A copy of jig-core's client.ts (plugins/jig-core/src/client.ts in Jig),
2
+ // kept identical apart from the import below: jig-core publishes its API on a process-wide symbol when it loads, and a provider
3
+ // keeps its own copy of this lookup instead of importing jig-core, so it loads in either order and can be split out.
4
+ // test/client.test.ts compares this file with the checkout in JIG_DIR, so a change in Jig's copy fails there.
5
+ import type { JigApi } from "./types.ts";
6
+
7
+ const API = Symbol.for("jig.core.api/v1");
8
+ const PENDING = Symbol.for("jig.core.pending/v1");
9
+
10
+ type Slot = typeof globalThis & {
11
+ [API]?: JigApi;
12
+ [PENDING]?: Array<(api: JigApi) => void>;
13
+ };
14
+
15
+ const slot = globalThis as Slot;
16
+
17
+ /** The API when jig-core has already loaded; undefined in plain Pi. */
18
+ export function jigApi(): JigApi | undefined {
19
+ return slot[API];
20
+ }
21
+
22
+ /** jig-core's API. Throws when jig-core is not loaded, so a Jig tool fails closed instead of running
23
+ * ungoverned. */
24
+ export function jig(): JigApi {
25
+ const api = slot[API];
26
+ if (api === undefined) throw new Error("jig-core is not loaded: enable jig-core, which governs every Jig tool");
27
+ return api;
28
+ }
29
+
30
+ /** Runs `use` now if jig-core is loaded, otherwise as soon as it loads. */
31
+ export function withJig(use: (api: JigApi) => void): void {
32
+ const api = slot[API];
33
+ if (api !== undefined) {
34
+ use(api);
35
+ return;
36
+ }
37
+ const pending = slot[PENDING] ?? [];
38
+ slot[PENDING] = pending;
39
+ pending.push(use);
40
+ }
41
+
42
+ /** For jig-core only: publishes the API and runs what plugins queued before it loaded. One plugin's failing
43
+ * callback does not stop the others: every callback runs, and the errors come back for jig-core to report. */
44
+ export function publishJig(api: JigApi): unknown[] {
45
+ slot[API] = api;
46
+ const pending = slot[PENDING] ?? [];
47
+ slot[PENDING] = [];
48
+ const errors: unknown[] = [];
49
+ for (const use of pending) {
50
+ try {
51
+ use(api);
52
+ } catch (error) {
53
+ errors.push(error);
54
+ }
55
+ }
56
+ return errors;
57
+ }
58
+
59
+ /** For jig-core only: withdraws the API when its runtime ends (reload, session replacement, exit), so a plugin
60
+ * loading into the next runtime waits for the new jig-core instead of declaring into the old one. */
61
+ export function withdrawJig(api: JigApi): void {
62
+ if (slot[API] === api) delete slot[API];
63
+ }
@@ -0,0 +1,12 @@
1
+ // The small part of Jig's runtime API this package uses. The package works without Jig; when Jig is present, `jig()` reads
2
+ // this through the process-wide slot in ./client.ts.
3
+ export interface JigApi {
4
+ readonly config: {
5
+ registerSection(
6
+ name: string,
7
+ validate: (value: unknown) => string[],
8
+ options?: { union?: readonly string[]; replace?: readonly string[] },
9
+ ): void;
10
+ section(name: string): unknown;
11
+ };
12
+ }
package/src/login.ts ADDED
@@ -0,0 +1,176 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { closeSync, lstatSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { atomicCache } from "./cache.ts";
6
+ import { boundedRegular } from "./catalog.ts";
7
+ import { profilePattern, type ResolvedConfig } from "./config.ts";
8
+ import { type Recover, SsoRecoveryError, shareRecovery } from "./sso.ts";
9
+ import { loginEnvironment, systemWsl, type WslDeps } from "./wsl.ts";
10
+ /** Never create an inherited system runtime path or repair unrelated permissions. */
11
+ export function ssoRuntime(env: NodeJS.ProcessEnv = process.env): string {
12
+ const safe = (path: string, privateMode = false) => {
13
+ try {
14
+ const meta = lstatSync(path);
15
+ return (
16
+ meta.isDirectory() &&
17
+ !meta.isSymbolicLink() &&
18
+ meta.uid === process.getuid?.() &&
19
+ (meta.mode & (privateMode ? 0o077 : 0o022)) === 0
20
+ );
21
+ } catch {
22
+ return false;
23
+ }
24
+ };
25
+ if (env.XDG_RUNTIME_DIR?.startsWith("/") && safe(env.XDG_RUNTIME_DIR, true)) return env.XDG_RUNTIME_DIR;
26
+ const home = env.HOME;
27
+ if (!home?.startsWith("/") || !safe(home)) throw new SsoRecoveryError("runtime");
28
+ let path = home;
29
+ for (const component of [".local", "state", "jig-bedrock", "sso-runtime"]) {
30
+ path = join(path, component);
31
+ try {
32
+ mkdirSync(path, { mode: 0o700 });
33
+ } catch (error) {
34
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw new SsoRecoveryError("runtime");
35
+ }
36
+ if (!safe(path)) throw new SsoRecoveryError("runtime");
37
+ }
38
+ if (!safe(path, true)) throw new SsoRecoveryError("runtime");
39
+ return path;
40
+ }
41
+ /** What a test replaces in the sign-in: the program that starts the login, the lock, the time, the environment, WSL, the notices. */
42
+ export interface RecoveryOptions {
43
+ /** How the login program is started. */
44
+ start?: typeof spawn;
45
+ /** The lock file, instead of one in the private runtime folder. */
46
+ lock?: string;
47
+ timeoutMs?: number;
48
+ /** The environment the login starts from (default: Pi's own, read when the login starts). */
49
+ env?: NodeJS.ProcessEnv;
50
+ wsl?: WslDeps;
51
+ /** Where the one-line notice of a repair goes (see wsl.ts). */
52
+ say?: (line: string) => void;
53
+ }
54
+
55
+ export function createRecovery(config: ResolvedConfig, options: RecoveryOptions = {}): Recover {
56
+ const { start = spawn, lock: explicitLock, timeoutMs = 300_000, wsl = systemWsl, say = () => {} } = options;
57
+ return shareRecovery(async (profile, signal) => {
58
+ if (!profilePattern.test(profile)) throw new Error("Invalid AWS profile");
59
+ const runtime = explicitLock ? undefined : ssoRuntime();
60
+ const lock = explicitLock ?? join(runtime!, "bedrock-sso.lock");
61
+ const deadline = Date.now() + timeoutMs;
62
+ let waited: string | undefined;
63
+ const token = randomUUID();
64
+ while (true) {
65
+ signal?.throwIfAborted();
66
+ if (Date.now() >= deadline) throw new SsoRecoveryError("timeout");
67
+ try {
68
+ const fd = openSync(lock, "wx", 0o600);
69
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, profile, token }));
70
+ closeSync(fd);
71
+ break;
72
+ } catch (e) {
73
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw new SsoRecoveryError("runtime");
74
+ try {
75
+ const owner = JSON.parse(boundedRegular(lock, 1024, true) || "{}");
76
+ if (!Number.isInteger(owner.pid) || owner.pid <= 0) throw new Error("invalid lock");
77
+ try {
78
+ process.kill(owner.pid, 0);
79
+ } catch (err) {
80
+ if ((err as NodeJS.ErrnoException).code === "ESRCH") {
81
+ unlinkSync(lock);
82
+ continue;
83
+ }
84
+ }
85
+ waited = owner.profile === profile && typeof owner.token === "string" ? owner.token : undefined;
86
+ } catch {
87
+ try {
88
+ const meta = lstatSync(lock);
89
+ if (!meta.isFile() || meta.isSymbolicLink()) throw new SsoRecoveryError("runtime");
90
+ if (Date.now() - meta.mtimeMs > timeoutMs + 1000) {
91
+ unlinkSync(lock);
92
+ continue;
93
+ }
94
+ } catch (error) {
95
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
96
+ throw error;
97
+ }
98
+ }
99
+ await new Promise((resolve) => setTimeout(resolve, 100));
100
+ // A completed same-profile login is reused by waiting sessions.
101
+ try {
102
+ lstatSync(lock);
103
+ } catch {
104
+ if (waited) {
105
+ signal?.throwIfAborted();
106
+ try {
107
+ const result = JSON.parse(boundedRegular(`${lock}.result`, 1024, true) || "{}");
108
+ if (result.token === waited && result.ok === true) return;
109
+ } catch {}
110
+ throw new Error("Shared SSO login did not complete; run /aws-login");
111
+ }
112
+ }
113
+ }
114
+ }
115
+ let succeeded = false;
116
+ try {
117
+ // The login's environment: Pi's own, the private runtime folder, and on WSL a browser that opens and an interop
118
+ // socket that answers (wsl.ts). The probe takes up to two seconds, under the lock, and only when a login starts.
119
+ const login = await loginEnvironment(
120
+ { browser: config.browser },
121
+ { ...(options.env ?? process.env), ...(runtime ? { XDG_RUNTIME_DIR: runtime } : {}) },
122
+ wsl,
123
+ );
124
+ for (const line of login.notes) say(line);
125
+ signal?.throwIfAborted();
126
+ await new Promise<void>((resolve, reject) => {
127
+ const argv = config.ssoLoginCommand.map((s) => s.replaceAll("{profile}", profile));
128
+ const child = start(argv[0] ?? "", argv.slice(1), { detached: true, stdio: "ignore", env: login.env });
129
+ let stopped = false;
130
+ let timedOut = false;
131
+ const stop = () => {
132
+ if (stopped) return;
133
+ stopped = true;
134
+ if (child.pid) {
135
+ try {
136
+ process.kill(-child.pid, "SIGTERM");
137
+ } catch {}
138
+ const kill = setTimeout(() => {
139
+ try {
140
+ process.kill(-child.pid!, "SIGKILL");
141
+ } catch {}
142
+ }, 500);
143
+ kill.unref();
144
+ }
145
+ };
146
+ const timer = setTimeout(
147
+ () => {
148
+ timedOut = true;
149
+ stop();
150
+ },
151
+ Math.max(1, deadline - Date.now()),
152
+ );
153
+ signal?.addEventListener("abort", stop, { once: true });
154
+ const finish = (ok: boolean) => {
155
+ clearTimeout(timer);
156
+ signal?.removeEventListener("abort", stop);
157
+ ok && !stopped
158
+ ? resolve()
159
+ : reject(new SsoRecoveryError(signal?.aborted ? "cancelled" : timedOut ? "timeout" : "login"));
160
+ };
161
+ child.once("error", () => finish(false));
162
+ child.once("close", (code) => finish(code === 0));
163
+ if (signal?.aborted) stop();
164
+ });
165
+ succeeded = true;
166
+ } finally {
167
+ try {
168
+ atomicCache(`${lock}.result`, { token, ok: succeeded });
169
+ } finally {
170
+ try {
171
+ unlinkSync(lock);
172
+ } catch {}
173
+ }
174
+ }
175
+ });
176
+ }
@@ -0,0 +1,45 @@
1
+ // Per-model facts for Bedrock's Anthropic Messages route. Bedrock asks for the newer thinking shape on the Claude 5.5
2
+ // generation (adaptive, with output_config.effort) and refuses Pi's `thinking: {type: "disabled"}` on Sonnet 5.5, asking for
3
+ // `between_tools` instead; older Claude models still take budget thinking. Nothing here guesses beyond the generation the real
4
+ // run covered: a model only gets a rewrite when its family is listed below.
5
+ const ANTHROPIC = /^(?:[a-z0-9-]+\.)?anthropic\./;
6
+ const CLAUDE_55 = /^(?:[a-z0-9-]+\.)?anthropic\.claude-(?:sonnet|opus|haiku)-5-5$/;
7
+ // Sonnet 5.5 is the one model the real run proved needs between_tools when thinking is off; Opus and Haiku have not been
8
+ // tested with thinking off, so they stay as Pi sends them.
9
+ const BETWEEN_TOOLS = /^(?:[a-z0-9-]+\.)?anthropic\.claude-sonnet-5-5$/;
10
+ // Opus 5.5 cannot turn thinking off at all: the real run got the adaptive error on off. Pi reads a null level as "not
11
+ // available", so off leaves the picker and a saved off clamps to the lowest level Pi has.
12
+ const NO_OFF = /^(?:[a-z0-9-]+\.)?anthropic\.claude-opus-5-5$/;
13
+
14
+ /** A foundation model served by Bedrock's Anthropic Messages route. Anything else stays on its own route. */
15
+ export function isAnthropicModel(foundationModelId: string): boolean {
16
+ return ANTHROPIC.test(foundationModelId);
17
+ }
18
+
19
+ /** The Claude generation that wants adaptive thinking; the older ones keep Pi's budget thinking. */
20
+ export function usesAdaptiveThinking(foundationModelId: string): boolean {
21
+ return CLAUDE_55.test(foundationModelId);
22
+ }
23
+
24
+ /** Whether thinking-off is sent as `between_tools`. */
25
+ export function needsBetweenTools(foundationModelId: string): boolean {
26
+ return BETWEEN_TOOLS.test(foundationModelId);
27
+ }
28
+
29
+ /** Whether the model has no thinking-off at all, which Pi expresses as a null entry in its thinking level map. */
30
+ export function noThinkingOff(foundationModelId: string): boolean {
31
+ return NO_OFF.test(foundationModelId);
32
+ }
33
+
34
+ /**
35
+ * Pi sends `thinking: {type: "disabled"}` when the level is off. Sonnet 5.5 answers with a 400 naming `between_tools`, so the
36
+ * payload is rewritten for that model only; every other payload and model is returned as it came.
37
+ */
38
+ export function rewriteThinkingOff(
39
+ payload: Record<string, unknown>,
40
+ foundationModelId: string,
41
+ ): Record<string, unknown> {
42
+ const thinking = payload.thinking as { type?: unknown } | undefined;
43
+ if (thinking?.type !== "disabled" || !needsBetweenTools(foundationModelId)) return payload;
44
+ return { ...payload, thinking: { type: "between_tools" } };
45
+ }
package/src/pi-host.ts ADDED
@@ -0,0 +1,54 @@
1
+ // Where Pi is installed, to load the AWS SDK the way Pi's own Bedrock provider does. Pi gives an extension its own packages but not
2
+ // the SDK behind them, which sits in Pi's install; `createRequire` from there finds it. The old toolkit used
3
+ // PI_HOST_DIR, else the global npm install next to this Node. This keeps both and tries a third first: the install the running
4
+ // `pi` command belongs to, found by walking up from the real path of the script Node was started with.
5
+ import { readFileSync, realpathSync } from "node:fs";
6
+ import { createRequire } from "node:module";
7
+ import path from "node:path";
8
+
9
+ const PI_PACKAGE = "@earendil-works/pi-coding-agent";
10
+
11
+ function isPi(dir: string): boolean {
12
+ try {
13
+ return (
14
+ (JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")) as { name?: unknown }).name === PI_PACKAGE
15
+ );
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Pi's package folder. `PI_HOST_DIR` wins when it is set (it is the folder, taken as it is). Otherwise the folder above the script
23
+ * Node started (`argv1`, with links followed) that is Pi's package, then the global npm install beside `execPath`. Undefined when
24
+ * none is Pi.
25
+ */
26
+ export function findPiHost(
27
+ env: NodeJS.ProcessEnv = process.env,
28
+ execPath: string = process.execPath,
29
+ argv1: string | undefined = process.argv[1],
30
+ ): string | undefined {
31
+ if (env.PI_HOST_DIR) return env.PI_HOST_DIR;
32
+ if (argv1) {
33
+ try {
34
+ for (let dir = path.dirname(realpathSync(argv1)); dir !== path.dirname(dir); dir = path.dirname(dir)) {
35
+ if (isPi(dir)) return dir;
36
+ }
37
+ } catch {
38
+ // The script is gone or unreadable: try the next place.
39
+ }
40
+ }
41
+ const beside = path.resolve(path.dirname(execPath), "../lib/node_modules", PI_PACKAGE);
42
+ return isPi(beside) ? beside : undefined;
43
+ }
44
+
45
+ /** What the SDK's `defaultProvider` takes and gives: a function that resolves the profile's credentials when called. */
46
+ export type CredentialProvider = (init: { profile: string }) => () => Promise<unknown>;
47
+
48
+ /** `defaultProvider` of the SDK in Pi's install. Throws when Pi or the SDK cannot be found. */
49
+ export function loadCredentialProvider(host: string | undefined): CredentialProvider {
50
+ if (!host) throw new Error("Pi's install was not found");
51
+ const require = createRequire(path.join(host, "package.json"));
52
+ const { defaultProvider } = require("@aws-sdk/credential-provider-node") as { defaultProvider: CredentialProvider };
53
+ return defaultProvider;
54
+ }
@@ -0,0 +1,50 @@
1
+ export type PromptCache = { short: number; long?: number };
2
+
3
+ const REGION_PREFIX = /^(?:us|eu|apac|au|jp|in|global|us-gov)\./;
4
+ /**
5
+ * Prompt-cache lifetimes in seconds, from Bedrock's "Supported models and explicit caching limits" table: Claude 4.5 and later
6
+ * accept a 5-minute or 1-hour checkpoint, older Claude and Nova only the 5-minute one (a `ttl` on those fails validation).
7
+ * Pi sends cache points only to Claude models; Nova relies on implicit caching and AWS caps its cache at 20K tokens, so for
8
+ * Nova the lifetime is informative (footer, /session) and Pi's warming economics never select it. Models Bedrock does not
9
+ * list stay undeclared, which keeps them out of Pi's cache warming.
10
+ */
11
+ export function promptCacheFor(foundationModelId: string): PromptCache | undefined {
12
+ const id = foundationModelId.toLowerCase().replace(REGION_PREFIX, "");
13
+ if (/^amazon\.nova-(?:2-)?(?:micro|lite|pro)(?![a-z0-9])/.test(id)) return { short: 300 };
14
+ if (!id.startsWith("anthropic.claude")) return undefined;
15
+ // anthropic.claude-sonnet-4-6, claude-haiku-4-5-20251001-v1:0, claude-opus-5; the 8-digit date is not a minor version.
16
+ const version = /^anthropic\.claude-(?:opus|sonnet|haiku|fable|mythos)-(\d+)(?:-(\d{1,2})(?!\d))?/.exec(id);
17
+ if (!version) return { short: 300 };
18
+ const major = Number(version[1]),
19
+ minor = Number(version[2] ?? 0);
20
+ return major > 4 || (major === 4 && minor >= 5) ? { short: 300, long: 3600 } : { short: 300 };
21
+ }
22
+
23
+ export type Retention = "short" | "long";
24
+ export type Cost = {
25
+ input: number;
26
+ output: number;
27
+ cacheRead: number;
28
+ cacheWrite: number;
29
+ tiers?: readonly { input: number; cacheWrite: number; inputTokensAbove: number }[];
30
+ };
31
+
32
+ /** `PI_CACHE_RETENTION=long` makes Pi's Bedrock stream write 1-hour cache points; anything else is the 5-minute default. */
33
+ export const retentionOf = (env: NodeJS.ProcessEnv = process.env): Retention =>
34
+ env.PI_CACHE_RETENTION === "long" ? "long" : "short";
35
+
36
+ /**
37
+ * Price per million tokens of writing the prompt cache for a request of `tokens` input tokens. The 5-minute write is the
38
+ * model's cacheWrite rate; Pi bills a 1-hour write at twice the base input rate (calculateCost in pi-ai), in the pricing
39
+ * tier the request size falls into.
40
+ */
41
+ export function cacheWriteRate(cost: Cost, tokens: number, retention: Retention): number {
42
+ let rates: { input: number; cacheWrite: number } = cost,
43
+ above = -1;
44
+ for (const tier of cost.tiers ?? [])
45
+ if (tokens > tier.inputTokensAbove && tier.inputTokensAbove > above) {
46
+ rates = tier;
47
+ above = tier.inputTokensAbove;
48
+ }
49
+ return retention === "long" ? rates.input * 2 : rates.cacheWrite;
50
+ }