little-coder 1.9.2 → 1.9.4

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.
@@ -8,6 +8,7 @@ import {
8
8
  type SubCoderResult,
9
9
  } from "./spawn.ts";
10
10
  import { SubCoderTracker } from "./tracker.ts";
11
+ import { truncateLineToWidth } from "../_shared/width.ts";
11
12
 
12
13
  // The `dispatch` tool: the main little-coder spawns isolated child little-coder
13
14
  // sessions ("sub-coders") to research a focused question — they read the repo
@@ -190,11 +191,22 @@ export default function (pi: ExtensionAPI) {
190
191
  });
191
192
  }
192
193
 
193
- /** A minimal pi-tui Component backed by precomputed lines. */
194
- function makeComponent(lines: string[]) {
194
+ /** A minimal pi-tui Component backed by precomputed lines.
195
+ *
196
+ * pi paints custom tool-result panels with a 1-char left margin + background-
197
+ * frame fill, so any line we hand back that's wider than `width - 1` overflows
198
+ * the terminal and crashes pi-tui (issue #51 / reopen of #48 — the dispatch
199
+ * renderer fed an unbounded sub-coder report sentence into the panel, and on
200
+ * `--resume` the same renderer paints session history, so the crash recurred
201
+ * even after v1.9.2 capped the *live* tracker). We respect the pi-supplied
202
+ * `width` here and truncate every line to `width - 2`, leaving a 2-char
203
+ * safety margin so wide unicode chars in the report can't sneak past our
204
+ * char-count-based visibleWidth approximation. */
205
+ export function makeComponent(lines: string[]) {
195
206
  return {
196
- render(_width: number): string[] {
197
- return lines;
207
+ render(width: number): string[] {
208
+ const cap = Math.max(1, width - 2);
209
+ return lines.map((l) => truncateLineToWidth(l, cap));
198
210
  },
199
211
  invalidate() {},
200
212
  };
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { makeComponent } from "./index.ts";
3
+
4
+ // Live regression for issue #51 (and the #48 reopen — same root cause from a
5
+ // different code path). pi paints the dispatch tool-result panel with a 1-char
6
+ // background-color left margin + fill; any line we return wider than
7
+ // `width - 1` overflows pi-tui and crashes the session, including on
8
+ // `--resume` because pi re-renders saved tool results from session history.
9
+ //
10
+ // The user's crash log showed a 134-char sub-coder report sentence rendered
11
+ // at terminal width 133 → 135 > 133. This test drives makeComponent at the
12
+ // same width with the same shape and asserts no emitted line exceeds.
13
+
14
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
15
+ const visibleWidth = (s: string) => stripAnsi(s).length;
16
+
17
+ describe("issue #51 — dispatch renderResult doesn't overflow", () => {
18
+ it("caps a wide sub-coder report line to fit the pi-supplied width", () => {
19
+ const wideSentence =
20
+ "There is **no `rate_limits` table**. The entire file defines a single class, `ConversationStore`, which manages only one SQLite table:";
21
+ // Sanity: this is the exact 134-char shape from the user's crash log.
22
+ expect(wideSentence.length).toBeGreaterThan(133);
23
+ const comp = makeComponent([
24
+ "✓ Storage schema",
25
+ "**Report: `bot/storage.py` Schema Analysis**",
26
+ "",
27
+ wideSentence,
28
+ "",
29
+ " …",
30
+ "(Ctrl+O to expand)",
31
+ ]);
32
+ const out = comp.render(133);
33
+ const max = Math.max(...out.map((l) => visibleWidth(l)));
34
+ expect(max).toBeLessThanOrEqual(133);
35
+ // The truncated wide sentence keeps its prefix verbatim — it's not blanked
36
+ // out, just clipped with an ellipsis so the user can still read most of it.
37
+ const truncated = out[3];
38
+ expect(stripAnsi(truncated).startsWith("There is **no")).toBe(true);
39
+ });
40
+
41
+ it("survives a narrow terminal (40 cols) without throwing", () => {
42
+ const comp = makeComponent([
43
+ "very long content " + "x".repeat(500),
44
+ "another long line " + "y".repeat(200),
45
+ ]);
46
+ const out = comp.render(40);
47
+ expect(Math.max(...out.map((l) => visibleWidth(l)))).toBeLessThanOrEqual(40);
48
+ });
49
+
50
+ it("preserves short lines unchanged", () => {
51
+ const comp = makeComponent(["short", "tiny"]);
52
+ expect(comp.render(133)).toEqual(["short", "tiny"]);
53
+ });
54
+ });
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
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.9.4] — 2026-06-18
6
+
7
+ ### Fixed
8
+ - **Dispatch tool-result panel overflows the terminal on wide report lines** ([#51](https://github.com/itayinbarr/little-coder/issues/51), reopen of [#48](https://github.com/itayinbarr/little-coder/issues/48)). v1.9.2 capped every line the *live* sub-coder tracker emitted, but the **dispatch tool's result renderer** (`subagent/index.ts`'s `makeComponent`) was still ignoring the `width` arg pi passes to `render(width)` — it returned the precomputed lines verbatim. pi paints the tool-result panel with a 1-char background-color left margin, so any sub-coder report sentence wider than `terminal_width - 1` overflowed pi-tui. Crash log line 453 was a 134-char markdown sentence rendered at terminal width 133 → 135 > 133. The same path runs on **`--resume`** (pi re-paints saved tool results from session history), so v1.9.2 users still hit it after upgrading whenever they resumed a session with a wide dispatch report saved — that's why @steverhoades caught the regression. `makeComponent` now truncates every emitted line to `width - 2` using the existing `_shared/width.ts` utility (2-char safety margin for wide unicode under our char-count-based `visibleWidth` approximation), so the dispatch panel can no longer crash a session — live, on resume, or anywhere else. New `subagent/issue-51-repro.test.ts` drives `makeComponent` with the user's exact 134-char content shape at width 133 and asserts no emitted line exceeds, plus a narrow-terminal (40-col) survival check.
9
+
10
+ ### Notes for upgraders
11
+ - No CLI-flag or public-API changes. If you saw `Rendered line N exceeds terminal width` on v1.9.2 / 1.9.3 — especially while *resuming* a session — 1.9.4 fixes it. If you still see it after upgrading, the offending line in `~/.pi/agent/pi-crash.log` should let us spot the source; reopen #51 or #48 with the log attached.
12
+
13
+ ---
14
+
15
+ ## [v1.9.3] — 2026-06-18
16
+
17
+ ### Added
18
+ - **`LITTLE_CODER_EXTRA_EXTENSIONS` env var: layer third-party pi extensions onto the bundled set without forking the installed package** ([#46](https://github.com/itayinbarr/little-coder/issues/46)). Path-delimited list (`:` on POSIX, `;` on Windows — `node:path.delimiter`) of extension paths. Each entry can be a direct file (e.g. a `pi-ponytail`-style `extensions/ponytail.js`) or a directory containing `index.ts` / `index.js` (the launcher prefers `.ts`). A leading `~/` is expanded; missing paths log a one-line warning to stderr and are skipped (a typo in the env var doesn't kill the session). Survives upgrades — drop the env var into your shell rc once and every `little-coder` run picks up the extras. Example: `LITTLE_CODER_EXTRA_EXTENSIONS=~/.local/lib/node_modules/pi-ponytail/extensions/ponytail.js little-coder`. Parsing rules live in `bin/extras.mjs` so they're unit-testable in isolation (9 cases covering direct-file / dir-index-resolution / `index.ts`-preference / missing-path warning / `~/` expansion / multiple entries / whitespace trimming). The launcher-level integration is exercised end-to-end (warning prints for a bad path; valid paths pass through silently to pi as `--extension <entry>` flags). Closest siblings — third-party skill bundles — are not yet covered; `skill-inject` still discovers only `<pkgRoot>/skills/tools/*.md`, and a follow-up will add the same kind of override.
19
+
20
+ ### Notes for upgraders
21
+ - No CLI-flag or public-API changes. The new env var is opt-in: unset = identical behavior to v1.9.2. If you were carrying a custom wrapper extension inside the installed npm package (which gets wiped on upgrade), you can drop it and use the env var instead.
22
+
23
+ ---
24
+
5
25
  ## [v1.9.2] — 2026-06-18
6
26
 
7
27
  ### Fixed
package/README.md CHANGED
@@ -67,6 +67,7 @@ The agent uses the directory you launched it from as its working directory — `
67
67
  - **Sub-coders (`dispatch`)** — little-coder can spawn isolated child sessions to research a question (read the repo + browse online, read-only) and report back concisely, without cluttering the main conversation. A live panel above the input tracks them. Tune parallelism with `LITTLE_CODER_SUBCODER_CONCURRENCY` (default 2).
68
68
  - **Sessions** — each session is auto-named from your first prompt (rename with `/name`) and shown in the terminal tab title. Use `/resume` to list and reopen past sessions for the current directory.
69
69
  - **Read-before-edit** — editing a file requires reading it first, so edits match the file's exact current text.
70
+ - **Third-party extensions (`LITTLE_CODER_EXTRA_EXTENSIONS`)** — path-delimited list (`:` on POSIX, `;` on Windows) of extension paths to layer on top of the bundled set. Each entry can be a direct file (e.g. a `pi-ponytail`-style `extensions/ponytail.js`) or a directory containing `index.ts` / `index.js`. `~/` is expanded; missing paths log a warning and are skipped. Survives upgrades, no patching the installed package. Example: `LITTLE_CODER_EXTRA_EXTENSIONS=~/.local/lib/node_modules/pi-ponytail/extensions/ponytail.js little-coder`. (Single-file extensions can still use `little-coder -e <path>` for one-off loads.)
70
71
 
71
72
  For local providers (llama.cpp, Ollama, LM Studio) pi expects *some* value in the API-key env even though local servers ignore it:
72
73
 
package/bin/extras.mjs ADDED
@@ -0,0 +1,56 @@
1
+ // Helpers for layering third-party pi extensions onto little-coder's bundled
2
+ // set. Extracted from the launcher so the parsing rules — path-delimited list,
3
+ // `~/` expansion, directory-with-index resolution, missing-path warning — are
4
+ // directly unit-testable without spawning the whole CLI.
5
+
6
+ import { existsSync, statSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { delimiter, join } from "node:path";
9
+
10
+ // Given the value of LITTLE_CODER_EXTRA_EXTENSIONS (path-delimited list of
11
+ // extension paths), return the resolved entry files that should be passed to pi
12
+ // as `--extension <entry>` flags. Skips empty segments, expands a leading `~/`,
13
+ // resolves a directory entry to its `index.ts` (preferred) or `index.js`, and
14
+ // records a one-line warning for each missing/unusable path so a typo in the
15
+ // env var doesn't kill the session — it just doesn't load that extension.
16
+ export function parseExtraExtensions(
17
+ envValue,
18
+ { home = homedir(), exists = existsSync, stat = statSync } = {},
19
+ ) {
20
+ const entries = [];
21
+ const warnings = [];
22
+ for (const raw of String(envValue ?? "").split(delimiter)) {
23
+ const trimmed = raw.trim();
24
+ if (!trimmed) continue;
25
+ const expanded = trimmed === "~"
26
+ ? home
27
+ : trimmed.startsWith("~/")
28
+ ? home + trimmed.slice(1)
29
+ : trimmed;
30
+ if (!exists(expanded)) {
31
+ warnings.push(
32
+ `little-coder: LITTLE_CODER_EXTRA_EXTENSIONS path not found, skipping: ${expanded}`,
33
+ );
34
+ continue;
35
+ }
36
+ let entry = expanded;
37
+ try {
38
+ if (stat(expanded).isDirectory()) {
39
+ const candidates = [join(expanded, "index.ts"), join(expanded, "index.js")];
40
+ const found = candidates.find((p) => exists(p));
41
+ if (!found) {
42
+ warnings.push(
43
+ `little-coder: LITTLE_CODER_EXTRA_EXTENSIONS dir has no index.ts/index.js, skipping: ${expanded}`,
44
+ );
45
+ continue;
46
+ }
47
+ entry = found;
48
+ }
49
+ } catch {
50
+ // unreadable / racing stat — skip silently
51
+ continue;
52
+ }
53
+ entries.push(entry);
54
+ }
55
+ return { entries, warnings };
56
+ }
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { delimiter, join } from "node:path";
5
+ import { parseExtraExtensions } from "./extras.mjs";
6
+
7
+ function setupTmp() {
8
+ return mkdtempSync(join(tmpdir(), "lc-extras-"));
9
+ }
10
+
11
+ describe("parseExtraExtensions", () => {
12
+ it("returns no entries when env is unset / empty", () => {
13
+ expect(parseExtraExtensions(undefined).entries).toEqual([]);
14
+ expect(parseExtraExtensions("").entries).toEqual([]);
15
+ expect(parseExtraExtensions(delimiter + delimiter).entries).toEqual([]);
16
+ });
17
+
18
+ it("forwards a direct file path verbatim", () => {
19
+ const dir = setupTmp();
20
+ try {
21
+ const file = join(dir, "ponytail.js");
22
+ writeFileSync(file, "export default function(){}");
23
+ const { entries, warnings } = parseExtraExtensions(file);
24
+ expect(entries).toEqual([file]);
25
+ expect(warnings).toEqual([]);
26
+ } finally {
27
+ rmSync(dir, { recursive: true });
28
+ }
29
+ });
30
+
31
+ it("resolves a directory entry to its index.ts (preferred)", () => {
32
+ const dir = setupTmp();
33
+ try {
34
+ const extDir = join(dir, "ponytail");
35
+ mkdirSync(extDir);
36
+ writeFileSync(join(extDir, "index.ts"), "");
37
+ writeFileSync(join(extDir, "index.js"), "");
38
+ const { entries } = parseExtraExtensions(extDir);
39
+ expect(entries).toEqual([join(extDir, "index.ts")]);
40
+ } finally {
41
+ rmSync(dir, { recursive: true });
42
+ }
43
+ });
44
+
45
+ it("falls back to index.js when index.ts is absent", () => {
46
+ const dir = setupTmp();
47
+ try {
48
+ const extDir = join(dir, "ponytail");
49
+ mkdirSync(extDir);
50
+ writeFileSync(join(extDir, "index.js"), "");
51
+ const { entries } = parseExtraExtensions(extDir);
52
+ expect(entries).toEqual([join(extDir, "index.js")]);
53
+ } finally {
54
+ rmSync(dir, { recursive: true });
55
+ }
56
+ });
57
+
58
+ it("warns and skips a directory without index.ts/index.js", () => {
59
+ const dir = setupTmp();
60
+ try {
61
+ const extDir = join(dir, "empty");
62
+ mkdirSync(extDir);
63
+ const { entries, warnings } = parseExtraExtensions(extDir);
64
+ expect(entries).toEqual([]);
65
+ expect(warnings).toHaveLength(1);
66
+ expect(warnings[0]).toContain("no index.ts/index.js");
67
+ } finally {
68
+ rmSync(dir, { recursive: true });
69
+ }
70
+ });
71
+
72
+ it("warns and skips a missing path (typo doesn't kill the session)", () => {
73
+ const { entries, warnings } = parseExtraExtensions("/tmp/does-not-exist-zzz-xyz-123");
74
+ expect(entries).toEqual([]);
75
+ expect(warnings).toHaveLength(1);
76
+ expect(warnings[0]).toContain("path not found");
77
+ });
78
+
79
+ it("expands a leading ~/ using the supplied home", () => {
80
+ const dir = setupTmp();
81
+ try {
82
+ const extDir = join(dir, "fake-home", "ext");
83
+ mkdirSync(extDir, { recursive: true });
84
+ writeFileSync(join(extDir, "index.js"), "");
85
+ const { entries } = parseExtraExtensions("~/ext", {
86
+ home: join(dir, "fake-home"),
87
+ });
88
+ expect(entries).toEqual([join(extDir, "index.js")]);
89
+ } finally {
90
+ rmSync(dir, { recursive: true });
91
+ }
92
+ });
93
+
94
+ it("layers multiple extensions from one path-delimited list", () => {
95
+ const dir = setupTmp();
96
+ try {
97
+ const a = join(dir, "a.js");
98
+ writeFileSync(a, "");
99
+ const b = join(dir, "b.js");
100
+ writeFileSync(b, "");
101
+ const { entries } = parseExtraExtensions([a, b].join(delimiter));
102
+ expect(entries).toEqual([a, b]);
103
+ } finally {
104
+ rmSync(dir, { recursive: true });
105
+ }
106
+ });
107
+
108
+ it("trims whitespace around list entries", () => {
109
+ const dir = setupTmp();
110
+ try {
111
+ const a = join(dir, "a.js");
112
+ writeFileSync(a, "");
113
+ const { entries } = parseExtraExtensions(` ${a} `);
114
+ expect(entries).toEqual([a]);
115
+ } finally {
116
+ rmSync(dir, { recursive: true });
117
+ }
118
+ });
119
+ });
@@ -17,6 +17,7 @@ import { homedir } from "node:os";
17
17
  import { dirname, join, resolve } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { checkForUpdate } from "./update-check.mjs";
20
+ import { parseExtraExtensions } from "./extras.mjs";
20
21
 
21
22
  // ---- 1. Node version preflight (>= 22.19.0, matching pi.dev) ----
22
23
  const MIN_NODE = [22, 19, 0];
@@ -110,6 +111,21 @@ if (existsSync(extDir)) {
110
111
  }
111
112
  }
112
113
 
114
+ // ---- 4b. Third-party extensions via LITTLE_CODER_EXTRA_EXTENSIONS ----
115
+ // Path-delimited list (`:` on POSIX, `;` on Windows — node:path.delimiter)
116
+ // of extra extension paths to load alongside the bundled ones. Each entry can
117
+ // be either a direct file path (e.g. a pi-ponytail-style `extensions/ponytail.js`)
118
+ // or a directory containing `index.ts` / `index.js`. Survives upgrades and
119
+ // avoids the "fork the installed npm package" workaround that issue #46 hit.
120
+ // Parsing rules — ~/ expansion, directory-with-index resolution, one-line
121
+ // warning for missing/unusable entries — live in ./extras.mjs so they're
122
+ // unit-testable in isolation.
123
+ {
124
+ const { entries, warnings } = parseExtraExtensions(process.env.LITTLE_CODER_EXTRA_EXTENSIONS);
125
+ for (const w of warnings) console.error(w);
126
+ for (const entry of entries) extArgs.push("--extension", entry);
127
+ }
128
+
113
129
  // ---- 5. Update check (best-effort, blocks on TTY prompt only) ----
114
130
  let currentVersion = "0.0.0";
115
131
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "little-coder",
3
- "version": "1.9.2",
3
+ "version": "1.9.4",
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": {