little-coder 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,80 @@
1
+ import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
2
+ import { readFileSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ // Replace pi's built-in startup header + terminal title with little-coder
7
+ // branding. The interactive TUI's "pi vX.Y.Z" logo, the "Pi can explain its
8
+ // own features..." onboarding line, and the "π - <cwd>" terminal title all
9
+ // come from pi's APP_NAME / built-in header; this extension swaps them for
10
+ // little-coder's own identity using the public ExtensionUIContext hooks.
11
+ //
12
+ // Pairs with `.pi/settings.json` setting `"quietStartup": true`, which
13
+ // suppresses pi's built-in header AND the loaded-resources dump (the long
14
+ // list of extension paths, skills, prompts, themes that used to flood the
15
+ // screen on launch). Power users can still run `little-coder --verbose` to
16
+ // override quietStartup and see the resource list.
17
+ //
18
+ // Implementation pattern follows the bundled pi example at
19
+ // `node_modules/@mariozechner/pi-coding-agent/examples/extensions/custom-header.ts` —
20
+ // the factory returns a duck-typed Component (`render(width): string[]` +
21
+ // `invalidate()`), so no deep imports from pi-tui are needed.
22
+
23
+ const TAGLINE = "A coding agent tuned for small local models";
24
+
25
+ function readVersion(): string {
26
+ // .pi/extensions/branding/index.ts → up 3 → package root (where package.json lives).
27
+ // The same path math works in the local checkout (loaded via tsx) and in the
28
+ // installed npm package layout (node_modules/little-coder/.pi/extensions/branding/).
29
+ try {
30
+ const here = dirname(fileURLToPath(import.meta.url));
31
+ const pkgPath = join(here, "..", "..", "..", "package.json");
32
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
33
+ if (typeof pkg?.version === "string" && pkg.version.length > 0) return pkg.version;
34
+ } catch {
35
+ // best-effort; fall through
36
+ }
37
+ return "0.0.0";
38
+ }
39
+
40
+ const VERSION = readVersion();
41
+
42
+ function buildHeader(theme: Theme): string[] {
43
+ const logo =
44
+ theme.bold(theme.fg("accent", "little-coder")) +
45
+ theme.fg("dim", ` v${VERSION}`);
46
+ const tagline = theme.fg("muted", TAGLINE);
47
+ const dim = (s: string) => theme.fg("dim", s);
48
+ const sep = theme.fg("muted", " · ");
49
+ const hints = [
50
+ `${dim("esc")} interrupt`,
51
+ `${dim("ctrl-l/ctrl-c")} clear/exit`,
52
+ `${dim("/")} commands`,
53
+ `${dim("!")} bash`,
54
+ `${dim("ctrl-r")} more`,
55
+ ].join(sep);
56
+ return ["", logo, tagline, "", hints, ""];
57
+ }
58
+
59
+ function setTitleForCwd(setTitle: (t: string) => void, cwd: string): void {
60
+ setTitle(`little-coder - ${basename(cwd)}`);
61
+ }
62
+
63
+ export default function (pi: ExtensionAPI) {
64
+ // session_start fires on initial load AND on every session switch,
65
+ // so registering once covers both. Pi's own updateTerminalTitle() runs
66
+ // during init/switch, so re-asserting our title here is what keeps
67
+ // "π - <cwd>" from sneaking back in.
68
+ pi.on("session_start", async (_event, ctx) => {
69
+ if (!ctx.hasUI) return;
70
+
71
+ ctx.ui.setHeader((_tui, theme) => ({
72
+ render(_width: number): string[] {
73
+ return buildHeader(theme);
74
+ },
75
+ invalidate() {},
76
+ }));
77
+
78
+ setTitleForCwd(ctx.ui.setTitle.bind(ctx.ui), ctx.cwd);
79
+ });
80
+ }
@@ -21,6 +21,10 @@ const BUILTIN_SAFE_PREFIXES: readonly string[] = [
21
21
  "pip show", "pip list", "npm list", "cargo metadata",
22
22
  "df ", "du ", "free ", "top -bn", "ps ",
23
23
  "curl -I", "curl --head",
24
+ // Routine filesystem scaffolding. Trailing space = word boundary, so
25
+ // "cp " matches "cp a b" but not "cpufetch". rm stays off the list by
26
+ // design; use LITTLE_CODER_BASH_ALLOW=rm if a deployment needs it.
27
+ "cp ", "mv ", "mkdir ", "touch ",
24
28
  ];
25
29
 
26
30
  // Trailing whitespace is meaningful — it acts as a word boundary in startsWith
@@ -9,10 +9,22 @@ describe("isSafeBash", () => {
9
9
  expect(isSafeBash("grep -r pattern .")).toBe(true);
10
10
  expect(isSafeBash("rg pattern src/")).toBe(true);
11
11
  });
12
+ it("allows routine filesystem scaffolding (cp/mv/mkdir/touch)", () => {
13
+ expect(isSafeBash("cp a b")).toBe(true);
14
+ expect(isSafeBash("mv old new")).toBe(true);
15
+ expect(isSafeBash("mkdir -p sub/dir")).toBe(true);
16
+ expect(isSafeBash("touch foo.md")).toBe(true);
17
+ });
18
+ it("preserves trailing-whitespace word boundary on fs prefixes", () => {
19
+ // Without the trailing space, "cp" would match "cpufetch". With it, these stay blocked.
20
+ expect(isSafeBash("cpufetch")).toBe(false);
21
+ expect(isSafeBash("mvtool")).toBe(false);
22
+ expect(isSafeBash("mkdiroops")).toBe(false);
23
+ expect(isSafeBash("touchscreen")).toBe(false);
24
+ });
12
25
  it("blocks non-whitelisted commands", () => {
13
26
  expect(isSafeBash("rm -rf /")).toBe(false);
14
27
  expect(isSafeBash("npm install foo")).toBe(false);
15
- expect(isSafeBash("cp a b")).toBe(false);
16
28
  expect(isSafeBash("sudo anything")).toBe(false);
17
29
  });
18
30
  it("handles leading whitespace", () => {
@@ -1,7 +1,40 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { Type } from "@sinclair/typebox";
3
3
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
- import { dirname } from "node:path";
4
+ import { dirname, isAbsolute, join } from "node:path";
5
+
6
+ /**
7
+ * Resolve the Write tool's `file_path` argument to a concrete on-disk path.
8
+ *
9
+ * Two deterministic rewrites:
10
+ *
11
+ * 1. `"/<single-segment>"` (e.g. `/foo.md`) → `<cwd>/<single-segment>`.
12
+ * Background: the model has been seen to anchor at filesystem root when
13
+ * given an "Absolute file path" schema and no obvious directory context.
14
+ * Genuine system-path writes always include at least one intermediate
15
+ * directory (`/etc/X`, `/tmp/Y/Z`), so a root + bare filename is almost
16
+ * always a mistake. Rewriting to cwd matches user intent and avoids
17
+ * accidentally writing to `/`.
18
+ *
19
+ * 2. Bare filename / relative path (no leading slash) → resolved against cwd.
20
+ * Node's `fs` APIs already do this implicitly, but resolving here makes
21
+ * the success message report the real absolute path that was written.
22
+ *
23
+ * Anything else (absolute path with at least one intermediate directory) is
24
+ * left untouched.
25
+ */
26
+ export function normalizeWritePath(
27
+ filePath: string,
28
+ cwd: string = process.cwd(),
29
+ ): { path: string; rewrittenFrom?: string } {
30
+ if (/^\/[^/]+$/.test(filePath)) {
31
+ return { path: join(cwd, filePath.slice(1)), rewrittenFrom: filePath };
32
+ }
33
+ if (!isAbsolute(filePath)) {
34
+ return { path: join(cwd, filePath) };
35
+ }
36
+ return { path: filePath };
37
+ }
5
38
 
6
39
  // Port of tools.py::_write. Preserves the exact Edit-recipe error string so
7
40
  // the model recovers to Edit on its next turn. The whitepaper's benchmark
@@ -12,18 +45,24 @@ export default function (pi: ExtensionAPI) {
12
45
  name: "write",
13
46
  label: "Write",
14
47
  description:
15
- "Create a NEW file with the given content. Refuses if the file already exists — use edit to modify existing files. Parent directories are created automatically.",
48
+ "Create a NEW file with the given content. Refuses if the file already exists — use edit to modify existing files. " +
49
+ "Parent directories are created automatically. " +
50
+ "Pass either a path relative to the working directory (e.g. `notes/plan.md`) or a full absolute path. " +
51
+ "A bare filename like `foo.md` resolves to <cwd>/foo.md. " +
52
+ "A path of the form `/<filename>` with no intermediate directories is treated as cwd-relative " +
53
+ "(use `/etc/hosts` etc. if you really mean the filesystem root).",
16
54
  parameters: Type.Object({
17
- file_path: Type.String({ description: "Absolute file path" }),
55
+ file_path: Type.String({ description: "File path (relative to cwd, or absolute)" }),
18
56
  content: Type.String({ description: "Full file content" }),
19
57
  }),
20
58
  async execute(_id, { file_path, content }) {
21
- if (existsSync(file_path)) {
59
+ const { path: resolved, rewrittenFrom } = normalizeWritePath(file_path);
60
+ if (existsSync(resolved)) {
22
61
  const recipe =
23
- `Error: Write refused — ${file_path} already exists.\n` +
62
+ `Error: Write refused — ${resolved} already exists.\n` +
24
63
  `\n` +
25
64
  `Write is only for creating NEW files. To change an existing file, use Edit:\n` +
26
- ` {"name": "Edit", "input": {"file_path": "${file_path}", ` +
65
+ ` {"name": "Edit", "input": {"file_path": "${resolved}", ` +
27
66
  `"old_string": "<exact text currently in the file>", ` +
28
67
  `"new_string": "<replacement text>"}}\n` +
29
68
  `\n` +
@@ -41,12 +80,15 @@ export default function (pi: ExtensionAPI) {
41
80
  }
42
81
 
43
82
  try {
44
- mkdirSync(dirname(file_path), { recursive: true });
45
- writeFileSync(file_path, content, { encoding: "utf-8" });
83
+ mkdirSync(dirname(resolved), { recursive: true });
84
+ writeFileSync(resolved, content, { encoding: "utf-8" });
46
85
  const lc = content.split("\n").length - (content.endsWith("\n") ? 1 : 0) +
47
86
  (content.length > 0 && !content.endsWith("\n") ? 1 : 0);
87
+ const suffix = rewrittenFrom
88
+ ? ` (rewrote ${rewrittenFrom} → cwd; root-path single-segment write redirected)`
89
+ : "";
48
90
  return {
49
- content: [{ type: "text", text: `Created ${file_path} (${lc} lines)` }],
91
+ content: [{ type: "text", text: `Created ${resolved} (${lc} lines)${suffix}` }],
50
92
  details: {},
51
93
  };
52
94
  } catch (e) {
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { normalizeWritePath } from "./index.ts";
3
+
4
+ describe("normalizeWritePath", () => {
5
+ const cwd = "/home/me/proj";
6
+
7
+ it("rewrites /<bare-filename> to <cwd>/<bare-filename>", () => {
8
+ // The model anchoring at filesystem root is the bug we're fixing.
9
+ expect(normalizeWritePath("/foo.md", cwd)).toEqual({
10
+ path: "/home/me/proj/foo.md",
11
+ rewrittenFrom: "/foo.md",
12
+ });
13
+ expect(normalizeWritePath("/person.md", cwd)).toEqual({
14
+ path: "/home/me/proj/person.md",
15
+ rewrittenFrom: "/person.md",
16
+ });
17
+ });
18
+
19
+ it("resolves bare filenames against cwd (no rewrite flag — already cwd-relative)", () => {
20
+ expect(normalizeWritePath("foo.md", cwd)).toEqual({
21
+ path: "/home/me/proj/foo.md",
22
+ });
23
+ });
24
+
25
+ it("resolves nested relative paths against cwd", () => {
26
+ expect(normalizeWritePath("sub/foo.md", cwd)).toEqual({
27
+ path: "/home/me/proj/sub/foo.md",
28
+ });
29
+ expect(normalizeWritePath("a/b/c.md", cwd)).toEqual({
30
+ path: "/home/me/proj/a/b/c.md",
31
+ });
32
+ });
33
+
34
+ it("leaves genuine absolute paths alone (path has an intermediate directory)", () => {
35
+ // /etc/hosts has an intermediate directory, so it's a legitimate
36
+ // absolute path. We don't rewrite it.
37
+ expect(normalizeWritePath("/etc/hosts", cwd)).toEqual({
38
+ path: "/etc/hosts",
39
+ });
40
+ expect(normalizeWritePath("/tmp/foo.log", cwd)).toEqual({
41
+ path: "/tmp/foo.log",
42
+ });
43
+ });
44
+
45
+ it("leaves deep absolute paths in cwd untouched", () => {
46
+ // Model handing back its own cwd-prefixed path: unchanged.
47
+ expect(normalizeWritePath("/home/me/proj/notes/plan.md", cwd)).toEqual({
48
+ path: "/home/me/proj/notes/plan.md",
49
+ });
50
+ });
51
+ });
package/.pi/settings.json CHANGED
@@ -1,4 +1,5 @@
1
1
  {
2
+ "quietStartup": true,
2
3
  "compaction": { "enabled": true },
3
4
  "retry": { "enabled": true, "maxRetries": 2 },
4
5
  "little_coder": {
package/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
2
2
 
3
3
  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.
4
4
 
5
+ ## [v1.4.0] — 2026-05-16
6
+
7
+ Startup UI rebrand. The TUI's opening frame now reads as **little-coder**, not as pi. Pi remains the substrate; the chrome above it just stops pretending it's the product.
8
+
9
+ ### Added
10
+ - **New `.pi/extensions/branding/` extension.** Calls `pi.ui.setHeader()` and `pi.ui.setTitle()` on every `session_start` event to install a little-coder banner: `little-coder vX.Y.Z` (logo) + `A coding agent tuned for small local models` (tagline, verbatim from the README opening line) + a compact keybinding-hint row. The terminal title goes from `π - <cwd>` to `little-coder - <cwd>`. Implementation pattern follows pi's bundled `examples/extensions/custom-header.ts` — the factory returns a duck-typed Component (`render(width): string[]`), so no deep imports of pi-tui internals are required.
11
+ - **Startup screenshot in README.** A real `docs/assets/startup.svg` captured from a live `little-coder` startup, rendered via [charm.sh `freeze`](https://github.com/charmbracelet/freeze). Embedded near the top of the README so the first thing a visitor sees is the actual product, not a description of it.
12
+
13
+ ### Changed
14
+ - **`.pi/settings.json` now ships `"quietStartup": true`.** This is what suppresses pi's built-in loaded-resources block — the long list of extension paths, skills, prompts, themes that previously flooded the screen on every launch. Power users who want the inventory back can pass `little-coder --verbose`, which sets pi's `verbose: true` and overrides `quietStartup`.
15
+ - **Pi's "Pi can explain its own features..." onboarding string is gone.** The branding extension's `setHeader` replaces pi's built-in header entirely, so the line never renders.
16
+
17
+ ### Notes for upgraders
18
+ - No API, settings, or skill-pack breaks. CLI flags unchanged.
19
+ - If you'd customized pi's startup output via your own `models.json` / `.pi/settings.json` override, your changes still apply — the only new top-level key in shipped `.pi/settings.json` is `quietStartup`, and pi's override semantics preserve per-key user values.
20
+ - To restore the original pi-style startup (the `pi vX.Y.Z` logo and the loaded-resources list), run `little-coder --verbose`. There's no way to disable the branding extension from the user side short of editing the installed package, but the rebrand is purely the startup frame — no functional difference.
21
+
22
+ ---
23
+
24
+ ## [v1.3.0] — 2026-05-16
25
+
26
+ First functional release of Phase 2 (iterative improvement on real-world coding tasks). Three concrete sharp edges that surfaced while actually using the Mac → Linux LAN setup, plus a quality-of-life cleanup on the pi update banner. Minor version bump because three of the four changes are new behavior, all backwards-compatible.
27
+
28
+ ### Added
29
+ - **`cp`, `mv`, `mkdir`, `touch` are now on the built-in bash whitelist.** The permission-gate's `BUILTIN_SAFE_PREFIXES` previously covered only read-only inspection (`ls`, `cat`, `git log`, `find`, `grep`, …), so the model couldn't move or copy a file it just created without flipping `LITTLE_CODER_PERMISSION_MODE=accept-all`. These four were the most common false-positive blocks on day-to-day editing work. Trailing-whitespace word-boundary convention preserved — `cp ` allows `cp a b` but not `cpufetch`. `rm` and `sudo` stay off the list by design; per-deployment escape hatch is still `LITTLE_CODER_BASH_ALLOW`. New positive + negative-boundary assertions in `.pi/extensions/permission-gate/permission.test.ts`.
30
+ - **Image input on `llamacpp/qwen3.6-35b-a3b`.** `models.json` now declares `input: ["text", "image"]` for this entry, so pi's TUI no longer rejects clipboard / drag-and-drop screenshots. Pi already ships the full image-conversion / resize / OpenAI-format encoding stack (`@mariozechner/pi-coding-agent/dist/utils/{clipboard-image,image-resize,image-convert,mime}.js`); the gate was purely the capability flag on the model. README's *Option A — llama.cpp* now folds the vision projector into the canonical setup: an extra `hf download unsloth/Qwen3.6-35B-A3B-GGUF mmproj-F16.gguf` line and `--mmproj ~/models/mmproj-F16.gguf` on the `llama-server` command. Skip both lines if you want a text-only deployment.
31
+
32
+ ### Fixed
33
+ - **Write tool no longer writes to filesystem root when the model emits `/<filename>`.** Previously the tool's schema described `file_path` as *"Absolute file path"*, so models that had no obvious working-directory context dutifully wrote `/person.md` — landing the file at the filesystem root instead of under cwd. `.pi/extensions/write-guard/index.ts` now runs a deterministic `normalizeWritePath()` before any filesystem call: a path matching `/^\/[^/]+$/` (root + single segment, no intermediate dirs) is rewritten to `<cwd>/<segment>` and the success message says so explicitly; bare filenames / relative paths are resolved against cwd up-front so the returned path is absolute; genuine system writes (`/etc/X`, `/tmp/Y/Z`) are passed through untouched. Tool description updated to give the model the right mental model. New unit-test module `.pi/extensions/write-guard/write-guard.test.ts` covers the five distinct path shapes.
34
+
35
+ ### Changed
36
+ - **Pi's "Update Available" banner is suppressed by default.** `bin/little-coder.mjs` now defaults `PI_SKIP_VERSION_CHECK=1` unless you've explicitly set it. little-coder bundles `@mariozechner/pi-coding-agent` as an internal dependency pinned per release, so the in-session nag about updating pi was telling users to do something they shouldn't (and couldn't usefully) do — `npm install -g @mariozechner/pi-coding-agent@latest` doesn't affect the bundled copy. Opt back in with `PI_SKIP_VERSION_CHECK=0` if you want the banner. (The broader `PI_OFFLINE=1` is still your hammer for killing pi's other startup network calls — package-update check, tool auto-fetch, install telemetry.)
37
+
38
+ ### Notes for upgraders
39
+ - No CLI flag, settings.json, or skill-pack breaks. Existing `LITTLE_CODER_BASH_ALLOW` overrides continue to compose on top of the (now-wider) built-in list. Existing `models.json` user-override files for the llamacpp provider continue to work unchanged; if you'd hand-rolled an override entry for `qwen3.6-35b-a3b` you'll keep its old `input` value until you redeclare it. Tool descriptions changed on Write, which the model sees as a system-prompt diff — no API surface change for you.
40
+
41
+ ---
42
+
5
43
  ## [v1.2.1] — 2026-05-16
6
44
 
7
45
  Docs-only release marking two milestones: **Terminal-Bench 2.0 leaderboard acceptance** and the **end of the Phase 1 benchmark baseline**. No CLI, settings, or skill-pack changes — the env-var path for remote inference (`LLAMACPP_BASE_URL` / `OLLAMA_BASE_URL` / `LMSTUDIO_BASE_URL` pointing at a non-loopback host) has worked since v1.1.0 / v1.2.0, but it was undocumented for the LAN-server case until now.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  **A coding agent tuned for small local models, built on top of [pi](https://pi.dev).**
4
4
 
5
+ ![little-coder startup view](docs/assets/startup.svg)
6
+
5
7
  The research story behind all this — why scaffold–model fit matters, how a 9.7 B Qwen beat frontier entries on Aider Polyglot, and what the load-bearing mechanisms actually do — is written up on Substack: **[*Honey, I Shrunk the Coding Agent*](https://open.substack.com/pub/itayinbarr/p/honey-i-shrunk-the-coding-agent)**. Start there if you want the "why"; stay here for the "how".
6
8
 
7
9
  ## How it relates to pi
@@ -81,16 +83,21 @@ git clone https://github.com/ggml-org/llama.cpp && cd llama.cpp
81
83
  cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=120 -DLLAMA_CURL=ON
82
84
  cmake --build build --config Release -j
83
85
 
84
- # Fetch a GGUF
86
+ # Fetch the model GGUF and the matching vision projector.
87
+ # The mmproj (~900 MB) is what lets the model see attached screenshots.
85
88
  pip install -U "huggingface_hub[cli]"
86
89
  hf download unsloth/Qwen3.6-35B-A3B-GGUF Qwen3.6-35B-A3B-UD-Q4_K_M.gguf --local-dir ~/models
90
+ hf download unsloth/Qwen3.6-35B-A3B-GGUF mmproj-F16.gguf --local-dir ~/models
87
91
 
88
92
  # Serve it (MoE trick: experts in RAM, attention on GPU → 22 GB model on 8 GB VRAM)
89
93
  build/bin/llama-server -m ~/models/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf \
94
+ --mmproj ~/models/mmproj-F16.gguf \
90
95
  --host 127.0.0.1 --port 8888 --jinja \
91
96
  -c 16384 -ngl 99 --n-cpu-moe 999 --flash-attn on
92
97
  ```
93
98
 
99
+ If you only need text and want to skip the projector download, drop the second `hf download` line and the `--mmproj` flag — little-coder still works text-only, but the TUI's image attachment will be rejected by the server with a 4xx.
100
+
94
101
  **Option B — Ollama** (simpler, but slower on MoE):
95
102
 
96
103
  ```bash
@@ -186,7 +193,7 @@ Then verify with `little-coder --list-models` — you should see your overridden
186
193
 
187
194
  ## Permissions
188
195
 
189
- little-coder gates `Bash` tool calls against a built-in safe-prefix whitelist (`ls`, `cat`, `git log/status/diff`, `find`, `grep`, etc.) before pi's own confirmation flow ever sees them.
196
+ little-coder gates `Bash` tool calls against a built-in safe-prefix whitelist (`ls`, `cat`, `head`, `tail`, `git log/status/diff`, `find`, `grep`, `cp`, `mv`, `mkdir`, `touch`, etc.) before pi's own confirmation flow ever sees them. `rm` and `sudo` are intentionally not on the list — add them via `LITTLE_CODER_BASH_ALLOW` per deployment if you really need them.
190
197
 
191
198
  Two env vars control the gate:
192
199
 
@@ -247,8 +254,14 @@ That spans short coding exercises (Polyglot), interactive shell-bound tasks (Ter
247
254
 
248
255
  **`ECONNREFUSED 127.0.0.1:8888`** — llama.cpp isn't running. Start `llama-server` first, or switch `--model` to an Ollama/cloud ID.
249
256
 
257
+ **LAN client times out (no `RST`, just hangs)** — the inference box's firewall is dropping the SYN. The usual cause is `ufw` with a default-deny policy that allow-lists only SSH / a few dev ports. From the server: `sudo ufw status verbose` to confirm; `sudo ufw allow from <your-lan-subnet>/24 to any port 8888 proto tcp` to fix (scoped to the LAN so you're not exposing the box). Docker-published ports bypass `ufw` via `PREROUTING` NAT, which is why a Docker container can be reachable while a plain `llama-server` on the same host isn't.
258
+
259
+ **Image attachment is accepted but the request returns 4xx** — your llama-server is running without a vision projector. Re-launch it with `--mmproj ~/models/mmproj-F16.gguf` (or another mmproj variant from the same GGUF repo). The `--list-models` `images` column reflects what the client *will attempt to send*, not what the server can answer; the projector is what gives the model eyes.
260
+
250
261
  **No API key env var warning** — pi expects *some* key even for local providers. Export `LLAMACPP_API_KEY=noop` (or `OLLAMA_API_KEY=noop`) before launching.
251
262
 
263
+ **No pi "Update Available" banner** — that's intentional. little-coder defaults `PI_SKIP_VERSION_CHECK=1` so the bundled pi runtime doesn't nag about updating itself; little-coder pins pi to a known-good version per release. If you actually want the banner back, `export PI_SKIP_VERSION_CHECK=0` before launching.
264
+
252
265
  **Extension load failures on startup** — run `little-coder --list-models --verbose`; extension errors surface there. If the install looks corrupt: `npm uninstall -g little-coder && npm install -g little-coder`.
253
266
 
254
267
  **Node version too old** — little-coder needs Node ≥ 20.6.0. Check with `node --version`. Easiest fix: `nvm install 20 && nvm use 20`.
@@ -279,9 +292,10 @@ The benchmarks harness (`benchmarks/`) is dev-only and not shipped with the npm
279
292
  little-coder/
280
293
  ├── .pi/
281
294
  │ ├── settings.json # per-model profiles + benchmark_overrides (terminal_bench, gaia)
282
- │ └── extensions/ # 20 TypeScript extensions, auto-discovered by pi
295
+ │ └── extensions/ # 21 TypeScript extensions, auto-discovered by pi
296
+ │ ├── branding/ # little-coder startup header + terminal title (replaces pi's built-in)
283
297
  │ ├── llama-cpp-provider/ # data-driven provider registration from models.json — ships llamacpp, ollama, lmstudio (+ user override file)
284
- │ ├── write-guard/ # Write refuses on existing files the whitepaper invariant
298
+ │ ├── write-guard/ # Write refuses on existing files; rewrites root-bare /foo.md paths to cwd
285
299
  │ ├── extra-tools/ # glob, webfetch, websearch (pi ships grep/find)
286
300
  │ ├── skill-inject/ # per-turn tool-skill selection (error > recency > intent)
287
301
  │ ├── knowledge-inject/ # algorithm cheat-sheet scoring (word=1.0, bigram=2.0, threshold=2.0)
@@ -87,7 +87,18 @@ const piArgs = [
87
87
  ...userArgs,
88
88
  ];
89
89
 
90
- // ---- 7. Spawn pi in the user's cwd ----
90
+ // ---- 7. Suppress pi's own version-banner by default ----
91
+ // pi is an internal dependency here; users install `little-coder` and shouldn't
92
+ // see in-session nags about updating the underlying coding-agent package.
93
+ // PI_SKIP_VERSION_CHECK is the surgical pi switch (interactive-mode.js:525)
94
+ // that gates the "Update Available" banner without touching pi's other
95
+ // network-dependent startup paths. Honor an explicit user value (set to "0" or
96
+ // anything else to re-enable the banner; PI_OFFLINE=1 also re-overrides).
97
+ if (process.env.PI_SKIP_VERSION_CHECK === undefined) {
98
+ process.env.PI_SKIP_VERSION_CHECK = "1";
99
+ }
100
+
101
+ // ---- 8. Spawn pi in the user's cwd ----
91
102
  const [spawnCmd, spawnArgs] = isWindows
92
103
  ? ["cmd.exe", ["/c", piBin, ...piArgs]]
93
104
  : [piBin, piArgs];
package/models.json CHANGED
@@ -18,7 +18,7 @@
18
18
  "id": "qwen3.6-35b-a3b",
19
19
  "name": "Qwen3.6-35B-A3B (MoE, local llama.cpp)",
20
20
  "reasoning": true,
21
- "input": ["text"],
21
+ "input": ["text", "image"],
22
22
  "contextWindow": 32768,
23
23
  "maxTokens": 4096,
24
24
  "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "little-coder",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "A pi-based coding agent optimized for small local language models. Reproduces the whitepaper's scaffold-model-fit adaptations as pi extensions.",
5
5
  "homepage": "https://github.com/itayinbarr/little-coder",
6
6
  "repository": {