[Sync] Lua Hyprland chain, licence cleanup

This commit is contained in:
Coja committed 2026-11-09 18:25:35 +01:00
1 parent 93b4f695ce
commit 47166d877f
183 files changed
+5618 -5839

No files matched your search

@@ -0,0 +1,26 @@
/**
* Pi agent-state extension: mirrors Pi's state into the tmux pane it runs in via
* ~/.config/tmux/scripts/agent-state.sh (pi busy|done|input|off), so the tmux status bar
* shows which pane is done or waiting for you. The script is a no-op outside tmux.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { execFile } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";
const SCRIPT = join(homedir(), ".config", "tmux", "scripts", "agent-state.sh");
function state(s: "busy" | "done" | "input" | "error" | "off"): void {
if (!process.env.TMUX_PANE) return;
execFile(SCRIPT, ["pi", s], () => {});
}
export default function (pi: ExtensionAPI) {
pi.on("agent_start", async () => state("busy"));
// agent_settled, not agent_end: Pi may still retry, compact, or run queued follow-ups.
pi.on("agent_settled", async () => state("done"));
// Blocking confirm/select prompts from extensions (permission-gate, confirm-destructive, questionnaire).
pi.on("ui_prompt_start", async () => state("input"));
pi.on("ui_prompt_end", async () => state("busy"));
pi.on("session_shutdown", async () => state("off"));
}
+78 -18
View File
@@ -3,31 +3,94 @@
*
* Compaction is otherwise silent, which is why "compacting fails a lot" took code
* reading and arithmetic to diagnose rather than a glance at a log. This makes it
* visible, and specifically names the failure mode that was breaking it here before
* 2026-08-16.
* visible and, for the one reason that signals trouble, says WHICH trouble.
*
* pi reports a `reason` on every compaction:
* "manual" - you ran /compact
* "threshold" - the healthy path: contextTokens passed contextWindow - reserveTokens
* "overflow" - the window was ALREADY full; this is recovery, not prevention
* "overflow" - recovery, not prevention: the previous assistant turn ended badly,
* pi compacts and (when `willRetry`) re-runs that turn once
*
* An "overflow" compaction means the turn blew past contextWindow before the threshold
* could fire, which happens whenever `compaction.reserveTokens` is smaller than the
* model's `maxTokens`. pi then gets one recovery attempt, summarizing from an
* already-overflowed context — and when that does not fit either, the session dies with
* "Context overflow recovery failed after one compact-and-retry attempt".
* Since pi 0.84.3 "overflow" covers TWO different situations, and the numbers on the
* last assistant message tell them apart (CLAUDE.md > Compaction tuning has the
* arithmetic for both):
*
* So: seeing "threshold" is fine. Seeing "overflow" means the settings are wrong for the
* model in use, and this says so at the moment it happens instead of leaving you to
* infer it later. See CLAUDE.md > Compaction tuning.
* 1. A real overflow — the server rejected the prompt (stopReason "error" matching
* pi's overflow patterns), or reported more input than the declared window.
* Fix: contextWindow / reserveTokens / maxTokens are wrong for the model in use.
* 2. Truncated-reply recovery — the reply stopped with stopReason "length" while
* usage.output is BELOW the model's maxTokens. The request itself carried a smaller
* max_tokens: before every call pi-ai clamps it to
* `contextWindow − its own token estimate − 4096` (clampMaxTokensToContext,
* CONTEXT_SAFETY_TOKENS) and then shrinks the thinking budget so that only 1024
* answer tokens remain. The window was never full. On a 24k window with maxTokens
* 4096 the clamp bites at any estimate above 16384 — the same number as the
* compaction threshold with reserveTokens 8192 — so it hits in the gaps the threshold
* check never sees: the first request after a compaction (no server usage to anchor
* on, so the system prompt and the tool schemas join the estimate) and the first call
* of a user turn (the new message). Thinking models feel it first: 2048 of thinking
* leaves a clamped reply about 1024 tokens of answer, and a `write` payload is cut.
*
* The old message here blamed every "overflow" on `reserveTokens < maxTokens`, which is
* wrong for case 2 — the one that actually shows up (Ornith, 2026-10-22). Now the
* notification names the case and prints the numbers (output vs maxTokens, context vs
* window, the estimate the clamp implies), so the next occurrence is diagnosable from the
* screen instead of from a session file.
*
* Note there is no event for a *failed* auto-compaction — `onError` exists only on the
* manual `ctx.compact()` call — so catching the overflow entry is the earliest reliable
* warning available.
* warning available. Observe-only: it never alters the compaction itself.
*/
import type { AssistantMessage } from "@earendil-works/pi-ai";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
/** pi-ai's CONTEXT_SAFETY_TOKENS: the margin clampMaxTokensToContext keeps below the window. */
const PI_CONTEXT_SAFETY_TOKENS = 4096;
/** Last assistant message on the branch — the turn that triggered the recovery. */
function lastAssistant(entries: readonly unknown[]): AssistantMessage | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i] as { type?: string; message?: { role?: string } };
if (e.type === "message" && e.message?.role === "assistant") return e.message as AssistantMessage;
}
return undefined;
}
/** One line saying which kind of "overflow" this is, with the numbers that prove it. */
export function describeOverflow(last: AssistantMessage | undefined, window: number, maxTokens: number): string {
if (!last) return "no assistant message on the branch to inspect.";
const u = last.usage;
const output = u?.output ?? 0;
const context = (u?.input ?? 0) + (u?.cacheRead ?? 0) + (u?.cacheWrite ?? 0) + output;
const ctxStr = window > 0 ? `${context}/${window}` : `${context}`;
const checkWindow = "check contextWindow against the server ctx-size (CLAUDE.md > Compaction tuning).";
if (last.stopReason === "error") {
return `the server rejected the request at context ${ctxStr} (${last.errorMessage ?? "no message"}) — a real overflow; ${checkWindow}`;
}
if (last.stopReason === "length") {
if (output === 0 && window > 0 && context >= window * 0.99) {
return `the server produced no tokens at context ${ctxStr} — the prompt alone filled the window; ${checkWindow}`;
}
if (window > 0 && context >= window - 64) {
return (
`the server ran out of context mid-reply (${ctxStr}, output ${output}/${maxTokens}) — the prompt was ` +
`under-estimated (big tool results or images count more than chars ÷ 4); not a reserveTokens problem.`
);
}
if (maxTokens > 0 && output < maxTokens) {
const estimate = window > 0 ? ` pi's estimate at request time ≈ ${window - PI_CONTEXT_SAFETY_TOKENS - output}, server-side context ${ctxStr}.` : "";
return (
`truncated-reply recovery, NOT a full window: the reply stopped at ${output} of ${maxTokens} max tokens, ` +
`so pi had clamped max_tokens to window − estimate − ${PI_CONTEXT_SAFETY_TOKENS}.${estimate} ` +
`reserveTokens is not the knob — see CLAUDE.md > Compaction tuning > max_tokens clamp.`
);
}
}
return `stopReason ${last.stopReason}, context ${ctxStr}, output ${output}/${maxTokens} — input reported above the declared window; ${checkWindow}`;
}
export default function (pi: ExtensionAPI) {
let overflowCount = 0;
@@ -36,12 +99,9 @@ export default function (pi: ExtensionAPI) {
if (event.reason === "overflow") {
overflowCount++;
ctx.ui.notify(
`Compacting in OVERFLOW recovery (#${overflowCount}) — the context window was already ` +
`full before compaction could fire. Raise compaction.reserveTokens above this model's ` +
`maxTokens, or cap maxTokens in models.json. See CLAUDE.md > Compaction tuning.`,
"warning",
);
const model = ctx.model;
const verdict = describeOverflow(lastAssistant(event.branchEntries), model?.contextWindow ?? 0, model?.maxTokens ?? 0);
ctx.ui.notify(`Compacting in OVERFLOW recovery (#${overflowCount}) — ${verdict}`, "warning");
}
// Never alter the compaction itself — observe only.
+153 -16
View File
@@ -17,7 +17,13 @@
* 󰚩 model model id + · thinking level (Claude 🤖) U+F06A9 nf-md-robot
* 󰈚 context % of context window used (+ tokens) (Claude 📝) U+F021A nf-md-text_box
* 󰓅 t/s last response's decode throughput U+F04C5 nf-md-speedometer
* ↑ ↓ tokens session input / output tokens (Claude 💰) plain arrows
* (llama.cpp's own server-side number + dim ` pp N` prompt-processing
* speed when the final SSE chunk carries `timings`; otherwise the
* client-side clock -- see "server-side timings" below)
* ↑ ↓ tokens session prompt / output tokens (Claude 🔢) plain arrows
* (prompt = input + cache read + cache write: everything the server
* processed, matching the Claude bar — llama.cpp reports a cached
* prefix as cacheRead, so bare usage.input is a few dozen tokens)
*
* git, context, t/s and tokens only appear once there is data for them (i.e. after
* the first response, and git only inside a git repo). Extension statuses (e.g.
@@ -58,12 +64,111 @@ const CTX_WARN_AT = 80;
// direct access to it). Defaults to the settings.json default of "off".
let thinkingLevel = "off";
// Decode throughput (tokens/sec) of the most recent assistant response, measured
// from the first streamed token to message end so prompt-eval time is excluded.
// Decode throughput (tokens/sec) of the most recent assistant response. Preferred
// source: the server's own timings (below). Fallback: a client-side clock from the
// first streamed token to message end, so prompt-eval time is excluded.
let lastTps = 0;
let lastPp = 0; // prompt-processing t/s, server-side only (0 = unknown)
let genFirstTokenAt = 0;
let sawToken = false;
// --- server-side timings (llama.cpp) ----------------------------------------
// llama.cpp puts a `timings` object on the final SSE chunk (the one carrying `usage`):
// prompt_n / prompt_ms / prompt_per_second, predicted_n / predicted_ms /
// predicted_per_second, cache_n, draft_n / draft_n_accepted. pi-ai's openai-completions
// provider reads only `usage` and drops the rest, and the extension API has no HTTP
// hook, so the one way to see it is to wrap globalThis.fetch and tee the SSE body.
// That is sanctioned: pi's http-dispatcher installs undici's fetch at startup and
// deliberately keeps a later override ("If a caller replaced fetch after module load,
// preserve that deliberate override"), and pi-ai builds `new OpenAI({ fetch: undefined })`
// per request, which falls back to globalThis.fetch -- so a wrapper installed at
// session_start sees every chat completion. Only POST */chat/completions responses
// with a text/event-stream body are teed; other providers' streams carry no `timings`
// and are passed through untouched apart from the tee. The tap stays installed for the
// life of the process (`/statusbar` off just stops displaying it; `/reload` rebinds the
// sink instead of wrapping twice).
interface ServerTimings {
tps: number; // predicted_per_second
pp: number; // prompt_per_second
predictedN: number;
promptN: number;
cacheN: number;
}
// Latest timings seen on the wire, consumed by message_end. Paired with the message by
// predicted_n === usage.output, so a compaction/summary call's timings never get
// attributed to the next visible response.
let pendingServerTimings: ServerTimings | undefined;
const FETCH_TAP_KEY = "__piStatusbarFetchTap";
/** Read one branch of a teed SSE body and report every `timings` object it carries. */
async function scanSse(stream: ReadableStream<Uint8Array>, onTimings: (t: ServerTimings) => void): Promise<void> {
const reader = stream.getReader();
const dec = new TextDecoder();
let buf = "";
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let nl: number;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload.includes('"timings"')) continue; // cheap pre-filter: parse only the final chunk
try {
const t = JSON.parse(payload)?.timings;
if (t && typeof t.predicted_per_second === "number") {
onTimings({
tps: t.predicted_per_second,
pp: typeof t.prompt_per_second === "number" ? t.prompt_per_second : 0,
predictedN: t.predicted_n ?? 0,
promptN: t.prompt_n ?? 0,
cacheN: t.cache_n ?? 0,
});
}
} catch {
// not JSON: ignore
}
}
}
} catch {
// aborted / cancelled stream: the primary branch reports that, we just stop
}
}
/** Wrap globalThis.fetch once; on /reload only rebind the sink. */
function installFetchTap(): void {
const g = globalThis as unknown as Record<string, unknown>;
const sink = (t: ServerTimings) => {
pendingServerTimings = t;
};
const existing = g[FETCH_TAP_KEY] as { sink: (t: ServerTimings) => void } | undefined;
if (existing) {
existing.sink = sink;
return;
}
const tap = { sink };
g[FETCH_TAP_KEY] = tap;
const orig = globalThis.fetch;
globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
const res = await orig(input, init);
let url = "";
try {
url = typeof input === "string" ? input : input instanceof URL ? input.href : ((input as Request)?.url ?? "");
} catch {
return res;
}
if (!url.endsWith("/chat/completions") || !res.body) return res;
if (!(res.headers.get("content-type") ?? "").includes("text/event-stream")) return res;
const [ours, theirs] = res.body.tee();
void scanSse(ours, (t) => tap.sink(t));
return new Response(theirs, res);
}) as typeof fetch;
}
// Set from inside the footer factory so events can trigger a re-render.
let requestRender: (() => void) | undefined;
@@ -74,12 +179,27 @@ function collapseHome(p: string): string {
return p;
}
/** 1234 -> 1.2k, 843000 -> 843k, 1234567 -> 1.2M (same boundaries as the Claude bar's
* fmt_tokens: 0.9995 / 9.95 so a value never prints as "10.0k" or "1000k"). */
function fmtTokens(n: number): string {
return n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`;
for (const [unit, div] of [
["M", 1e6],
["k", 1e3],
] as const) {
const v = n / div;
if (v < 0.9995) continue;
return v >= 9.95 ? `${v.toFixed(0)}${unit}` : `${v.toFixed(1)}${unit}`;
}
return `${Math.round(n)}`;
}
/** Cumulative session tokens, plus the last request's input tokens (~ current
* context-window fill). */
/** Cumulative session tokens, plus the last request's prompt size (~ current
* context-window fill).
*
* Prompt = input + cacheRead + cacheWrite. pi-ai splits a cached prefix out of
* `usage.input` (llama.cpp too: the second call of a session reports e.g. input 20,
* cacheRead 2417), so summing bare `input` undercounted ↑ and -- worse -- made the
* context % and the ≥80% warning read near zero on every cached call. */
function tokenStats(ctx: ExtensionContext): {
input: number;
output: number;
@@ -92,9 +212,10 @@ function tokenStats(ctx: ExtensionContext): {
if (entry.type === "message" && entry.message.role === "assistant") {
const usage = (entry.message as AssistantMessage).usage;
if (!usage) continue; // aborted stream: no usage — a throwing render kills the TUI
input += usage.input;
output += usage.output;
lastInput = usage.input;
const prompt = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
input += prompt;
output += usage.output ?? 0;
lastInput = prompt;
}
}
return { input, output, lastInput };
@@ -161,8 +282,19 @@ export default function (pi: ExtensionAPI) {
pi.on("message_end", async (event, ctx) => {
if (event.message?.role !== "assistant") return;
const output = (event.message as AssistantMessage).usage?.output ?? 0;
const secs = genFirstTokenAt ? (Date.now() - genFirstTokenAt) / 1000 : 0;
if (secs > 0 && output > 0) lastTps = output / secs;
// The tee reader and pi-ai consume the same final chunk; pi-ai's path to this
// event is longer, but give the reader one macrotask in case it is still behind.
if (!pendingServerTimings) await new Promise((r) => setTimeout(r, 20));
const srv = pendingServerTimings;
pendingServerTimings = undefined;
if (srv && srv.predictedN > 0 && (output === 0 || srv.predictedN === output)) {
lastTps = srv.tps;
lastPp = srv.pp;
} else {
const secs = genFirstTokenAt ? (Date.now() - genFirstTokenAt) / 1000 : 0;
if (secs > 0 && output > 0) lastTps = output / secs;
lastPp = 0;
}
requestRender?.();
if (enabled) updateContextWidget(ctx);
});
@@ -217,14 +349,15 @@ export default function (pi: ExtensionAPI) {
parts.push(seg);
}
// 󰓅 tokens/sec of the last response
// 󰓅 tokens/sec of the last response (+ dim prompt-processing t/s when the
// server reported it)
if (lastTps > 0) {
parts.push(
theme.fg("accent", ICON.tps) + " " + theme.fg("text", lastTps.toFixed(1)) + theme.fg("dim", " t/s"),
);
let seg = theme.fg("accent", ICON.tps) + " " + theme.fg("text", lastTps.toFixed(1)) + theme.fg("dim", " t/s");
if (lastPp > 0) seg += theme.fg("dim", ` pp ${lastPp.toFixed(0)}`);
parts.push(seg);
}
// ↑ ↓ cumulative session tokens (Claude's 💰 slot; local models are $0)
// ↑ ↓ cumulative session prompt / output tokens (Claude's 🔢 segment)
if (input || output) {
parts.push(theme.fg("dim", `${ICON.up}${fmtTokens(input)} ${ICON.down}${fmtTokens(output)}`));
}
@@ -236,6 +369,10 @@ export default function (pi: ExtensionAPI) {
};
pi.on("session_start", async (_event, ctx) => {
// Installed here, not at load time: pi's http-dispatcher only swaps in undici's
// fetch (idle timeout, proxy) while globalThis.fetch is still the original, so the
// tap must not get there first. By session_start, startup is done.
installFetchTap();
if (enabled) install(ctx);
});
+4 -4
View File
@@ -107,11 +107,11 @@
},
{
"id": "Ornith-1.5-35B-A3B-IQ3_XXS",
"name": "Ornith 1.5 35B · 24k · vision — agentic coder, thinking (~32 t/s)",
"name": "Ornith 1.5 35B · 32k · vision — agentic coder, thinking (~32 t/s)",
"reasoning": true,
"compat": { "thinkingFormat": "qwen-chat-template", "thinkingTokenBudgetField": "thinking_budget_tokens" },
"input": ["text", "image"],
"contextWindow": 24576,
"contextWindow": 32768,
"maxTokens": 4096,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
@@ -242,11 +242,11 @@
},
{
"id": "Ornith-1.5-35B-A3B-IQ3_XXS",
"name": "Ornith 1.5 35B · 24k · vision — agentic coder, thinking (~32 t/s)",
"name": "Ornith 1.5 35B · 32k · vision — agentic coder, thinking (~32 t/s)",
"reasoning": true,
"compat": { "thinkingFormat": "qwen-chat-template", "thinkingTokenBudgetField": "thinking_budget_tokens" },
"input": ["text", "image"],
"contextWindow": 24576,
"contextWindow": 32768,
"maxTokens": 4096,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
},
+3 -3
View File
@@ -53,8 +53,8 @@
},
"borderSync": {
"insert": "host",
"normal": "host",
"visual": "host",
"normal": "mode",
"visual": "mode",
"ex": "host"
},
"labelSync": {
@@ -64,7 +64,7 @@
"ex": "mode"
}
},
"lastChangelogVersion": "1.0.0",
"lastChangelogVersion": "1.5.0",
"packages": [
"npm:pi-vim"
]