little-coder 1.9.4 → 1.9.6
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,7 +8,6 @@ import {
|
|
|
8
8
|
type SubCoderResult,
|
|
9
9
|
} from "./spawn.ts";
|
|
10
10
|
import { SubCoderTracker } from "./tracker.ts";
|
|
11
|
-
import { truncateLineToWidth } from "../_shared/width.ts";
|
|
12
11
|
|
|
13
12
|
// The `dispatch` tool: the main little-coder spawns isolated child little-coder
|
|
14
13
|
// sessions ("sub-coders") to research a focused question — they read the repo
|
|
@@ -191,22 +190,106 @@ export default function (pi: ExtensionAPI) {
|
|
|
191
190
|
});
|
|
192
191
|
}
|
|
193
192
|
|
|
194
|
-
/**
|
|
193
|
+
/**
|
|
194
|
+
* Sanitize and wrap lines for the dispatch tool-result panel.
|
|
195
195
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
196
|
+
* Two problems we solve:
|
|
197
|
+
* 1. Long whitespace-free tokens (URLs, file paths, base64) are broken up
|
|
198
|
+
* so word-wrap has somewhere to split — follows openclaw-cn's
|
|
199
|
+
* tui-formatters.ts sanitizer (commit 8c822da).
|
|
200
|
+
* 2. Each line is wrapped to the pi-supplied width so no rendered line
|
|
201
|
+
* exceeds the terminal — the failure mode of issues #48 / #51 (a 134-char
|
|
202
|
+
* sub-coder report sentence + pi's 1-char panel left margin overflowed
|
|
203
|
+
* pi-tui's strict line-width check, including on `--resume` because the
|
|
204
|
+
* same renderer paints session history).
|
|
205
|
+
*
|
|
206
|
+
* Word-wrap was contributed by @steverhoades in PR #49; v1.9.5 cherry-picked
|
|
207
|
+
* it onto v1.9.4 because wrapping is a strictly better UX for markdown
|
|
208
|
+
* report bodies than the truncate-with-ellipsis we shipped in v1.9.4 — the
|
|
209
|
+
* user sees the whole sentence across multiple lines instead of a cut-off
|
|
210
|
+
* tail. The 2-char safety margin (`width - 2`) survives wide-unicode chars
|
|
211
|
+
* our char-count-based stripAnsi/length undercounts, and absorbs pi's panel
|
|
212
|
+
* frame margin so the rendered output still fits.
|
|
213
|
+
*
|
|
214
|
+
* pi-tui's own visibleWidth / truncateToWidth aren't importable here (pi
|
|
215
|
+
* 0.79 stopped hoisting pi-tui for extensions), so the ANSI-aware helpers
|
|
216
|
+
* are inlined.
|
|
217
|
+
*/
|
|
218
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
219
|
+
const MAX_TOKEN_CHARS = 32;
|
|
220
|
+
const LONG_TOKEN_RE = /\S{33,}/g;
|
|
221
|
+
|
|
222
|
+
function chunkToken(token: string): string[] {
|
|
223
|
+
const chunks: string[] = [];
|
|
224
|
+
for (let i = 0; i < token.length; i += MAX_TOKEN_CHARS) {
|
|
225
|
+
chunks.push(token.slice(i, i + MAX_TOKEN_CHARS));
|
|
226
|
+
}
|
|
227
|
+
return chunks;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sanitizeLongTokens(text: string): string {
|
|
231
|
+
return LONG_TOKEN_RE.test(text)
|
|
232
|
+
? text.replace(LONG_TOKEN_RE, (token) => chunkToken(token).join(" "))
|
|
233
|
+
: text;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Extract leading ANSI SGR codes so wrapped lines can re-apply them. */
|
|
237
|
+
function extractAnsiPrefix(text: string): { prefix: string; rest: string } {
|
|
238
|
+
let end = 0;
|
|
239
|
+
while (end < text.length && text.slice(end, end + 2) === "\x1b[") {
|
|
240
|
+
const mPos = text.indexOf("m", end + 2);
|
|
241
|
+
if (mPos === -1) break;
|
|
242
|
+
end = mPos + 1;
|
|
243
|
+
}
|
|
244
|
+
return { prefix: text.slice(0, end), rest: text.slice(end) };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function stripAnsi(text: string): string {
|
|
248
|
+
return text.replace(ANSI_RE, "");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Word-wrap plain text at whitespace; assumes long tokens are pre-chunked. */
|
|
252
|
+
function wrapPlainText(text: string, maxWidth: number): string[] {
|
|
253
|
+
if (text.length <= maxWidth) return [text];
|
|
254
|
+
const words = text.split(/\s+/);
|
|
255
|
+
const result: string[] = [];
|
|
256
|
+
let current = "";
|
|
257
|
+
for (const word of words) {
|
|
258
|
+
if (!word) continue;
|
|
259
|
+
if (current.length === 0) {
|
|
260
|
+
current = word;
|
|
261
|
+
} else if (current.length + 1 + word.length <= maxWidth) {
|
|
262
|
+
current += " " + word;
|
|
263
|
+
} else {
|
|
264
|
+
result.push(current);
|
|
265
|
+
current = word;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (current) result.push(current);
|
|
269
|
+
return result.length > 0 ? result : [text];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Wrap one ANSI-aware line to width, re-applying any leading SGR prefix. */
|
|
273
|
+
function wrapLine(line: string, width: number): string[] {
|
|
274
|
+
const plain = stripAnsi(line);
|
|
275
|
+
if (plain.length <= width) return [line];
|
|
276
|
+
const { prefix } = extractAnsiPrefix(line);
|
|
277
|
+
const wrappedLines = wrapPlainText(plain, width);
|
|
278
|
+
return wrappedLines.map((l) => prefix + l);
|
|
279
|
+
}
|
|
280
|
+
|
|
205
281
|
export function makeComponent(lines: string[]) {
|
|
206
282
|
return {
|
|
207
283
|
render(width: number): string[] {
|
|
208
284
|
const cap = Math.max(1, width - 2);
|
|
209
|
-
|
|
285
|
+
const output: string[] = [];
|
|
286
|
+
for (const line of lines) {
|
|
287
|
+
const sanitized = sanitizeLongTokens(line);
|
|
288
|
+
for (const wrapped of wrapLine(sanitized, cap)) {
|
|
289
|
+
output.push(wrapped);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return output;
|
|
210
293
|
},
|
|
211
294
|
invalidate() {},
|
|
212
295
|
};
|
|
@@ -8,14 +8,20 @@ import { makeComponent } from "./index.ts";
|
|
|
8
8
|
// `--resume` because pi re-renders saved tool results from session history.
|
|
9
9
|
//
|
|
10
10
|
// The user's crash log showed a 134-char sub-coder report sentence rendered
|
|
11
|
-
// at terminal width 133 → 135 > 133.
|
|
12
|
-
//
|
|
11
|
+
// at terminal width 133 → 135 > 133.
|
|
12
|
+
//
|
|
13
|
+
// v1.9.4 fixed this by truncating to width-2. v1.9.5 (PR #49 by
|
|
14
|
+
// @steverhoades) replaced the truncation with **word-wrap**: a 134-char
|
|
15
|
+
// sentence becomes two visual lines that together preserve the full
|
|
16
|
+
// sentence, instead of dropping the tail. Both behaviors satisfy the
|
|
17
|
+
// width invariant — this test asserts (a) no emitted line exceeds and (b)
|
|
18
|
+
// the wide content is preserved across the wrapped lines (no data loss).
|
|
13
19
|
|
|
14
20
|
const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
|
15
21
|
const visibleWidth = (s: string) => stripAnsi(s).length;
|
|
16
22
|
|
|
17
23
|
describe("issue #51 — dispatch renderResult doesn't overflow", () => {
|
|
18
|
-
it("
|
|
24
|
+
it("wraps a wide sub-coder report line to fit the pi-supplied width", () => {
|
|
19
25
|
const wideSentence =
|
|
20
26
|
"There is **no `rate_limits` table**. The entire file defines a single class, `ConversationStore`, which manages only one SQLite table:";
|
|
21
27
|
// Sanity: this is the exact 134-char shape from the user's crash log.
|
|
@@ -30,12 +36,23 @@ describe("issue #51 — dispatch renderResult doesn't overflow", () => {
|
|
|
30
36
|
"(Ctrl+O to expand)",
|
|
31
37
|
]);
|
|
32
38
|
const out = comp.render(133);
|
|
39
|
+
// Width invariant: no emitted line exceeds the pi-supplied width.
|
|
33
40
|
const max = Math.max(...out.map((l) => visibleWidth(l)));
|
|
34
41
|
expect(max).toBeLessThanOrEqual(133);
|
|
35
|
-
// The
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
// The wide sentence wraps but is preserved: rejoining the wrapped lines
|
|
43
|
+
// (collapsing whitespace) reproduces the original prose verbatim. That's
|
|
44
|
+
// the user-visible win over v1.9.4's truncate-with-ellipsis.
|
|
45
|
+
const wrappedRoundtrip = out
|
|
46
|
+
.map((l) => stripAnsi(l))
|
|
47
|
+
.join(" ")
|
|
48
|
+
.replace(/\s+/g, " ")
|
|
49
|
+
.trim();
|
|
50
|
+
expect(wrappedRoundtrip).toContain(
|
|
51
|
+
"There is **no `rate_limits` table**. The entire file defines a single class, `ConversationStore`, which manages only one SQLite table:",
|
|
52
|
+
);
|
|
53
|
+
// And it's wrapped, not truncated — there are more emitted lines than
|
|
54
|
+
// input lines because the long sentence split.
|
|
55
|
+
expect(out.length).toBeGreaterThan(7);
|
|
39
56
|
});
|
|
40
57
|
|
|
41
58
|
it("survives a narrow terminal (40 cols) without throwing", () => {
|
|
@@ -51,4 +68,13 @@ describe("issue #51 — dispatch renderResult doesn't overflow", () => {
|
|
|
51
68
|
const comp = makeComponent(["short", "tiny"]);
|
|
52
69
|
expect(comp.render(133)).toEqual(["short", "tiny"]);
|
|
53
70
|
});
|
|
71
|
+
|
|
72
|
+
it("chunks long whitespace-free tokens (URLs/paths) so wrapping has room to split", () => {
|
|
73
|
+
// A 200-char URL-ish token has no spaces; without sanitizeLongTokens it
|
|
74
|
+
// would defeat word-wrap and overflow at any narrow width.
|
|
75
|
+
const url = "https://example.com/" + "a".repeat(200);
|
|
76
|
+
const comp = makeComponent([url]);
|
|
77
|
+
const out = comp.render(60);
|
|
78
|
+
expect(Math.max(...out.map((l) => visibleWidth(l)))).toBeLessThanOrEqual(60);
|
|
79
|
+
});
|
|
54
80
|
});
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
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.6] — 2026-06-18
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- **Auto-update silently failed on Windows** ([PR #52](https://github.com/itayinbarr/little-coder/pull/52) by [@i-snyder](https://github.com/i-snyder)). On Windows `npm` is `npm.cmd` (a batch-file shim), and `spawnSync("npm", …)` without `shell: true` returns `ENOENT` before npm ever launches — the user saw `✗ Update failed (npm exit null). Continuing with v1.9.x.`, where the `null` exit code was the tell that npm never ran. The launcher now invokes `npm` via `process.env.COMSPEC /c npm` on Windows (`shell: true` would also work but triggers Node 24+'s `DEP0190` deprecation warning; COMSPEC doesn't). Cross-platform behavior unchanged: POSIX still uses plain `spawnSync("npm", …)`. The failure message is also fixed — when the spawn itself fails (`result.error` is set), the launcher now surfaces `result.error.code` (e.g. `ENOENT`) instead of `result.status` (which is `null` and meaningless), so users diagnosing future spawn failures get an actionable code instead of `npm exit null`.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **`little-coder --update` forces a fresh update check** ([PR #52](https://github.com/itayinbarr/little-coder/pull/52) by [@i-snyder](https://github.com/i-snyder)). The launcher caches the registry "latest" lookup for 12 hours; `--update` bypasses that cache and fetches fresh from npm, then either updates or prints `✓ little-coder is already up to date (v<x>)`. The flag is stripped from argv before forwarding to pi, so `little-coder --update` no longer errors with `Unknown option: --update`. Two new `shouldSkip` tests document the interaction (the flag forces a check; the notice-only mode still applies on non-TTY).
|
|
12
|
+
|
|
13
|
+
### Notes for upgraders
|
|
14
|
+
- No CLI-flag or public-API breakage. Windows users on v1.9.5 or earlier should manually `npm install -g little-coder@1.9.6` this once; future updates will work via the in-app prompt or `little-coder --update`.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## [v1.9.5] — 2026-06-18
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- **Dispatch tool-result panel now word-wraps wide report lines instead of truncating them** ([PR #49](https://github.com/itayinbarr/little-coder/pull/49) by [@steverhoades](https://github.com/steverhoades), closes [#48](https://github.com/itayinbarr/little-coder/issues/48) and [#51](https://github.com/itayinbarr/little-coder/issues/51)). v1.9.4 fixed the width-overflow crash by truncating each panel line to `width - 2` with an ellipsis; v1.9.5 replaces the truncation with **word-wrap** so the full sentence survives across multiple visual lines — a strictly better UX for markdown sub-coder reports than dropping the tail at char 131. The cherry-picked commit (steverhoades's authorship preserved) keeps the wrap helpers (ANSI-aware prefix extraction, long-token chunking for whitespace-free URLs/paths/base64 that would otherwise defeat word-wrap, plain-text word-wrap), and the `makeComponent.render(width)` is rebased onto v1.9.4's `width - 2` safety margin so wide-unicode chars our char-count `visibleWidth` undercounts still can't sneak past pi's strict line-width check. Inspiration for the long-token sanitizer credited in-source to [openclaw-cn's tui-formatters.ts](https://github.com/mf-yang/openclaw-cn/commit/8c822da26f0a77396107a31f09df60817bf39c98). `issue-51-repro.test.ts` updated for wrap semantics (4 cases): no emitted line exceeds; the wrapped lines round-trip to the original 134-char sentence verbatim (no data loss); narrow terminal (40 cols) survives; 200-char URL-ish tokens get chunked so wrapping has room to split.
|
|
22
|
+
|
|
23
|
+
### Notes for upgraders
|
|
24
|
+
- No CLI-flag or public-API changes. If you upgraded from v1.9.3 → v1.9.4 → v1.9.5, the user-visible difference between the last two is just wrap-vs-truncate in the dispatch tool's expanded report panel — both eliminate the crash. If you saw an ellipsis at the right edge of a sub-coder report on v1.9.4, you'll now see the full sentence wrapped onto the next line instead.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
5
28
|
## [v1.9.4] — 2026-06-18
|
|
6
29
|
|
|
7
30
|
### Fixed
|
package/bin/little-coder.mjs
CHANGED
|
@@ -135,7 +135,8 @@ try {
|
|
|
135
135
|
// ignore — update-check just won't fire if we can't read the version
|
|
136
136
|
}
|
|
137
137
|
if (!isSubagent) {
|
|
138
|
-
const
|
|
138
|
+
const forceUpdate = process.argv.includes("--update");
|
|
139
|
+
const exitAfterCheck = await checkForUpdate(currentVersion, { force: forceUpdate });
|
|
139
140
|
if (exitAfterCheck) {
|
|
140
141
|
// Successful update happened; user needs to re-run the new binary.
|
|
141
142
|
process.exit(0);
|
|
@@ -148,7 +149,9 @@ if (!isSubagent) {
|
|
|
148
149
|
// --system-prompt : load <pkgRoot>/AGENTS.md regardless of cwd
|
|
149
150
|
//
|
|
150
151
|
// Strip our own flags before forwarding to pi so it doesn't reject them.
|
|
151
|
-
const userArgs = process.argv.slice(2).filter(
|
|
152
|
+
const userArgs = process.argv.slice(2).filter(
|
|
153
|
+
(a) => a !== "--no-update-check" && a !== "--update",
|
|
154
|
+
);
|
|
152
155
|
const agentsMd = join(pkgRoot, "AGENTS.md");
|
|
153
156
|
|
|
154
157
|
// Default the thinking level to "medium" for interactive sessions (pi's own
|
package/bin/update-check.mjs
CHANGED
|
@@ -123,17 +123,28 @@ function promptYesNo(question) {
|
|
|
123
123
|
// Returns `true` if the launcher should NOT proceed to spawn pi (because we
|
|
124
124
|
// updated and exited / the user opted out and we should re-run). Returns
|
|
125
125
|
// `false` to let the launcher continue.
|
|
126
|
+
//
|
|
127
|
+
// opts.force — bypass the 12h cache and always fetch the latest version from
|
|
128
|
+
// the registry. Used when the user explicitly passes `--update`. If already
|
|
129
|
+
// at the latest version, prints a short "up to date" notice instead of
|
|
130
|
+
// silently returning.
|
|
126
131
|
export async function checkForUpdate(currentVersion, opts = {}) {
|
|
127
132
|
const skip = opts.skip ?? shouldSkip();
|
|
128
133
|
if (skip === true) return false;
|
|
129
134
|
|
|
130
|
-
|
|
135
|
+
const force = opts.force ?? false;
|
|
136
|
+
let latest = (!force && readCache()?.latest) || null;
|
|
131
137
|
if (!latest) {
|
|
132
138
|
latest = await fetchLatest();
|
|
133
139
|
if (latest) writeCache(latest);
|
|
134
140
|
}
|
|
135
141
|
if (!latest) return false;
|
|
136
|
-
if (compareSemver(latest, currentVersion) <= 0)
|
|
142
|
+
if (compareSemver(latest, currentVersion) <= 0) {
|
|
143
|
+
if (force) {
|
|
144
|
+
process.stderr.write(`\n ✓ little-coder is already up to date (v${currentVersion}).\n\n`);
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
137
148
|
|
|
138
149
|
const headline =
|
|
139
150
|
`\n📦 little-coder v${latest} is available (you have v${currentVersion}).`;
|
|
@@ -151,17 +162,25 @@ export async function checkForUpdate(currentVersion, opts = {}) {
|
|
|
151
162
|
}
|
|
152
163
|
|
|
153
164
|
process.stderr.write(`\n Running: npm install -g little-coder@${latest}\n\n`);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
165
|
+
// On Windows `npm` resolves to `npm.cmd`, a batch-file shim that Node's
|
|
166
|
+
// spawnSync cannot execute without shell:true. However, shell:true with
|
|
167
|
+
// array args triggers DEP0190 on Node 24+. Instead, invoke cmd.exe directly
|
|
168
|
+
// via COMSPEC — it resolves `npm` to `npm.cmd` itself, no shell:true needed.
|
|
169
|
+
const npmArgs = ["install", "-g", `little-coder@${latest}`];
|
|
170
|
+
const result = process.platform === "win32"
|
|
171
|
+
? spawnSync(process.env.COMSPEC || "cmd.exe", ["/c", "npm", ...npmArgs], { stdio: "inherit" })
|
|
172
|
+
: spawnSync("npm", npmArgs, { stdio: "inherit" });
|
|
157
173
|
if (result.status === 0) {
|
|
158
174
|
process.stderr.write(
|
|
159
175
|
`\n ✓ Updated to v${latest}. Re-run \`little-coder\` to use the new version.\n\n`,
|
|
160
176
|
);
|
|
161
177
|
return true;
|
|
162
178
|
}
|
|
179
|
+
const exitDesc = result.error
|
|
180
|
+
? `could not launch npm (${result.error.code ?? result.error.message})`
|
|
181
|
+
: `npm exit ${result.status}`;
|
|
163
182
|
process.stderr.write(
|
|
164
|
-
`\n ✗ Update failed (
|
|
183
|
+
`\n ✗ Update failed (${exitDesc}). Continuing with v${currentVersion}.\n\n`,
|
|
165
184
|
);
|
|
166
185
|
return false;
|
|
167
186
|
}
|
|
@@ -161,4 +161,12 @@ describe("shouldSkip", () => {
|
|
|
161
161
|
it("returns notice-only on non-TTY pipelines", () => {
|
|
162
162
|
expect(shouldSkip([], noEnv, pipeStdout())).toBe("notice-only");
|
|
163
163
|
});
|
|
164
|
+
|
|
165
|
+
it("does not skip for --update (it forces the check, not skips it)", () => {
|
|
166
|
+
expect(shouldSkip(["--update"], noEnv, ttyStdout())).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("notice-only still applies with --update on non-TTY", () => {
|
|
170
|
+
expect(shouldSkip(["--update"], noEnv, pipeStdout())).toBe("notice-only");
|
|
171
|
+
});
|
|
164
172
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "little-coder",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.6",
|
|
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": {
|