Compare commits
No commits in common. "bacafdf715918429c4760ca0c6295d9df8eaba03" and "488d613cbb71a74a4be835b60b0f68b95873de31" have entirely different histories.
bacafdf715
...
488d613cbb
8 changed files with 10 additions and 1244 deletions
|
|
@ -3,20 +3,11 @@
|
|||
"mcpServers": {
|
||||
"firecrawl": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"firecrawl-mcp"
|
||||
],
|
||||
"args": ["-y", "firecrawl-mcp"],
|
||||
"env": {
|
||||
"FIRECRAWL_API_KEY": "123",
|
||||
"FIRECRAWL_API_URL": "http://bazzite.local:3002"
|
||||
}
|
||||
},
|
||||
"godot-mcp-pro": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"/home/melon/Desktop/godot-mcp-pro-v1.16.0/server/build/index.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -29,18 +29,18 @@ symbolPreset: nerd
|
|||
theme:
|
||||
dark: dark-gruvbox
|
||||
light: light
|
||||
setupVersion: 2
|
||||
setupVersion: 1
|
||||
modelRoles:
|
||||
task: llama-swap.miche/godoter-27b
|
||||
task: llama-swap.byte/qwen36-27b-711
|
||||
plan: zai-coding/glm-5.2:xhigh
|
||||
slow: deepseek/deepseek-v4-pro:high
|
||||
vision: llama-swap.byte/qwen36-35b-heretic-apex
|
||||
tiny: llama-swap.byte/gemma4-26b-hauhau
|
||||
default: deepseek/deepseek-v4-flash:high
|
||||
smol: llama-swap.byte/kat-coder-v2.5-dev
|
||||
commit: llama-swap.byte/gemma4-26b-hauhau
|
||||
designer: zai-coding/glm-5.2
|
||||
advisor: zai-coding/glm-5.2
|
||||
default: openrouter/stealth/ox-alpha:max
|
||||
retry:
|
||||
fallbackChains:
|
||||
default:
|
||||
|
|
@ -73,5 +73,5 @@ tui:
|
|||
textSizing: false
|
||||
defaultThinkingLevel: auto
|
||||
personality: pragmatic
|
||||
hideThinkingBlock: true
|
||||
hideThinkingBlock: false
|
||||
readLineNumbers: true
|
||||
|
|
@ -1,789 +0,0 @@
|
|||
// caveman:native-pi — GENERATED by `caveman enable pi`.
|
||||
process.env.CAVEMAN_PI_HOOK_CMD ??= "[\"{{ .chezmoi.homeDir }}/.npm-global/bin/caveman\"]";
|
||||
// ../pi-extension/src/index.ts
|
||||
import { readFileSync as readFileSync2 } from "node:fs";
|
||||
import { homedir as homedir3 } from "node:os";
|
||||
import { join as join4 } from "node:path";
|
||||
import { Type } from "typebox";
|
||||
|
||||
// ../pi-extension/src/lifecycle.ts
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { join as join2 } from "node:path";
|
||||
|
||||
// ../pi-extension/src/portable-command.ts
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { dirname, extname, isAbsolute, join, resolve } from "node:path";
|
||||
function parseWindowsNodeShim(source) {
|
||||
for (const line of source.split(/\r?\n/)) {
|
||||
if (!/(?:\bnode(?:\.exe)?\b|_prog)/i.test(line) || !/%\*/.test(line)) continue;
|
||||
const match = line.match(/"%(?:dp0%|~dp0)\\([^"\r\n]+\.(?:cjs|mjs|js))"\s+%\*/i) ?? line.match(/"([A-Za-z]:[\\/][^"\r\n]+\.(?:cjs|mjs|js))"\s+%\*/i);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function envValue(env, name) {
|
||||
const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
|
||||
return key === void 0 ? void 0 : env[key];
|
||||
}
|
||||
function resolveWindowsCommand(command, env) {
|
||||
const pathExt = envValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD";
|
||||
const names = extname(command) ? [command] : pathExt.split(";").map((extension) => `${command}${extension.startsWith(".") ? extension : `.${extension}`}`);
|
||||
if (isAbsolute(command) || /[\\/]/.test(command)) {
|
||||
for (const name of names) if (existsSync(name)) return name;
|
||||
return existsSync(command) ? command : void 0;
|
||||
}
|
||||
for (const directory of (envValue(env, "PATH") ?? "").split(";")) {
|
||||
if (!directory) continue;
|
||||
for (const name of names) {
|
||||
const candidate = join(directory, name);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return void 0;
|
||||
}
|
||||
function portableInvocation(command, args, platform = process.platform, env = process.env) {
|
||||
if (platform !== "win32") return { command, args: [...args] };
|
||||
const executable = resolveWindowsCommand(command, env) ?? command;
|
||||
if (!/\.(?:cmd|bat)$/i.test(executable)) return { command: executable, args: [...args] };
|
||||
const stat = statSync(executable);
|
||||
if (!stat.isFile() || stat.size > 256 * 1024) {
|
||||
throw new Error(`cannot safely launch Windows command shim: ${executable}`);
|
||||
}
|
||||
const shimScript = parseWindowsNodeShim(readFileSync(executable, "utf8"));
|
||||
if (!shimScript) {
|
||||
throw new Error(`cannot safely launch non-Node Windows command shim: ${executable}; install a native .exe`);
|
||||
}
|
||||
const script = /^[A-Za-z]:[\\/]/.test(shimScript) ? shimScript : resolve(dirname(executable), ...shimScript.split(/[\\/]+/));
|
||||
if (!statSync(script).isFile()) {
|
||||
throw new Error(`Windows command shim target is missing: ${script}`);
|
||||
}
|
||||
return { command: process.execPath, args: [script, ...args] };
|
||||
}
|
||||
|
||||
// ../pi-extension/src/protocol.ts
|
||||
var MAX_CONTEXT_BYTES = 64 * 1024;
|
||||
var MAX_MESSAGE_BYTES = 4 * 1024;
|
||||
var MAX_OUTPUT_REPLACEMENT_BYTES = 2 * 1024 * 1024;
|
||||
var MAX_RECOVERY_REF_BYTES = 1024;
|
||||
var MAX_DECISION_ID_BYTES = 256;
|
||||
var MAX_TOOL_OUTPUT_BYTES = 2 * 1024 * 1024;
|
||||
function fits(value, max) {
|
||||
return typeof value === "string" && Buffer.byteLength(value, "utf8") <= max;
|
||||
}
|
||||
function sanitizeHookResponse(raw) {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const value = raw;
|
||||
const out = {};
|
||||
if (fits(value.context, MAX_CONTEXT_BYTES)) out.context = value.context;
|
||||
if (fits(value.message, MAX_MESSAGE_BYTES)) out.message = value.message;
|
||||
if (fits(value.output_replacement, MAX_OUTPUT_REPLACEMENT_BYTES)) out.output_replacement = value.output_replacement;
|
||||
if (fits(value.recovery_ref, MAX_RECOVERY_REF_BYTES)) out.recovery_ref = value.recovery_ref;
|
||||
if (fits(value.decision_id, MAX_DECISION_ID_BYTES)) out.decision_id = value.decision_id;
|
||||
if (typeof value.action === "string") out.action = value.action;
|
||||
const hso = value.hookSpecificOutput;
|
||||
if (hso && typeof hso === "object" && !Array.isArray(hso)) {
|
||||
const nested = hso;
|
||||
const cleaned = {};
|
||||
if (typeof nested.hookEventName === "string") cleaned.hookEventName = nested.hookEventName;
|
||||
if (fits(nested.additionalContext, MAX_CONTEXT_BYTES)) cleaned.additionalContext = nested.additionalContext;
|
||||
if (fits(nested.updatedToolOutput, MAX_OUTPUT_REPLACEMENT_BYTES)) cleaned.updatedToolOutput = nested.updatedToolOutput;
|
||||
out.hookSpecificOutput = cleaned;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function additionalContextOf(response) {
|
||||
const context = response?.hookSpecificOutput?.additionalContext ?? response?.context;
|
||||
return context ? context : void 0;
|
||||
}
|
||||
function outputReplacementOf(response) {
|
||||
const replacement = response?.hookSpecificOutput?.updatedToolOutput ?? response?.output_replacement;
|
||||
return replacement ? replacement : void 0;
|
||||
}
|
||||
var ROUTES_BY_API = {
|
||||
"anthropic-messages": "/w/pi",
|
||||
"openai-completions": "/w/pi/openai/v1",
|
||||
"openai-responses": "/w/pi/openai/v1",
|
||||
"google-generative-ai": "/w/pi/v1beta"
|
||||
};
|
||||
function routeForApi(gateway, api) {
|
||||
const path = api ? ROUTES_BY_API[api] : void 0;
|
||||
return path ? joinUrl(gateway, path) : void 0;
|
||||
}
|
||||
function joinUrl(base, path) {
|
||||
return `${base.replace(/\/+$/, "")}${path}`;
|
||||
}
|
||||
function isLoopbackUrl(url) {
|
||||
try {
|
||||
const host = new URL(url).hostname;
|
||||
return host === "127.0.0.1" || host === "::1" || host === "[::1]" || host === "localhost";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ../pi-extension/src/lifecycle.ts
|
||||
var HOOK_TIMEOUT_MS = 2e3;
|
||||
var SESSION_START_TIMEOUT_MS = 6e3;
|
||||
var HOOK_MAX_BUFFER = 2 * 1024 * 1024;
|
||||
function resolveHookInvocations(env = process.env) {
|
||||
const localBin = join2(env.CAVEMAN_HOME || join2(homedir(), ".caveman"), "bin", "caveman");
|
||||
const candidates = [
|
||||
{ command: "caveman", args: [] },
|
||||
{ command: "cave", args: [] },
|
||||
{ command: localBin, args: [] }
|
||||
];
|
||||
const stamped = env.CAVEMAN_PI_HOOK_CMD;
|
||||
if (stamped) {
|
||||
try {
|
||||
const parsed = JSON.parse(stamped);
|
||||
if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((part) => typeof part === "string" && part.length > 0)) {
|
||||
candidates.unshift({ command: parsed[0], args: parsed.slice(1) });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
var TRY_NEXT = "__caveman_try_next__";
|
||||
var HookBridge = class {
|
||||
invocations;
|
||||
constructor(invocations = resolveHookInvocations()) {
|
||||
this.invocations = invocations;
|
||||
}
|
||||
// call runs one native-hook event. Fail-open by contract: any spawn error,
|
||||
// timeout, or unparseable output resolves to undefined.
|
||||
async call(event, payload) {
|
||||
const input = JSON.stringify({ event_name: event, surface: "cli", ...payload });
|
||||
for (const invocation of this.invocations) {
|
||||
const raw = await this.invoke(invocation, event, input);
|
||||
if (raw === TRY_NEXT) continue;
|
||||
if (raw === void 0 || !raw.trim()) return void 0;
|
||||
try {
|
||||
return sanitizeHookResponse(JSON.parse(raw));
|
||||
} catch {
|
||||
return void 0;
|
||||
}
|
||||
}
|
||||
return void 0;
|
||||
}
|
||||
invoke(invocation, event, input) {
|
||||
return new Promise((resolve2) => {
|
||||
let portable;
|
||||
try {
|
||||
portable = portableInvocation(invocation.command, [...invocation.args, "native-hook", "pi", event]);
|
||||
} catch {
|
||||
resolve2(TRY_NEXT);
|
||||
return;
|
||||
}
|
||||
const child = execFile(
|
||||
portable.command,
|
||||
portable.args,
|
||||
{ timeout: event === "SessionStart" ? SESSION_START_TIMEOUT_MS : HOOK_TIMEOUT_MS, maxBuffer: HOOK_MAX_BUFFER, encoding: "utf8" },
|
||||
(error, stdout) => {
|
||||
const timedOut = !!error && error.killed === true;
|
||||
if (error && stdout === "") return resolve2(timedOut ? void 0 : TRY_NEXT);
|
||||
resolve2(stdout);
|
||||
}
|
||||
);
|
||||
child.stdin?.on("error", () => {
|
||||
});
|
||||
child.stdin?.end(input);
|
||||
});
|
||||
}
|
||||
};
|
||||
function promptDigest(prompt) {
|
||||
return {
|
||||
bytes: Buffer.byteLength(prompt, "utf8"),
|
||||
sha256: `sha256:${createHash("sha256").update(prompt, "utf8").digest("hex")}`
|
||||
};
|
||||
}
|
||||
function taskType(text) {
|
||||
const lower = text.toLowerCase();
|
||||
const has = (...terms) => terms.some((term) => lower.includes(term));
|
||||
if (has("migration", "migrate", "schema change", "backfill", "rollback")) return "migration";
|
||||
if (has("bug", "fix", "broken", "regression", "crash", "error", "incorrect")) return "bugfix";
|
||||
if (has("investigate", "diagnose", "root cause", "why does", "trace")) return "investigation";
|
||||
if (has("refactor", "restructure", "reorganize", "cleanup")) return "refactor";
|
||||
if (has("review", "audit", "critique", "assess")) return "review";
|
||||
if (has("verify", "verification", "prove", "validate", "check that")) return "verification";
|
||||
if (has("build", "implement", "add", "create", "ship", "feature")) return "feature";
|
||||
return "general";
|
||||
}
|
||||
var TASK_STOPWORDS = /* @__PURE__ */ new Set(["about", "after", "agent", "before", "build", "change", "code", "create", "from", "have", "help", "implement", "into", "make", "please", "project", "repository", "should", "spec", "task", "that", "then", "there", "these", "they", "this", "through", "user", "want", "what", "when", "where", "which", "with", "would", "your"]);
|
||||
function taskTerms(text) {
|
||||
const out = [];
|
||||
const seen = /* @__PURE__ */ new Set();
|
||||
for (const raw of text.match(/[A-Za-z][A-Za-z0-9_./-]{2,63}/g) ?? []) {
|
||||
const term = raw.toLowerCase().replace(/^[-./]+|[-./]+$/g, "");
|
||||
if (!term || term.includes("..") || TASK_STOPWORDS.has(term) || seen.has(term)) continue;
|
||||
if (/^(?:sk|pk|rk|ghp|github_pat|xox[baprs]|akia)[-_]/i.test(term) || /^[a-z0-9_-]{40,}$/i.test(term)) continue;
|
||||
seen.add(term);
|
||||
out.push(term);
|
||||
if (out.length === 12) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function taskContinuation(text) {
|
||||
const prompt = text.trim().toLowerCase();
|
||||
if (!prompt || prompt.length > 160 || prompt.split(/\s+/).length > 14) return false;
|
||||
return /^(?:please\s+)?(?:continue|go ahead|keep going|proceed|do (?:it|that)|fix (?:it|that)|retry|try again|explain (?:it|that)|what do you mean|yes|yep|yeah|why\??|how\??)[.!?\s]*$/.test(prompt);
|
||||
}
|
||||
|
||||
// ../pi-extension/src/provider.ts
|
||||
var ProviderRouter = class {
|
||||
pi;
|
||||
notify;
|
||||
gateway;
|
||||
gateOpen = false;
|
||||
overridden = /* @__PURE__ */ new Set();
|
||||
applying = false;
|
||||
warnedModels = /* @__PURE__ */ new Set();
|
||||
constructor(pi, notify) {
|
||||
this.pi = pi;
|
||||
this.notify = notify;
|
||||
}
|
||||
// openGate is called once per session after the recovery gate held. Refuses
|
||||
// non-loopback gateways: managed routing needs auth proof v1 does not carry.
|
||||
async openGate(gateway, ctx) {
|
||||
if (!isLoopbackUrl(gateway)) {
|
||||
this.notify("Caveman: direct mode, no compression this session (gateway is not loopback)", "warning");
|
||||
return;
|
||||
}
|
||||
this.gateway = gateway;
|
||||
this.gateOpen = true;
|
||||
await this.apply(ctx.model, ctx);
|
||||
}
|
||||
closeGate() {
|
||||
this.gateOpen = false;
|
||||
this.clearOverrides();
|
||||
}
|
||||
routing() {
|
||||
return this.gateOpen && this.overridden.size > 0;
|
||||
}
|
||||
// apply routes one model's provider, or restores direct mode when the model
|
||||
// has no verified route. Called from the gate and from model_select; the
|
||||
// applying flag swallows the model_select echo of our own setModel call.
|
||||
async apply(model, ctx) {
|
||||
if (!this.gateOpen || this.applying || !this.gateway) return;
|
||||
if (!model) return;
|
||||
const route = routeForApi(this.gateway, model.api);
|
||||
let oauth = true;
|
||||
try {
|
||||
oauth = ctx.modelRegistry.isUsingOAuth(model);
|
||||
} catch {
|
||||
}
|
||||
if (!route || oauth) {
|
||||
this.clearOverrides();
|
||||
const key = `${model.provider}/${model.id}`;
|
||||
if (!this.warnedModels.has(key)) {
|
||||
this.warnedModels.add(key);
|
||||
const reason = oauth ? "OAuth/subscription credentials are not routed" : `unsupported API "${model.api}"`;
|
||||
this.notify(`Caveman: pass-through for ${key} (${reason}); no compression`, "warning");
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.applying = true;
|
||||
try {
|
||||
for (const provider of this.overridden) {
|
||||
if (provider !== model.provider) {
|
||||
this.pi.unregisterProvider(provider);
|
||||
this.overridden.delete(provider);
|
||||
}
|
||||
}
|
||||
this.pi.registerProvider(model.provider, { baseUrl: route });
|
||||
this.overridden.add(model.provider);
|
||||
const refreshed = ctx.modelRegistry.find(model.provider, model.id);
|
||||
if (!refreshed || !await this.pi.setModel(refreshed)) {
|
||||
this.clearOverrides();
|
||||
this.notify("Caveman: direct mode, no compression this session (model re-resolution failed)", "warning");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
this.clearOverrides();
|
||||
this.notify("Caveman: direct mode, no compression this session (provider override failed)", "warning");
|
||||
} finally {
|
||||
this.applying = false;
|
||||
}
|
||||
}
|
||||
clearOverrides() {
|
||||
for (const provider of this.overridden) {
|
||||
try {
|
||||
this.pi.unregisterProvider(provider);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
this.overridden.clear();
|
||||
}
|
||||
};
|
||||
|
||||
// ../pi-extension/src/recovery.ts
|
||||
import { execFile as execFile2, spawn } from "node:child_process";
|
||||
import { existsSync as existsSync2 } from "node:fs";
|
||||
import { homedir as homedir2 } from "node:os";
|
||||
import { delimiter, join as join3 } from "node:path";
|
||||
var PROBE_TIMEOUT_MS = 2e3;
|
||||
var CALL_TIMEOUT_MS = 3e4;
|
||||
var INIT_TIMEOUT_MS = 2e3;
|
||||
function resolveMcpBinary(env = process.env) {
|
||||
const explicit = env.CAVEMAN_MCP_BIN?.trim();
|
||||
if (explicit) return explicit;
|
||||
const name = process.platform === "win32" ? "caveman-mcp.exe" : "caveman-mcp";
|
||||
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
||||
if (dir && existsSync2(join3(dir, name))) return join3(dir, name);
|
||||
}
|
||||
const local = join3(env.CAVEMAN_HOME || join3(homedir2(), ".caveman"), "bin", name);
|
||||
return existsSync2(local) ? local : void 0;
|
||||
}
|
||||
var RecoveryClient = class {
|
||||
binary;
|
||||
child;
|
||||
initialized = false;
|
||||
probed;
|
||||
ensuring;
|
||||
nextId = 1;
|
||||
pending = /* @__PURE__ */ new Map();
|
||||
buffer = "";
|
||||
disposed = false;
|
||||
constructor(binary = resolveMcpBinary()) {
|
||||
this.binary = binary;
|
||||
}
|
||||
// ensure probes the binary once and brings up an initialized child. Returns
|
||||
// false (never throws) when recovery cannot hold — the caller passes through.
|
||||
async ensure() {
|
||||
if (this.disposed || !this.binary) return false;
|
||||
if (this.child && this.initialized) return true;
|
||||
this.ensuring ??= this.bringUp().finally(() => {
|
||||
this.ensuring = void 0;
|
||||
});
|
||||
return this.ensuring;
|
||||
}
|
||||
async bringUp() {
|
||||
if (this.probed === void 0) this.probed = await this.probe();
|
||||
if (!this.probed || this.disposed) return false;
|
||||
if (this.child && this.initialized) return true;
|
||||
return this.start();
|
||||
}
|
||||
ready() {
|
||||
return Boolean(this.child && this.initialized && !this.disposed);
|
||||
}
|
||||
async retrieve(handle, query, signal) {
|
||||
if (!await this.ensure()) {
|
||||
return { text: JSON.stringify({ error: "cave_recovery_unavailable", message: "caveman-mcp is not available this session" }), isError: true };
|
||||
}
|
||||
try {
|
||||
const result = await this.call("tools/call", {
|
||||
name: "caveman_retrieve",
|
||||
arguments: { recovery_handle: handle, ...query ? { query } : {} }
|
||||
}, signal);
|
||||
const value = result;
|
||||
const text = (value.content ?? []).map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("");
|
||||
return { text, isError: Boolean(value.isError) };
|
||||
} catch (error) {
|
||||
return { text: JSON.stringify({ error: "cave_recovery_transport", message: error.message }), isError: true };
|
||||
}
|
||||
}
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
const child = this.child;
|
||||
this.child = void 0;
|
||||
this.initialized = false;
|
||||
for (const [, entry] of this.pending) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.reject(new Error("recovery client disposed"));
|
||||
}
|
||||
this.pending.clear();
|
||||
this.stop(child);
|
||||
void this.ensuring?.then(() => {
|
||||
const late = this.child;
|
||||
this.child = void 0;
|
||||
this.stop(late);
|
||||
});
|
||||
}
|
||||
// resolveMcpBinary appends .exe on win32, but CAVEMAN_MCP_BIN is an operator
|
||||
// override that can name anything — including a .cmd shim, which execFile
|
||||
// and spawn both refuse with EFTYPE. Route through the same resolver the hook
|
||||
// bridge uses so the override is as spawnable as the default. Fail-closed:
|
||||
// callers treat a throw as "recovery cannot hold" and pass through.
|
||||
invocation(args) {
|
||||
try {
|
||||
return portableInvocation(this.binary, args);
|
||||
} catch {
|
||||
return void 0;
|
||||
}
|
||||
}
|
||||
probe() {
|
||||
return new Promise((resolve2) => {
|
||||
const portable = this.invocation(["version", "--json"]);
|
||||
if (!portable) return resolve2(false);
|
||||
execFile2(portable.command, portable.args, { timeout: PROBE_TIMEOUT_MS, encoding: "utf8" }, (error, stdout) => {
|
||||
if (error) return resolve2(false);
|
||||
try {
|
||||
const parsed = JSON.parse(stdout);
|
||||
resolve2(Array.isArray(parsed?.capabilities) && parsed.capabilities.includes("mcp_recovery"));
|
||||
} catch {
|
||||
resolve2(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
async start() {
|
||||
let child;
|
||||
try {
|
||||
const portable = this.invocation([]);
|
||||
if (!portable) return false;
|
||||
child = spawn(portable.command, portable.args, { stdio: ["pipe", "pipe", "ignore"] });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
this.child = child;
|
||||
this.initialized = false;
|
||||
this.buffer = "";
|
||||
child.on("error", () => this.onChildGone(child));
|
||||
child.on("exit", () => this.onChildGone(child));
|
||||
child.stdin?.on("error", () => this.onChildGone(child));
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk) => this.onData(chunk));
|
||||
try {
|
||||
await this.call("initialize", {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "caveman-pi-extension", version: "0.1.0" }
|
||||
}, void 0, INIT_TIMEOUT_MS);
|
||||
this.notify("notifications/initialized");
|
||||
if (this.disposed) {
|
||||
this.onChildGone(child);
|
||||
this.stop(child);
|
||||
return false;
|
||||
}
|
||||
this.initialized = true;
|
||||
return true;
|
||||
} catch {
|
||||
this.onChildGone(child);
|
||||
this.stop(child);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
stop(child) {
|
||||
if (!child) return;
|
||||
try {
|
||||
child.stdin?.end();
|
||||
} catch {
|
||||
}
|
||||
const term = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
}
|
||||
}, 500);
|
||||
const kill = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
}
|
||||
}, 2e3);
|
||||
child.once("exit", () => {
|
||||
clearTimeout(term);
|
||||
clearTimeout(kill);
|
||||
});
|
||||
}
|
||||
onChildGone(child) {
|
||||
if (this.child !== child) return;
|
||||
this.child = void 0;
|
||||
this.initialized = false;
|
||||
for (const [, entry] of this.pending) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.reject(new Error("caveman-mcp exited"));
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
onData(chunk) {
|
||||
this.buffer += chunk;
|
||||
let newline;
|
||||
while ((newline = this.buffer.indexOf("\n")) !== -1) {
|
||||
const line = this.buffer.slice(0, newline).trim();
|
||||
this.buffer = this.buffer.slice(newline + 1);
|
||||
if (!line) continue;
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (typeof message.id !== "number") continue;
|
||||
const entry = this.pending.get(message.id);
|
||||
if (!entry) continue;
|
||||
this.pending.delete(message.id);
|
||||
clearTimeout(entry.timer);
|
||||
if (message.error) entry.reject(new Error(message.error.message || "caveman-mcp error"));
|
||||
else entry.resolve(message.result);
|
||||
}
|
||||
}
|
||||
call(method, params, signal, timeoutMs = CALL_TIMEOUT_MS) {
|
||||
const child = this.child;
|
||||
if (!child?.stdin?.writable) return Promise.reject(new Error("caveman-mcp not running"));
|
||||
if (signal?.aborted) return Promise.reject(new Error("retrieve cancelled"));
|
||||
const id = this.nextId++;
|
||||
return new Promise((resolve2, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`caveman-mcp ${method} timed out`));
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
const onAbort = () => {
|
||||
this.pending.delete(id);
|
||||
clearTimeout(timer);
|
||||
reject(new Error("retrieve cancelled"));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
this.pending.set(id, {
|
||||
resolve: (value) => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve2(value);
|
||||
},
|
||||
reject: (error) => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
},
|
||||
timer
|
||||
});
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n", (error) => {
|
||||
if (error) {
|
||||
this.pending.delete(id);
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
notify(method) {
|
||||
try {
|
||||
this.child?.stdin?.write(JSON.stringify({ jsonrpc: "2.0", method }) + "\n");
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ../pi-extension/src/tool-output.ts
|
||||
async function shrinkToolResult(bridge, sessionId, event) {
|
||||
const text = event.content.map((block) => block.type === "text" && typeof block.text === "string" ? block.text : "").join("");
|
||||
if (!text) return void 0;
|
||||
if (Buffer.byteLength(text, "utf8") > MAX_TOOL_OUTPUT_BYTES) return void 0;
|
||||
const response = await bridge.call(event.isError ? "PostToolUseFailure" : "PostToolUse", {
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName,
|
||||
tool_input: event.input,
|
||||
tool_output: text
|
||||
});
|
||||
const replacement = outputReplacementOf(response);
|
||||
if (!replacement) return void 0;
|
||||
return { content: [{ type: "text", text: replacement }, ...event.content.filter((block) => block.type !== "text")] };
|
||||
}
|
||||
|
||||
// ../pi-extension/src/index.ts
|
||||
var HEALTH_TIMEOUT_MS = 750;
|
||||
var RECOVERY_TOOL = "caveman_retrieve";
|
||||
function cavemanHome() {
|
||||
return process.env.CAVEMAN_HOME || join4(homedir3(), ".caveman");
|
||||
}
|
||||
function gatewayUrl() {
|
||||
const env = process.env.CAVE_GATEWAY_URL?.trim();
|
||||
if (env) return env;
|
||||
try {
|
||||
const config = JSON.parse(readFileSync2(join4(homedir3(), ".caveman-cloud", "config.json"), "utf8"));
|
||||
if (typeof config.gatewayUrl === "string" && config.gatewayUrl) return config.gatewayUrl;
|
||||
} catch {
|
||||
}
|
||||
return "http://127.0.0.1:8787";
|
||||
}
|
||||
function runStateRecoveryViaMcp(gateway) {
|
||||
let port;
|
||||
try {
|
||||
const url = new URL(gateway);
|
||||
port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
} catch {
|
||||
return void 0;
|
||||
}
|
||||
try {
|
||||
const state = JSON.parse(readFileSync2(join4(cavemanHome(), "run", `${port}.json`), "utf8"));
|
||||
if (state?.schema !== "caveman.proxy.run.v1") return void 0;
|
||||
if (typeof state.pid !== "number" || typeof state.instance_token !== "string") return void 0;
|
||||
if (state.owner !== "wrap" && state.owner !== "start") return void 0;
|
||||
try {
|
||||
process.kill(state.pid, 0);
|
||||
} catch {
|
||||
return void 0;
|
||||
}
|
||||
return Boolean(state.recovery_via_mcp);
|
||||
} catch {
|
||||
return void 0;
|
||||
}
|
||||
}
|
||||
async function proxyAliveOnce(gateway) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(`${gateway.replace(/\/+$/, "")}/health/live`, { signal: controller.signal });
|
||||
await response.body?.cancel();
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
async function proxyAlive(gateway) {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (await proxyAliveOnce(gateway)) return true;
|
||||
await new Promise((resolve2) => setTimeout(resolve2, 300));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function index_default(pi) {
|
||||
const bridge = new HookBridge();
|
||||
const recovery = new RecoveryClient();
|
||||
let router;
|
||||
let sessionId = "default";
|
||||
let coreContext;
|
||||
let pendingContext = [];
|
||||
let pendingBytes = 0;
|
||||
let gateDone = false;
|
||||
const notify = (ctx, message, kind) => {
|
||||
if (ctx?.hasUI) ctx.ui.notify(message, kind);
|
||||
else process.stderr.write(`${message}
|
||||
`);
|
||||
};
|
||||
const debug = (message) => {
|
||||
if (process.env.CAVEMAN_PI_DEBUG === "1") process.stderr.write(`[caveman-pi] ${message}
|
||||
`);
|
||||
};
|
||||
const guardedBase = (fn, label = fn.name || "handler") => (async (...args) => {
|
||||
try {
|
||||
debug(`enter ${label}`);
|
||||
const result = await fn(...args);
|
||||
debug(`exit ${label}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
process.stderr.write(`caveman pi extension: ${error.message}
|
||||
`);
|
||||
return void 0;
|
||||
}
|
||||
});
|
||||
const GUARD = (label) => (fn) => guardedBase(fn, label);
|
||||
const GUARD_session_start = GUARD("session_start");
|
||||
const GUARD_model_select = GUARD("model_select");
|
||||
const GUARD_before_agent_start = GUARD("before_agent_start");
|
||||
const GUARD_turn_start = GUARD("turn_start");
|
||||
const GUARD_turn_end = GUARD("turn_end");
|
||||
const GUARD_tool_call = GUARD("tool_call");
|
||||
const GUARD_tool_result = GUARD("tool_result");
|
||||
const GUARD_session_before_compact = GUARD("session_before_compact");
|
||||
const GUARD_session_compact = GUARD("session_compact");
|
||||
const GUARD_session_shutdown = GUARD("session_shutdown");
|
||||
pi.registerTool({
|
||||
name: RECOVERY_TOOL,
|
||||
label: "Retrieve compressed context",
|
||||
description: "Recover exact original content from a Caveman recovery handle.",
|
||||
parameters: Type.Object({
|
||||
recovery_handle: Type.String({ description: "Exact ccr_ handle returned by Caveman or copied from a <<ccr:HANDLE>> marker." }),
|
||||
query: Type.Optional(Type.String({ description: "One broad description covering every detail needed from this handle." }))
|
||||
}),
|
||||
async execute(_toolCallId, params, signal) {
|
||||
const result = await recovery.retrieve(params.recovery_handle, params.query, signal);
|
||||
if (result.isError) throw new Error(result.text || "caveman_retrieve failed");
|
||||
return { content: [{ type: "text", text: result.text }], details: { recovery_handle: params.recovery_handle } };
|
||||
}
|
||||
});
|
||||
pi.on("session_start", GUARD_session_start(async (_event, ctx) => {
|
||||
router ??= new ProviderRouter(pi, (message, kind) => notify(ctx, message, kind));
|
||||
gateDone = false;
|
||||
coreContext = void 0;
|
||||
pendingContext = [];
|
||||
pendingBytes = 0;
|
||||
try {
|
||||
sessionId = ctx.sessionManager.getSessionId() || "default";
|
||||
} catch {
|
||||
sessionId = "default";
|
||||
}
|
||||
const start = await bridge.call("SessionStart", { session_id: sessionId });
|
||||
coreContext = additionalContextOf(start);
|
||||
if (!start) {
|
||||
gateDone = true;
|
||||
notify(ctx, "Caveman: direct mode, no compression this session (caveman native runtime unreachable)", "warning");
|
||||
return;
|
||||
}
|
||||
const gateway = gatewayUrl();
|
||||
const alive = await proxyAlive(gateway);
|
||||
const published = runStateRecoveryViaMcp(gateway);
|
||||
const recoveryReady = alive ? await recovery.ensure() : false;
|
||||
gateDone = true;
|
||||
if (!alive || published === void 0) {
|
||||
notify(ctx, "Caveman: direct mode, no compression this session (local proxy not running)", "warning");
|
||||
return;
|
||||
}
|
||||
if (!published || !recoveryReady) {
|
||||
notify(ctx, "Caveman: direct mode, no compression this session (recovery not available \u2014 run `caveman doctor pi`)", "warning");
|
||||
return;
|
||||
}
|
||||
await router.openGate(gateway, ctx);
|
||||
}));
|
||||
pi.on("model_select", GUARD_model_select(async (event, ctx) => {
|
||||
if (gateDone) await router?.apply(event.model, ctx);
|
||||
}));
|
||||
pi.on("before_agent_start", GUARD_before_agent_start(async (event, ctx) => {
|
||||
const response = await bridge.call("UserPromptSubmit", {
|
||||
session_id: sessionId,
|
||||
model: ctx.model?.id,
|
||||
provider: ctx.model?.provider,
|
||||
prompt: promptDigest(event.prompt),
|
||||
task_type: taskType(event.prompt),
|
||||
task_terms: taskTerms(event.prompt),
|
||||
task_continuation: taskContinuation(event.prompt)
|
||||
});
|
||||
const dynamic = [additionalContextOf(response), ...pendingContext].filter(Boolean);
|
||||
pendingContext = [];
|
||||
pendingBytes = 0;
|
||||
if (!coreContext && dynamic.length === 0) return void 0;
|
||||
const parts = [event.systemPrompt, coreContext, ...dynamic].filter(Boolean);
|
||||
return { systemPrompt: parts.join("\n\n") };
|
||||
}));
|
||||
pi.on("turn_start", GUARD_turn_start(() => {
|
||||
void bridge.call("ModelBefore", { session_id: sessionId });
|
||||
}));
|
||||
pi.on("turn_end", GUARD_turn_end(async () => {
|
||||
await bridge.call("ModelAfter", { session_id: sessionId });
|
||||
void bridge.call("Stop", { session_id: sessionId });
|
||||
}));
|
||||
pi.on("tool_call", GUARD_tool_call(async (event) => {
|
||||
const response = await bridge.call("PreToolUse", {
|
||||
session_id: sessionId,
|
||||
tool_name: event.toolName,
|
||||
tool_input: event.input
|
||||
});
|
||||
const context = additionalContextOf(response);
|
||||
const bytes = context ? Buffer.byteLength(context, "utf8") : 0;
|
||||
if (context && pendingBytes + bytes <= MAX_CONTEXT_BYTES) {
|
||||
pendingContext.push(context);
|
||||
pendingBytes += bytes;
|
||||
}
|
||||
}));
|
||||
pi.on("tool_result", GUARD_tool_result((event) => event.toolName === RECOVERY_TOOL ? void 0 : shrinkToolResult(bridge, sessionId, event)));
|
||||
pi.on("session_before_compact", GUARD_session_before_compact(() => {
|
||||
void bridge.call("PreCompact", { session_id: sessionId });
|
||||
}));
|
||||
pi.on("session_compact", GUARD_session_compact(async () => {
|
||||
const response = await bridge.call("PostCompact", { session_id: sessionId });
|
||||
const context = additionalContextOf(response);
|
||||
if (context) coreContext = context;
|
||||
}));
|
||||
pi.on("session_shutdown", GUARD_session_shutdown(async () => {
|
||||
await bridge.call("SessionEnd", { session_id: sessionId });
|
||||
router?.closeGate();
|
||||
recovery.dispose();
|
||||
}));
|
||||
}
|
||||
export {
|
||||
index_default as default
|
||||
};
|
||||
|
|
@ -1,394 +0,0 @@
|
|||
// installed by herdr
|
||||
// managed by herdr; reinstalling or updating the integration overwrites this file.
|
||||
// add custom hooks/plugins beside this file instead of editing it.
|
||||
// HERDR_INTEGRATION_ID=pi
|
||||
// HERDR_INTEGRATION_VERSION=4
|
||||
// @ts-nocheck
|
||||
|
||||
import { createConnection } from "node:net";
|
||||
|
||||
const HERDR_ENV = process.env.HERDR_ENV;
|
||||
const socketPath = process.env.HERDR_SOCKET_PATH;
|
||||
const paneId = process.env.HERDR_PANE_ID;
|
||||
const source = "herdr:pi";
|
||||
|
||||
function enabled() {
|
||||
return HERDR_ENV === "1" && !!socketPath && !!paneId;
|
||||
}
|
||||
|
||||
function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
|
||||
if (!enabled()) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (delivered: boolean) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
socket.destroy();
|
||||
resolve(delivered);
|
||||
};
|
||||
|
||||
const socket = createConnection(socketPath!);
|
||||
socket.on("error", () => finish(false));
|
||||
socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
|
||||
socket.on("data", () => finish(true));
|
||||
socket.on("end", () => finish(false));
|
||||
timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
timeout.unref?.();
|
||||
});
|
||||
}
|
||||
|
||||
async function sendRequest(request: unknown): Promise<void> {
|
||||
if (await sendRequestAttempt(request, 500)) {
|
||||
return;
|
||||
}
|
||||
await sendRequestAttempt(request, 1500);
|
||||
}
|
||||
|
||||
type AgentState = "working" | "blocked" | "idle";
|
||||
|
||||
type QueuedState = {
|
||||
state: AgentState;
|
||||
message?: string;
|
||||
seq: number;
|
||||
};
|
||||
|
||||
const idleDebounceMs = parseDurationEnv("HERDR_PI_IDLE_DEBOUNCE_MS", 250);
|
||||
const retryGraceMs = parseDurationEnv("HERDR_PI_RETRY_GRACE_MS", 2500);
|
||||
const retryableErrorPattern =
|
||||
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
|
||||
let reportSeq = Date.now() * 1000;
|
||||
let currentAgentSessionId: string | undefined;
|
||||
let currentAgentSessionPath: string | undefined;
|
||||
|
||||
function nextReportSeq(): number {
|
||||
reportSeq += 1;
|
||||
return reportSeq;
|
||||
}
|
||||
|
||||
function parseDurationEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function updateSessionRef(ctx: any): void {
|
||||
try {
|
||||
const file = ctx?.sessionManager?.getSessionFile?.();
|
||||
currentAgentSessionPath =
|
||||
typeof file === "string" && file.startsWith("/") ? file : undefined;
|
||||
} catch {
|
||||
currentAgentSessionPath = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const id = ctx?.sessionManager?.getSessionId?.();
|
||||
currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined;
|
||||
} catch {
|
||||
currentAgentSessionId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function withSessionRef(params: Record<string, unknown>): Record<string, unknown> {
|
||||
if (currentAgentSessionPath) {
|
||||
return { ...params, agent_session_path: currentAgentSessionPath };
|
||||
}
|
||||
if (currentAgentSessionId) {
|
||||
return { ...params, agent_session_id: currentAgentSessionId };
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function currentSessionRef(): Record<string, unknown> | undefined {
|
||||
if (currentAgentSessionPath) {
|
||||
return { agent_session_path: currentAgentSessionPath };
|
||||
}
|
||||
if (currentAgentSessionId) {
|
||||
return { agent_session_id: currentAgentSessionId };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function reportSession(): Promise<void> {
|
||||
const sessionRef = currentSessionRef();
|
||||
if (!sessionRef) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return sendRequest({
|
||||
id: `${source}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
||||
method: "pane.report_agent_session",
|
||||
params: {
|
||||
pane_id: paneId,
|
||||
source,
|
||||
agent: "pi",
|
||||
seq: nextReportSeq(),
|
||||
...sessionRef,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise<void> {
|
||||
return sendRequest({
|
||||
id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
||||
method: "pane.report_agent",
|
||||
params: withSessionRef({
|
||||
pane_id: paneId,
|
||||
source,
|
||||
agent: "pi",
|
||||
state,
|
||||
message,
|
||||
seq,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function releaseAgent(): Promise<void> {
|
||||
return sendRequest({
|
||||
id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
||||
method: "pane.release_agent",
|
||||
params: {
|
||||
pane_id: paneId,
|
||||
source,
|
||||
agent: "pi",
|
||||
seq: nextReportSeq(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function shouldReleaseOnSessionShutdown(event: any): boolean {
|
||||
// Pi tears down and rebinds extension runtimes for internal lifecycle actions
|
||||
// such as /reload, /new, /resume, and /fork. Those do not mean the pane's
|
||||
// agent process has exited, and releasing hook authority there can suppress
|
||||
// legitimate reports from the replacement runtime. Only a user/process quit
|
||||
// should release Herdr's full-lifecycle authority.
|
||||
const reason = event?.reason;
|
||||
return reason === "quit";
|
||||
}
|
||||
|
||||
let sendInFlight = false;
|
||||
let queuedState: QueuedState | undefined;
|
||||
|
||||
function queueState(state: AgentState, message?: string): void {
|
||||
queuedState = { state, message, seq: nextReportSeq() };
|
||||
if (!sendInFlight) {
|
||||
void drainStateQueue();
|
||||
}
|
||||
}
|
||||
|
||||
async function drainStateQueue(): Promise<void> {
|
||||
if (sendInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendInFlight = true;
|
||||
try {
|
||||
while (queuedState) {
|
||||
const next = queuedState;
|
||||
queuedState = undefined;
|
||||
await sendState(next.state, next.message, next.seq);
|
||||
}
|
||||
} finally {
|
||||
sendInFlight = false;
|
||||
if (queuedState) {
|
||||
void drainStateQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function lastAssistantMessage(messages: unknown[]): any | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i] as any;
|
||||
if (message?.role === "assistant") {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function retryableErrorMessage(event: any): string | undefined {
|
||||
const messages = Array.isArray(event?.messages) ? event.messages : [];
|
||||
const assistant = lastAssistantMessage(messages);
|
||||
if (assistant?.stopReason !== "error") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const errorMessage = String(assistant.errorMessage ?? "");
|
||||
if (!retryableErrorPattern.test(errorMessage)) {
|
||||
return undefined;
|
||||
}
|
||||
return errorMessage || "retryable provider error";
|
||||
}
|
||||
|
||||
export default function (pi) {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let agentActive = false;
|
||||
let retryHoldActive = false;
|
||||
let failureBlocked = false;
|
||||
let failureMessage: string | undefined;
|
||||
let blockedCount = 0;
|
||||
let blockedMessage: string | undefined;
|
||||
let lastState: AgentState | undefined;
|
||||
let lastMessage: string | undefined;
|
||||
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let rootSession = false;
|
||||
|
||||
function clearTimer(timer: ReturnType<typeof setTimeout> | undefined) {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingTimers() {
|
||||
clearTimer(idleTimer);
|
||||
clearTimer(retryTimer);
|
||||
idleTimer = undefined;
|
||||
retryTimer = undefined;
|
||||
}
|
||||
|
||||
function clearFailureState() {
|
||||
retryHoldActive = false;
|
||||
failureBlocked = false;
|
||||
failureMessage = undefined;
|
||||
}
|
||||
|
||||
function desiredState() {
|
||||
if (blockedCount > 0) {
|
||||
return { state: "blocked" as const, message: blockedMessage };
|
||||
}
|
||||
if (failureBlocked) {
|
||||
return { state: "blocked" as const, message: failureMessage };
|
||||
}
|
||||
if (agentActive || retryHoldActive) {
|
||||
return { state: "working" as const, message: undefined };
|
||||
}
|
||||
return { state: "idle" as const, message: undefined };
|
||||
}
|
||||
|
||||
function publishState(force = false) {
|
||||
const next = desiredState();
|
||||
if (!force && next.state === lastState && next.message === lastMessage) {
|
||||
return;
|
||||
}
|
||||
lastState = next.state;
|
||||
lastMessage = next.message;
|
||||
queueState(next.state, next.message);
|
||||
}
|
||||
|
||||
function scheduleIdle() {
|
||||
clearPendingTimers();
|
||||
clearFailureState();
|
||||
idleTimer = setTimeout(() => {
|
||||
idleTimer = undefined;
|
||||
publishState();
|
||||
}, idleDebounceMs);
|
||||
idleTimer.unref?.();
|
||||
}
|
||||
|
||||
function holdForRetry(message: string) {
|
||||
clearPendingTimers();
|
||||
retryHoldActive = true;
|
||||
failureBlocked = false;
|
||||
failureMessage = message;
|
||||
publishState();
|
||||
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = undefined;
|
||||
retryHoldActive = false;
|
||||
failureBlocked = true;
|
||||
publishState();
|
||||
}, retryGraceMs);
|
||||
retryTimer.unref?.();
|
||||
}
|
||||
|
||||
pi.events.on("herdr:blocked", (data) => {
|
||||
if (!rootSession) {
|
||||
return;
|
||||
}
|
||||
if (!data?.active) {
|
||||
blockedCount = Math.max(0, blockedCount - 1);
|
||||
if (blockedCount === 0) {
|
||||
blockedMessage = undefined;
|
||||
}
|
||||
publishState();
|
||||
return;
|
||||
}
|
||||
|
||||
clearPendingTimers();
|
||||
blockedCount += 1;
|
||||
blockedMessage = data.label;
|
||||
publishState();
|
||||
});
|
||||
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
if (ctx?.hasUI !== true) {
|
||||
return;
|
||||
}
|
||||
rootSession = true;
|
||||
updateSessionRef(ctx);
|
||||
void reportSession();
|
||||
// A reload can replace this extension mid-run without emitting another agent_start.
|
||||
agentActive = ctx?.isIdle?.() === false;
|
||||
publishState(true);
|
||||
});
|
||||
|
||||
pi.on("agent_start", (_event, ctx) => {
|
||||
if (!rootSession) {
|
||||
return;
|
||||
}
|
||||
updateSessionRef(ctx);
|
||||
void reportSession();
|
||||
clearPendingTimers();
|
||||
clearFailureState();
|
||||
agentActive = true;
|
||||
publishState();
|
||||
});
|
||||
|
||||
pi.on("agent_end", (event) => {
|
||||
if (!rootSession) {
|
||||
return;
|
||||
}
|
||||
if (!agentActive) {
|
||||
// Pi can emit duplicate/late end events while auto-retry is already
|
||||
// holding the pane in Working. Do not let an unqualified duplicate end
|
||||
// cancel the retry hold and publish a false Idle.
|
||||
return;
|
||||
}
|
||||
|
||||
agentActive = false;
|
||||
|
||||
const retryableMessage = retryableErrorMessage(event);
|
||||
if (retryableMessage) {
|
||||
holdForRetry(retryableMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleIdle();
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async (event) => {
|
||||
if (!rootSession) {
|
||||
return;
|
||||
}
|
||||
clearPendingTimers();
|
||||
if (shouldReleaseOnSessionShutdown(event)) {
|
||||
await releaseAgent();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -357,7 +357,7 @@ export default async function (pi: ExtensionAPI) {
|
|||
"byte.llama",
|
||||
"byte.llama (byte.local GPU)",
|
||||
HOST.startsWith("byte") ? "http://localhost:9292/v1" : "http://byte.local:9292/v1",
|
||||
"LLAMA_SWAP_API_KEY",
|
||||
"none",
|
||||
);
|
||||
|
||||
// miche.llama — miche.local GPU (localhost when running on miche itself)
|
||||
|
|
@ -366,6 +366,6 @@ export default async function (pi: ExtensionAPI) {
|
|||
"miche.llama",
|
||||
"miche.llama (miche.local GPU)",
|
||||
HOST.startsWith("miche") ? "http://localhost:9292/v1" : "http://miche.local:9292/v1",
|
||||
"LLAMA_SWAP_API_KEY",
|
||||
"none",
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
/**
|
||||
* Pi Notify Extension (with audible bell)
|
||||
*
|
||||
* Fires a terminal bell + native desktop notification when Pi finishes.
|
||||
* - Bell (\x07): tmux flags the window in the status bar + audible ding
|
||||
* - OSC 777: Ghostty, iTerm2, WezTerm desktop notification popup
|
||||
* - OSC 99: Kitty desktop notification popup
|
||||
*
|
||||
* Based on the official example from earendil-works/pi, with bell added.
|
||||
*/
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
function notifyOSC777(title: string, body: string): void {
|
||||
process.stdout.write(`\x1b]777;notify;${title};${body}\x07`);
|
||||
}
|
||||
|
||||
function notifyOSC99(title: string, body: string): void {
|
||||
process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`);
|
||||
process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`);
|
||||
}
|
||||
|
||||
function notify(title: string, body: string): void {
|
||||
// Audible bell — tmux catches this, flags the window, terminal dings
|
||||
process.stdout.write("\x07");
|
||||
// Desktop notification popup (OSC 777 for Ghostty, OSC 99 for Kitty)
|
||||
if (process.env.KITTY_WINDOW_ID) {
|
||||
notifyOSC99(title, body);
|
||||
} else {
|
||||
notifyOSC777(title, body);
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("agent_end", async () => {
|
||||
notify("Pi", "Task complete — ready for input");
|
||||
});
|
||||
}
|
||||
|
|
@ -19,9 +19,7 @@
|
|||
"npm:@danielmeneses/pi-llama-swap",
|
||||
"npm:pi-llama-cpp-stats",
|
||||
"https://github.com/firecrawl/pi-firecrawl",
|
||||
"git:github.com/davebcn87/pi-autoresearch",
|
||||
"npm:pi-powerline-footer",
|
||||
"npm:@firstpick/pi-themes-bundle"
|
||||
"git:github.com/davebcn87/pi-autoresearch"
|
||||
],
|
||||
"enableInstallTelemetry": false,
|
||||
"subagents": {
|
||||
|
|
|
|||
|
|
@ -115,9 +115,6 @@ alias ll='eza -l --color=always --group-directories-first --icons'
|
|||
alias la='eza -a --color=always --group-directories-first --icons'
|
||||
alias lt='eza -aT --color=always --group-directories-first --icons'
|
||||
|
||||
# Oh My Pi — self-update before launch
|
||||
alias omp='omp update && omp'
|
||||
|
||||
{{ if eq .os_family "arch" -}}
|
||||
# Pacman / system (Arch-base only)
|
||||
alias update='sudo pacman -Syu'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue