Skip to content

Commit 4fedf26

Browse files
Itay Inbarclaude
andcommitted
v1.9.12: mid-run compaction watchdog + update-prompt timeout/notice/command + Zed ACP guide
Fixed (#59): context-watchdog extension triggers pi compaction at turn boundaries once usage crosses 80% of the window, so long autonomous runs compact before overflowing instead of only at the (never-reached) idle boundary. Tunable via LITTLE_CODER_COMPACT_AT_PERCENT / disable via LITTLE_CODER_NO_COMPACT_WATCHDOG. Added (#64): the launcher update prompt auto-continues (default 10s, LITTLE_CODER_UPDATE_PROMPT_TIMEOUT) instead of blocking startup; an in-TUI "update available" notice and a /update command (update-notice extension). Docs (#58): docs/zed-acp.md documents the community pi-acp bridge for running little-coder in Zed's agent panel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FmSETqCghnJheH2GGVpy4D
1 parent f5cf27b commit 4fedf26

10 files changed

Lines changed: 570 additions & 3 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2+
3+
// Mid-run context watchdog (issue #59).
4+
//
5+
// pi only evaluates auto-compaction at a *user-turn boundary* — its
6+
// `_checkCompaction` runs inside `_handlePostAgentRun`, which fires only after
7+
// `agent.prompt()` has fully returned (i.e. once the model stops requesting
8+
// tools and goes idle). During one long autonomous run this boundary is never
9+
// reached: little-coder's small models routinely chain dozens of tool-call
10+
// turns before yielding, so context grows unchecked and can blow straight past
11+
// the window — pi then only reacts to the *overflow error* after the fact.
12+
// charly1r reproduced exactly this: context climbing 34k → 40k → … → 64k across
13+
// many `slot release` turns with no compaction until the request overflowed.
14+
//
15+
// pi does expose the levers to fix this from an extension: `ctx.getContextUsage()`
16+
// reports live token usage against the active model's window, and `ctx.compact()`
17+
// triggers pi's own compaction without awaiting it. This extension watches usage
18+
// at every turn boundary and, once it crosses a threshold, proactively kicks off
19+
// compaction — so a long single run compacts *before* it overflows, at roughly
20+
// the same point pi would have if the model had yielded.
21+
//
22+
// Tuning / opt-out:
23+
// LITTLE_CODER_COMPACT_AT_PERCENT trigger threshold, percent of the context
24+
// window (default 80). <=0 or >=100 disables.
25+
// LITTLE_CODER_NO_COMPACT_WATCHDOG=1 hard off.
26+
//
27+
// This is complementary to pi's end-of-run compaction, not a replacement — the
28+
// `compacting` guard below keeps us from re-firing while a compaction is already
29+
// in flight, and pi's own threshold/overflow paths still run at run boundaries.
30+
31+
export interface ContextUsageLike {
32+
tokens: number | null;
33+
contextWindow: number;
34+
percent: number | null;
35+
}
36+
37+
const DEFAULT_PERCENT = 80;
38+
39+
// Resolve the trigger threshold (percent of context window). Non-numeric or
40+
// missing → default. Returns 0 to mean "disabled" for out-of-band values
41+
// (<=0 disables outright; >=100 leaves it to pi's own overflow recovery).
42+
export function thresholdPercent(env: NodeJS.ProcessEnv = process.env): number {
43+
if (env.LITTLE_CODER_NO_COMPACT_WATCHDOG === "1") return 0;
44+
const raw = env.LITTLE_CODER_COMPACT_AT_PERCENT;
45+
if (raw === undefined || raw.trim() === "") return DEFAULT_PERCENT;
46+
const n = Number(raw);
47+
if (!Number.isFinite(n)) return DEFAULT_PERCENT;
48+
if (n <= 0 || n >= 100) return 0;
49+
return n;
50+
}
51+
52+
// Pure decision: should we kick off compaction on this turn? True only when the
53+
// watchdog is enabled, no compaction is already in flight, and we have a real
54+
// usage reading at or above the threshold. `tokens == null` (e.g. right after a
55+
// compaction, before the next LLM response) is treated as "unknown" → no-op.
56+
export function shouldCompactNow(
57+
usage: ContextUsageLike | undefined,
58+
pct: number,
59+
compacting: boolean,
60+
): boolean {
61+
if (pct <= 0) return false;
62+
if (compacting) return false;
63+
if (!usage) return false;
64+
if (usage.contextWindow <= 0) return false;
65+
if (usage.tokens === null || usage.percent === null) return false;
66+
return usage.percent >= pct;
67+
}
68+
69+
export default function (pi: ExtensionAPI) {
70+
const pct = thresholdPercent();
71+
if (pct <= 0) return; // disabled — register nothing
72+
73+
// In flight until the matching `session_compact` (or the next user prompt)
74+
// clears it, so a burst of turn_start events can't stack compaction calls.
75+
let compacting = false;
76+
77+
pi.on("before_agent_start", async () => {
78+
// A fresh user prompt is a clean boundary; drop any stale in-flight flag so
79+
// a cancelled/failed compaction can't wedge the watchdog off permanently.
80+
compacting = false;
81+
});
82+
83+
pi.on("turn_start", async (_event, ctx) => {
84+
const usage = ctx.getContextUsage?.();
85+
if (!shouldCompactNow(usage, pct, compacting)) return;
86+
compacting = true;
87+
const windowK = Math.round((usage!.contextWindow / 1000) * 10) / 10;
88+
ctx.ui.notify(
89+
`context at ${Math.round(usage!.percent!)}% of ${windowK}k — compacting mid-run to stay under the window`,
90+
"info",
91+
);
92+
// Fire-and-forget: pi runs compaction and the run continues on the compacted
93+
// transcript. We do NOT await (the API is explicitly non-awaiting).
94+
ctx.compact();
95+
});
96+
97+
pi.on("session_compact", async () => {
98+
compacting = false;
99+
});
100+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, it, expect } from "vitest";
2+
import { thresholdPercent, shouldCompactNow, type ContextUsageLike } from "./index.ts";
3+
4+
describe("thresholdPercent", () => {
5+
it("defaults to 80 when unset", () => {
6+
expect(thresholdPercent({})).toBe(80);
7+
expect(thresholdPercent({ LITTLE_CODER_COMPACT_AT_PERCENT: " " })).toBe(80);
8+
});
9+
10+
it("honors a valid override", () => {
11+
expect(thresholdPercent({ LITTLE_CODER_COMPACT_AT_PERCENT: "70" })).toBe(70);
12+
expect(thresholdPercent({ LITTLE_CODER_COMPACT_AT_PERCENT: "92.5" })).toBe(92.5);
13+
});
14+
15+
it("treats non-numeric as the default", () => {
16+
expect(thresholdPercent({ LITTLE_CODER_COMPACT_AT_PERCENT: "soon" })).toBe(80);
17+
});
18+
19+
it("disables for out-of-band values (<=0 or >=100)", () => {
20+
expect(thresholdPercent({ LITTLE_CODER_COMPACT_AT_PERCENT: "0" })).toBe(0);
21+
expect(thresholdPercent({ LITTLE_CODER_COMPACT_AT_PERCENT: "-5" })).toBe(0);
22+
expect(thresholdPercent({ LITTLE_CODER_COMPACT_AT_PERCENT: "100" })).toBe(0);
23+
expect(thresholdPercent({ LITTLE_CODER_COMPACT_AT_PERCENT: "150" })).toBe(0);
24+
});
25+
26+
it("hard-off via LITTLE_CODER_NO_COMPACT_WATCHDOG=1 overrides a percent", () => {
27+
expect(
28+
thresholdPercent({
29+
LITTLE_CODER_NO_COMPACT_WATCHDOG: "1",
30+
LITTLE_CODER_COMPACT_AT_PERCENT: "70",
31+
}),
32+
).toBe(0);
33+
});
34+
});
35+
36+
describe("shouldCompactNow", () => {
37+
const usage = (over: Partial<ContextUsageLike>): ContextUsageLike => ({
38+
tokens: 50000,
39+
contextWindow: 64000,
40+
percent: 78,
41+
...over,
42+
});
43+
44+
it("fires once usage is at/above the threshold", () => {
45+
expect(shouldCompactNow(usage({ percent: 80 }), 80, false)).toBe(true);
46+
expect(shouldCompactNow(usage({ percent: 95 }), 80, false)).toBe(true);
47+
});
48+
49+
it("does not fire below the threshold", () => {
50+
expect(shouldCompactNow(usage({ percent: 79 }), 80, false)).toBe(false);
51+
});
52+
53+
it("never fires while a compaction is already in flight", () => {
54+
expect(shouldCompactNow(usage({ percent: 99 }), 80, true)).toBe(false);
55+
});
56+
57+
it("no-ops on unknown token usage (null right after compaction)", () => {
58+
expect(shouldCompactNow(usage({ tokens: null, percent: null }), 80, false)).toBe(false);
59+
});
60+
61+
it("no-ops when disabled (pct<=0) or usage missing / window unknown", () => {
62+
expect(shouldCompactNow(usage({ percent: 99 }), 0, false)).toBe(false);
63+
expect(shouldCompactNow(undefined, 80, false)).toBe(false);
64+
expect(shouldCompactNow(usage({ contextWindow: 0 }), 80, false)).toBe(false);
65+
});
66+
67+
it("reproduces #59: a run climbing 34k→64k on a 64k window compacts before overflow", () => {
68+
const window = 64000;
69+
const pct = 80; // fires at 51.2k, ~13k of headroom before the 64k overflow
70+
let compacting = false;
71+
let firstCompactAt: number | null = null;
72+
for (const tokens of [34472, 40829, 46990, 52048, 55461, 58076, 62572]) {
73+
const u = usage({ tokens, contextWindow: window, percent: (tokens / window) * 100 });
74+
if (shouldCompactNow(u, pct, compacting)) {
75+
compacting = true; // pi compaction now in flight for the rest of the run
76+
if (firstCompactAt === null) firstCompactAt = tokens;
77+
}
78+
}
79+
expect(firstCompactAt).toBe(52048); // first turn past 80% — well before 64k
80+
});
81+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2+
import { spawnSync } from "node:child_process";
3+
4+
// In-app update surfacing (issue #64, items 2 & 3).
5+
//
6+
// The launcher's pre-flight update check (bin/update-check.mjs) runs before pi
7+
// starts and, when a newer little-coder is published, exports the version via
8+
// LITTLE_CODER_UPDATE_AVAILABLE. This extension consumes that:
9+
// 2. shows a one-line "update available" notice inside the running TUI, so a
10+
// user who dismissed / timed out of the launcher prompt still knows; and
11+
// 3. registers `/update` to install the latest and cleanly end the session
12+
// for a restart — no need to quit, remember the npm incantation, and relaunch.
13+
//
14+
// Registering `/update` unconditionally (even with no pending update) is
15+
// deliberate: it doubles as a manual "pull the latest" command. When a version
16+
// is known from the launcher we pin to it; otherwise we install `@latest`.
17+
18+
// Exported for unit tests: build the npm argv used to upgrade in place. Mirrors
19+
// the launcher's --ignore-scripts posture (issue #50 — block postinstall as a
20+
// supply-chain landing spot on upgrade).
21+
export function upgradeArgs(latest?: string) {
22+
const spec = latest ? `little-coder@${latest}` : "little-coder@latest";
23+
return ["install", "-g", "--ignore-scripts", spec];
24+
}
25+
26+
export default function (pi: ExtensionAPI) {
27+
const available = process.env.LITTLE_CODER_UPDATE_AVAILABLE;
28+
29+
if (available) {
30+
pi.on("session_start", async (_event, ctx) => {
31+
ctx.ui.notify(
32+
`little-coder v${available} is available — run /update to install it (then restart)`,
33+
"info",
34+
);
35+
});
36+
}
37+
38+
pi.registerCommand("update", {
39+
description: "Install the latest little-coder and end this session so you can restart into it",
40+
handler: async (_args, ctx) => {
41+
const latest = process.env.LITTLE_CODER_UPDATE_AVAILABLE;
42+
const ok = await ctx.ui.confirm(
43+
`Update little-coder${latest ? ` to v${latest}` : " to the latest version"}?`,
44+
"This ends the current session — you'll relaunch little-coder to use the new version.",
45+
);
46+
if (!ok) return;
47+
48+
ctx.ui.notify("updating little-coder… (this can take a moment)", "info");
49+
const args = upgradeArgs(latest);
50+
// On Windows `npm` is a .cmd shim spawnSync can't exec directly; go via
51+
// COMSPEC (same rationale as the launcher). Capture output rather than
52+
// inherit so npm's noise doesn't fight pi's TUI; we surface a summary.
53+
const result =
54+
process.platform === "win32"
55+
? spawnSync(process.env.COMSPEC || "cmd.exe", ["/c", "npm", ...args], { encoding: "utf-8" })
56+
: spawnSync("npm", args, { encoding: "utf-8" });
57+
58+
if (result.status === 0) {
59+
ctx.ui.notify(
60+
`✓ updated${latest ? ` to v${latest}` : ""} — relaunch little-coder to use it. Ending session…`,
61+
"info",
62+
);
63+
ctx.shutdown();
64+
return;
65+
}
66+
const why = result.error
67+
? ((result.error as NodeJS.ErrnoException).code ?? result.error.message)
68+
: `npm exit ${result.status}`;
69+
ctx.ui.notify(
70+
`✗ update failed (${why}). Run manually: npm install -g --ignore-scripts little-coder@latest`,
71+
"error",
72+
);
73+
},
74+
});
75+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2+
import setupUpdateNotice, { upgradeArgs } from "./index.ts";
3+
4+
describe("upgradeArgs", () => {
5+
it("pins to the known latest version when provided", () => {
6+
expect(upgradeArgs("1.9.12")).toEqual([
7+
"install",
8+
"-g",
9+
"--ignore-scripts",
10+
"little-coder@1.9.12",
11+
]);
12+
});
13+
14+
it("falls back to @latest with no version, and always keeps --ignore-scripts", () => {
15+
const args = upgradeArgs();
16+
expect(args).toContain("--ignore-scripts");
17+
expect(args[args.length - 1]).toBe("little-coder@latest");
18+
});
19+
});
20+
21+
describe("update-notice extension wiring", () => {
22+
const prev = process.env.LITTLE_CODER_UPDATE_AVAILABLE;
23+
afterEach(() => {
24+
if (prev === undefined) delete process.env.LITTLE_CODER_UPDATE_AVAILABLE;
25+
else process.env.LITTLE_CODER_UPDATE_AVAILABLE = prev;
26+
});
27+
28+
function harness() {
29+
const handlers: Record<string, Function> = {};
30+
let command: { name: string; opts: any } | undefined;
31+
const pi = {
32+
on(event: string, handler: Function) {
33+
handlers[event] = handler;
34+
},
35+
registerCommand(name: string, opts: any) {
36+
command = { name, opts };
37+
},
38+
};
39+
setupUpdateNotice(pi as any);
40+
return { handlers, command };
41+
}
42+
43+
it("always registers a /update command with a handler", () => {
44+
delete process.env.LITTLE_CODER_UPDATE_AVAILABLE;
45+
const { command } = harness();
46+
expect(command?.name).toBe("update");
47+
expect(typeof command?.opts.handler).toBe("function");
48+
});
49+
50+
it("shows a session_start notice only when an update is available", () => {
51+
delete process.env.LITTLE_CODER_UPDATE_AVAILABLE;
52+
expect(harness().handlers.session_start).toBeUndefined();
53+
54+
process.env.LITTLE_CODER_UPDATE_AVAILABLE = "1.9.12";
55+
expect(typeof harness().handlers.session_start).toBe("function");
56+
});
57+
58+
it("session_start notice names the available version", async () => {
59+
process.env.LITTLE_CODER_UPDATE_AVAILABLE = "1.9.12";
60+
const { handlers } = harness();
61+
const notes: string[] = [];
62+
const ctx = { ui: { notify: (m: string) => notes.push(m) } };
63+
await handlers.session_start({}, ctx);
64+
expect(notes.join("\n")).toContain("1.9.12");
65+
expect(notes.join("\n")).toContain("/update");
66+
});
67+
68+
it("/update aborts cleanly when the user declines the confirm", async () => {
69+
process.env.LITTLE_CODER_UPDATE_AVAILABLE = "1.9.12";
70+
const { command } = harness();
71+
let shutdownCalls = 0;
72+
const ctx = {
73+
ui: { confirm: async () => false, notify: () => {} },
74+
shutdown: () => shutdownCalls++,
75+
};
76+
await command!.opts.handler("", ctx);
77+
expect(shutdownCalls).toBe(0);
78+
});
79+
});

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,19 @@
22

33
All notable changes to little-coder are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and little-coder's public interface (CLI, providers, tools, skills) follows semver starting at `v0.0.1` post-rename.
44

5+
## [v1.9.12] — 2026-07-04
6+
7+
### Fixed
8+
- **Long autonomous runs now compact *before* they overflow the context window** ([#59](https://github.com/itayinbarr/little-coder/issues/59) by [@charly1r](https://github.com/charly1r)). pi only re-evaluates auto-compaction at a *user-turn boundary* — its check runs after `agent.prompt()` fully returns, i.e. once the model stops requesting tools and goes idle. During one long autonomous run that boundary is never reached: little-coder's small models routinely chain dozens of tool-call turns before yielding, so context climbs unchecked and pi only reacts to the *overflow error* after the fact. charly1r reproduced it precisely — context growing 34k → 40k → … → 64k across many turns with no compaction until the request overflowed a 64k window. A new **`context-watchdog`** extension closes the gap: it reads live usage via pi's `getContextUsage()` at every turn boundary and, once usage crosses **80%** of the window, calls pi's `compact()` mid-run — so a single long run compacts at roughly the same point pi would have if the model had paused. Tunable via `LITTLE_CODER_COMPACT_AT_PERCENT` (percent; e.g. `70` to compact earlier); `≤0`/`≥100` or `LITTLE_CODER_NO_COMPACT_WATCHDOG=1` disable it and defer entirely to pi's end-of-run/overflow paths. It's complementary to pi's own compaction (an in-flight guard prevents double-firing) and independent of the `reserveTokens`/`keepRecentTokens` knobs, which still govern how much is summarized vs. kept verbatim.
9+
10+
### Added
11+
- **The launcher's update prompt auto-continues instead of blocking, plus an in-app notice and `/update` command** ([#64](https://github.com/itayinbarr/little-coder/issues/64) by [@cndjonno](https://github.com/cndjonno)). When a newer version was published, the launcher's `Update now? [Y/n]` prompt blocked startup indefinitely waiting on input — an unattended terminal never got past it. It now **auto-continues without updating after 10 s** (configurable via `LITTLE_CODER_UPDATE_PROMPT_TIMEOUT=<seconds>`; `0`/`off`/`never` restores the old wait-forever behavior), and the prompt shows the countdown. Two follow-ups from the same request: (2) if you dismiss or time out of the launcher prompt, a one-line "update available" notice now appears **inside the running TUI** so the pending update isn't lost, and (3) a new **`/update`** command installs the latest little-coder (with `--ignore-scripts`, matching the launcher's supply-chain posture from [#50](https://github.com/itayinbarr/little-coder/issues/50)) and cleanly ends the session so you can relaunch into it — no quitting to remember the npm incantation.
12+
13+
### Docs
14+
- **Guide for running little-coder inside Zed via an ACP bridge** ([#58](https://github.com/itayinbarr/little-coder/issues/58) by [@BMorgan1296](https://github.com/BMorgan1296), with [@charly1r](https://github.com/charly1r)). little-coder still ships no ACP server of its own — `--mode rpc` is pi's internal extension-UI RPC, not the Agent Client Protocol — but the community [`pi-acp`](https://github.com/svkozak/pi-acp) bridge drives it well: point pi-acp's `PI_ACP_PI_COMMAND` at the `little-coder` binary and every bundled extension/skill comes along. New [`docs/zed-acp.md`](docs/zed-acp.md) writes up the full setup (Zed `agent_servers` config + a wrapper script that starts/stops `llama-server`), generalized from BMorgan1296's working recipe. Marked explicitly as community/unofficial — a first-class ACP transport still belongs in pi upstream, where both projects would benefit.
15+
16+
---
17+
518
## [v1.9.11] — 2026-06-28
619

720
### Fixed

0 commit comments

Comments
 (0)