little-coder 1.8.3 → 1.9.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.
- package/.pi/extensions/branding/branding.test.ts +42 -0
- package/.pi/extensions/branding/index.ts +56 -10
- package/.pi/extensions/extra-tools/glob.ts +3 -3
- package/.pi/extensions/extra-tools/index.ts +1 -1
- package/.pi/extensions/output-parser/index.ts +46 -16
- package/.pi/extensions/output-parser/parser.test.ts +123 -1
- package/.pi/extensions/output-parser/parser.ts +202 -0
- package/.pi/extensions/plan-mode/index.ts +377 -0
- package/.pi/extensions/plan-mode/plan-mode.test.ts +49 -0
- package/.pi/extensions/plan-mode/status.ts +79 -0
- package/.pi/extensions/prompt-history/index.ts +154 -0
- package/.pi/extensions/prompt-history/prompt-history.test.ts +72 -0
- package/.pi/extensions/read-guard-edit/index.ts +89 -0
- package/.pi/extensions/read-guard-edit/read-guard-edit.test.ts +100 -0
- package/.pi/extensions/skill-inject/index.ts +3 -0
- package/.pi/extensions/skill-inject/selector.test.ts +2 -2
- package/.pi/extensions/subagent/index.ts +201 -0
- package/.pi/extensions/subagent/live-spawn.test.ts +47 -0
- package/.pi/extensions/subagent/spawn.test.ts +97 -0
- package/.pi/extensions/subagent/spawn.ts +373 -0
- package/.pi/extensions/subagent/tracker.ts +139 -0
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +36 -0
- package/README.md +19 -3
- package/bin/little-coder.mjs +56 -5
- package/package.json +2 -2
- package/skills/tools/dispatch.md +38 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { makeHistory } from "./index.ts";
|
|
3
|
+
|
|
4
|
+
describe("prompt history recall", () => {
|
|
5
|
+
it("records prompts, skipping blanks and consecutive duplicates", () => {
|
|
6
|
+
const h = makeHistory();
|
|
7
|
+
h.add("first");
|
|
8
|
+
h.add(" "); // blank
|
|
9
|
+
h.add("second");
|
|
10
|
+
h.add("second"); // dup
|
|
11
|
+
expect(h.items).toEqual(["first", "second"]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("persists on add via the provided callback", () => {
|
|
15
|
+
const saved: string[][] = [];
|
|
16
|
+
const h = makeHistory([], (items) => saved.push([...items]));
|
|
17
|
+
h.add("one");
|
|
18
|
+
h.add("two");
|
|
19
|
+
expect(saved).toEqual([["one"], ["one", "two"]]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("seeds from initial (cross-session) history", () => {
|
|
23
|
+
const h = makeHistory(["older", "newer"]);
|
|
24
|
+
expect(h.recall("up", "")).toBe("newer");
|
|
25
|
+
expect(h.recall("up", "newer")).toBe("older");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("up from an empty prompt recalls most-recent-first and clamps", () => {
|
|
29
|
+
const h = makeHistory(["a", "b", "c"]);
|
|
30
|
+
expect(h.recall("up", "")).toBe("c");
|
|
31
|
+
expect(h.recall("up", "c")).toBe("b");
|
|
32
|
+
expect(h.recall("up", "b")).toBe("a");
|
|
33
|
+
expect(h.recall("up", "a")).toBe("a"); // clamps at oldest
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("down walks forward and clears past the newest", () => {
|
|
37
|
+
const h = makeHistory(["a", "b"]);
|
|
38
|
+
h.recall("up", ""); // → b
|
|
39
|
+
h.recall("up", "b"); // → a
|
|
40
|
+
expect(h.recall("down", "a")).toBe("b");
|
|
41
|
+
expect(h.recall("down", "b")).toBe(""); // past newest → empty
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("does NOT hijack up when the prompt is non-empty (editor handles it)", () => {
|
|
45
|
+
const h = makeHistory(["a"]);
|
|
46
|
+
expect(h.recall("up", "draft text")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("does nothing on up when there is no history", () => {
|
|
50
|
+
const h = makeHistory([]);
|
|
51
|
+
expect(h.recall("up", "")).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("down with no active navigation is a no-op (editor handles it)", () => {
|
|
55
|
+
const h = makeHistory(["a"]);
|
|
56
|
+
expect(h.recall("down", "")).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("reset() ends navigation so the next up starts fresh", () => {
|
|
60
|
+
const h = makeHistory(["a", "b"]);
|
|
61
|
+
h.recall("up", ""); // → b (navigating)
|
|
62
|
+
h.reset();
|
|
63
|
+
expect(h.recall("up", "b")).toBeNull(); // non-empty + not navigating → editor handles
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("caps stored history at the maximum", () => {
|
|
67
|
+
const many = Array.from({ length: 150 }, (_, i) => `p${i}`);
|
|
68
|
+
const h = makeHistory(many);
|
|
69
|
+
expect(h.items.length).toBe(100);
|
|
70
|
+
expect(h.items[0]).toBe("p50"); // kept the newest 100
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { normalizeWritePath } from "../write-guard/index.ts";
|
|
3
|
+
import { harnessIntervention } from "../_shared/intervention.ts";
|
|
4
|
+
|
|
5
|
+
// Read-before-edit guard.
|
|
6
|
+
//
|
|
7
|
+
// Small models routinely fire `edit` with an `oldText` they never actually saw
|
|
8
|
+
// — guessing at the current file contents — which either fails the exact-match
|
|
9
|
+
// requirement (wasting a turn) or, worse, matches the wrong span. Editors the
|
|
10
|
+
// user is used to (Claude Code et al.) enforce a simple invariant: a file must
|
|
11
|
+
// be Read before it can be Edited. We reproduce that here.
|
|
12
|
+
//
|
|
13
|
+
// Mechanism mirrors write-guard: we don't own pi's built-in `read`/`edit`
|
|
14
|
+
// tools, so we enforce at the event layer. We remember every file that was
|
|
15
|
+
// successfully `read` this session (`tool_result`, !isError), and block any
|
|
16
|
+
// `edit` whose target hasn't been read, redirecting the model to Read first.
|
|
17
|
+
//
|
|
18
|
+
// Why a separate extension from `read-guard`: read-guard trims an oversized
|
|
19
|
+
// read so it can't overflow a small context window — a different concern from
|
|
20
|
+
// the read-before-edit invariant. Keeping them apart keeps each single-purpose.
|
|
21
|
+
//
|
|
22
|
+
// A successful `edit` or `write` also marks the path as known: an edit only
|
|
23
|
+
// succeeds when the file was already read (we'd have blocked it otherwise), and
|
|
24
|
+
// a write means the model authored the file's contents, so a follow-up edit to
|
|
25
|
+
// either is legitimate without a re-read.
|
|
26
|
+
|
|
27
|
+
// Files read (or authored) in the current session. Module-scoped: one pi
|
|
28
|
+
// process drives one session at a time, and we clear on session_start.
|
|
29
|
+
export const readFiles = new Set<string>();
|
|
30
|
+
|
|
31
|
+
// pi's built-in tools use `path`; some prompts/older builds use `file_path`.
|
|
32
|
+
// Accept both so the guard is independent of which key the model emits.
|
|
33
|
+
export function resolveToolPath(
|
|
34
|
+
input: Record<string, unknown>,
|
|
35
|
+
cwd: string,
|
|
36
|
+
): string | undefined {
|
|
37
|
+
const raw =
|
|
38
|
+
typeof input.path === "string"
|
|
39
|
+
? input.path
|
|
40
|
+
: typeof input.file_path === "string"
|
|
41
|
+
? input.file_path
|
|
42
|
+
: undefined;
|
|
43
|
+
if (!raw) return undefined;
|
|
44
|
+
return normalizeWritePath(raw, cwd).path;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function editBeforeReadReason(resolved: string): string {
|
|
48
|
+
return (
|
|
49
|
+
`File must be read first before edit — ${resolved} has not been read in ` +
|
|
50
|
+
`this session.\n` +
|
|
51
|
+
`\n` +
|
|
52
|
+
`Read ${resolved} first to get the exact current text for oldText ` +
|
|
53
|
+
`(whitespace and indentation must match exactly), then issue the Edit. ` +
|
|
54
|
+
`Reading also lets you include enough surrounding context (2-3 lines) to ` +
|
|
55
|
+
`make oldText unique in the file. Do NOT guess the file's contents.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default function (pi: ExtensionAPI) {
|
|
60
|
+
// New session (startup, /clear, /resume, reload) is a clean slate — what was
|
|
61
|
+
// read in a previous session says nothing about the current one.
|
|
62
|
+
pi.on("session_start", async () => {
|
|
63
|
+
readFiles.clear();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Record successful reads (and authored files) as "known".
|
|
67
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
68
|
+
const name = String((event as any).toolName ?? "").toLowerCase();
|
|
69
|
+
if (name !== "read" && name !== "edit" && name !== "write") return;
|
|
70
|
+
if ((event as any).isError) return;
|
|
71
|
+
const p = resolveToolPath(((event as any).input ?? {}) as Record<string, unknown>, ctx.cwd);
|
|
72
|
+
if (p) readFiles.add(p);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Block edits to files that were never read.
|
|
76
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
77
|
+
if (String((event as any).toolName ?? "").toLowerCase() !== "edit") return;
|
|
78
|
+
const input = ((event as any).input ?? {}) as Record<string, unknown>;
|
|
79
|
+
const p = resolveToolPath(input, ctx.cwd);
|
|
80
|
+
if (!p) return; // no resolvable path — let the edit tool surface its own error
|
|
81
|
+
if (readFiles.has(p)) return; // already read this session — allow
|
|
82
|
+
|
|
83
|
+
harnessIntervention(
|
|
84
|
+
ctx,
|
|
85
|
+
"the model tried to edit a file it hadn't read — redirected it to Read first.",
|
|
86
|
+
);
|
|
87
|
+
return { block: true, reason: editBeforeReadReason(p) };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import setupReadGuardEdit, { readFiles, resolveToolPath } from "./index.ts";
|
|
3
|
+
|
|
4
|
+
describe("resolveToolPath", () => {
|
|
5
|
+
const cwd = "/home/me/proj";
|
|
6
|
+
it("resolves relative paths against cwd", () => {
|
|
7
|
+
expect(resolveToolPath({ path: "src/a.ts" }, cwd)).toBe("/home/me/proj/src/a.ts");
|
|
8
|
+
});
|
|
9
|
+
it("honors the file_path key", () => {
|
|
10
|
+
expect(resolveToolPath({ file_path: "b.ts" }, cwd)).toBe("/home/me/proj/b.ts");
|
|
11
|
+
});
|
|
12
|
+
it("returns undefined with no path", () => {
|
|
13
|
+
expect(resolveToolPath({ content: "x" }, cwd)).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Wire up the extension against a fake pi, capturing each registered handler.
|
|
18
|
+
function setup() {
|
|
19
|
+
const handlers: Record<string, (...args: any[]) => any> = {};
|
|
20
|
+
const pi = {
|
|
21
|
+
on(name: string, h: (...args: any[]) => any) {
|
|
22
|
+
handlers[name] = h;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
setupReadGuardEdit(pi as any);
|
|
26
|
+
return handlers;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeCtx(cwd: string) {
|
|
30
|
+
const notifies: string[] = [];
|
|
31
|
+
return { cwd, notifies, ui: { notify: (m: string) => notifies.push(m) } };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("read-before-edit guard", () => {
|
|
35
|
+
const cwd = "/home/me/proj";
|
|
36
|
+
beforeEach(() => readFiles.clear());
|
|
37
|
+
|
|
38
|
+
it("blocks an edit to a file that was never read", async () => {
|
|
39
|
+
const h = setup();
|
|
40
|
+
const ctx = makeCtx(cwd);
|
|
41
|
+
const result = await h.tool_call({ toolName: "edit", input: { path: "a.ts", edits: [] } }, ctx);
|
|
42
|
+
expect(result?.block).toBe(true);
|
|
43
|
+
expect(result.reason).toContain("must be read first");
|
|
44
|
+
expect(result.reason).toContain("/home/me/proj/a.ts");
|
|
45
|
+
expect(ctx.notifies[0]).toMatch(/harness intervention:.*Read first/i);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("allows an edit after the file was successfully read", async () => {
|
|
49
|
+
const h = setup();
|
|
50
|
+
const ctx = makeCtx(cwd);
|
|
51
|
+
await h.tool_result({ toolName: "read", isError: false, input: { path: "a.ts" } }, ctx);
|
|
52
|
+
const result = await h.tool_call({ toolName: "edit", input: { path: "a.ts", edits: [] } }, ctx);
|
|
53
|
+
expect(result).toBeUndefined();
|
|
54
|
+
expect(ctx.notifies).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("does not count a failed read as having read the file", async () => {
|
|
58
|
+
const h = setup();
|
|
59
|
+
const ctx = makeCtx(cwd);
|
|
60
|
+
await h.tool_result({ toolName: "read", isError: true, input: { path: "a.ts" } }, ctx);
|
|
61
|
+
const result = await h.tool_call({ toolName: "edit", input: { path: "a.ts" } }, ctx);
|
|
62
|
+
expect(result?.block).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("treats a freshly written file as known (write then edit is allowed)", async () => {
|
|
66
|
+
const h = setup();
|
|
67
|
+
const ctx = makeCtx(cwd);
|
|
68
|
+
await h.tool_result({ toolName: "write", isError: false, input: { path: "new.ts" } }, ctx);
|
|
69
|
+
const result = await h.tool_call({ toolName: "edit", input: { path: "new.ts" } }, ctx);
|
|
70
|
+
expect(result).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("clears known files on session_start", async () => {
|
|
74
|
+
const h = setup();
|
|
75
|
+
const ctx = makeCtx(cwd);
|
|
76
|
+
await h.tool_result({ toolName: "read", isError: false, input: { path: "a.ts" } }, ctx);
|
|
77
|
+
await h.session_start();
|
|
78
|
+
const result = await h.tool_call({ toolName: "edit", input: { path: "a.ts" } }, ctx);
|
|
79
|
+
expect(result?.block).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("matches reads and edits that use different path spellings for the same file", async () => {
|
|
83
|
+
const h = setup();
|
|
84
|
+
const ctx = makeCtx(cwd);
|
|
85
|
+
// read with cwd-relative, edit with the absolute form of the same file
|
|
86
|
+
await h.tool_result({ toolName: "read", isError: false, input: { path: "src/a.ts" } }, ctx);
|
|
87
|
+
const result = await h.tool_call(
|
|
88
|
+
{ toolName: "edit", input: { path: "/home/me/proj/src/a.ts" } },
|
|
89
|
+
ctx,
|
|
90
|
+
);
|
|
91
|
+
expect(result).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("ignores non-edit tool calls", async () => {
|
|
95
|
+
const h = setup();
|
|
96
|
+
const ctx = makeCtx(cwd);
|
|
97
|
+
const result = await h.tool_call({ toolName: "grep", input: { pattern: "x" } }, ctx);
|
|
98
|
+
expect(result).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -56,6 +56,9 @@ const INTENT_MAP: Record<string, string[]> = {
|
|
|
56
56
|
browse: ["BrowserNavigate", "BrowserExtract"],
|
|
57
57
|
page: ["BrowserExtract"],
|
|
58
58
|
click: ["BrowserClick"],
|
|
59
|
+
// Sub-coder delegation
|
|
60
|
+
delegate: ["dispatch"], dispatch: ["dispatch"], subagent: ["dispatch"],
|
|
61
|
+
investigate: ["dispatch"], parallel: ["dispatch"],
|
|
59
62
|
};
|
|
60
63
|
|
|
61
64
|
function skillsDir(): string {
|
|
@@ -60,10 +60,10 @@ describe("skills directory loads from repo", () => {
|
|
|
60
60
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
61
61
|
const toolsDir = join(here, "..", "..", "..", "skills", "tools");
|
|
62
62
|
|
|
63
|
-
it("exists and has
|
|
63
|
+
it("exists and has 14 markdown files", () => {
|
|
64
64
|
expect(existsSync(toolsDir)).toBe(true);
|
|
65
65
|
const files = readdirSync(toolsDir).filter((f) => f.endsWith(".md"));
|
|
66
|
-
expect(files.length).toBe(
|
|
66
|
+
expect(files.length).toBe(14);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
it("every tool skill has target_tool in frontmatter", () => {
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import {
|
|
4
|
+
runSubCoder,
|
|
5
|
+
runSubCodersConcurrent,
|
|
6
|
+
truncateReport,
|
|
7
|
+
type SubCoderItem,
|
|
8
|
+
type SubCoderResult,
|
|
9
|
+
} from "./spawn.ts";
|
|
10
|
+
import { SubCoderTracker } from "./tracker.ts";
|
|
11
|
+
|
|
12
|
+
// The `dispatch` tool: the main little-coder spawns isolated child little-coder
|
|
13
|
+
// sessions ("sub-coders") to research a focused question — they read the repo
|
|
14
|
+
// and browse online, then return a CONCISE report. The full child transcript
|
|
15
|
+
// lives in the tool's `details` (UI-only); only the short report enters the
|
|
16
|
+
// parent model's context. A live panel above the input tracks them while they
|
|
17
|
+
// run. See spawn.ts for the engine and the read-only constraints.
|
|
18
|
+
|
|
19
|
+
const MAX_PARALLEL = 4;
|
|
20
|
+
|
|
21
|
+
/** "provider/id" of the current model, so children run on the same backend. */
|
|
22
|
+
export function currentModelId(ctx: any): string | undefined {
|
|
23
|
+
const m = ctx?.model;
|
|
24
|
+
if (!m || typeof m.id !== "string") return undefined;
|
|
25
|
+
return m.provider ? `${m.provider}/${m.id}` : m.id;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A short label for a single-mode task (first few words). */
|
|
29
|
+
function shortLabel(task: string): string {
|
|
30
|
+
const words = task.trim().split(/\s+/).filter(Boolean).slice(0, 4).join(" ");
|
|
31
|
+
return words.length > 28 ? `${words.slice(0, 27)}…` : words || "sub-coder";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildContent(results: SubCoderResult[]): string {
|
|
35
|
+
if (results.length === 1) {
|
|
36
|
+
const r = results[0];
|
|
37
|
+
if (r.exitCode !== 0) return `Sub-coder "${r.label}" ${r.stopReason || "failed"}: ${r.errorMessage || "(no output)"}`;
|
|
38
|
+
return truncateReport(r.report) || "(no report)";
|
|
39
|
+
}
|
|
40
|
+
const ok = results.filter((r) => r.exitCode === 0).length;
|
|
41
|
+
const blocks = results.map((r) => {
|
|
42
|
+
const status = r.exitCode === 0 ? "" : ` [${r.stopReason || "failed"}]`;
|
|
43
|
+
const body = r.exitCode === 0 ? truncateReport(r.report) : r.errorMessage || "(no output)";
|
|
44
|
+
return `### ${r.label}${status}\n${body || "(no report)"}`;
|
|
45
|
+
});
|
|
46
|
+
return `${ok}/${results.length} sub-coders succeeded.\n\n${blocks.join("\n\n")}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function (pi: ExtensionAPI) {
|
|
50
|
+
pi.registerTool({
|
|
51
|
+
name: "dispatch",
|
|
52
|
+
label: "Dispatch sub-coder",
|
|
53
|
+
description: [
|
|
54
|
+
"Dispatch one or more isolated sub-coders to research a focused question.",
|
|
55
|
+
"Each runs in its own context window, can read the repo and browse online",
|
|
56
|
+
"(read, grep, glob, webfetch, websearch, browser, read-only bash) but CANNOT",
|
|
57
|
+
"edit or write files. Each returns a concise report. Use this to gather",
|
|
58
|
+
"information without cluttering your own context.",
|
|
59
|
+
"Single: { task }. Parallel: { tasks: [{ label, task }] } (max 4).",
|
|
60
|
+
].join(" "),
|
|
61
|
+
parameters: Type.Object({
|
|
62
|
+
task: Type.Optional(Type.String({ description: "A single research task (single mode)" })),
|
|
63
|
+
tasks: Type.Optional(
|
|
64
|
+
Type.Array(
|
|
65
|
+
Type.Object({
|
|
66
|
+
label: Type.String({ description: "Short name for this sub-coder (shown in the tracker)" }),
|
|
67
|
+
task: Type.String({ description: "The research task delegated to this sub-coder" }),
|
|
68
|
+
}),
|
|
69
|
+
{ description: `Up to ${MAX_PARALLEL} tasks to run in parallel` },
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the sub-coders (default: current)" })),
|
|
73
|
+
}),
|
|
74
|
+
|
|
75
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
76
|
+
const hasSingle = typeof params.task === "string" && params.task.trim().length > 0;
|
|
77
|
+
const hasTasks = Array.isArray(params.tasks) && params.tasks.length > 0;
|
|
78
|
+
if (hasSingle === hasTasks) {
|
|
79
|
+
return {
|
|
80
|
+
content: [{ type: "text", text: "Provide exactly one of `task` (single) or `tasks` (parallel)." }],
|
|
81
|
+
details: { results: [] },
|
|
82
|
+
isError: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (hasTasks && params.tasks!.length > MAX_PARALLEL) {
|
|
86
|
+
return {
|
|
87
|
+
content: [{ type: "text", text: `Too many parallel tasks (${params.tasks!.length}). Max is ${MAX_PARALLEL}.` }],
|
|
88
|
+
details: { results: [] },
|
|
89
|
+
isError: true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const cwd = params.cwd || ctx.cwd;
|
|
94
|
+
const items: SubCoderItem[] = hasSingle
|
|
95
|
+
? [{ id: "1", label: shortLabel(params.task!), task: params.task!, cwd }]
|
|
96
|
+
: params.tasks!.map((t, i) => ({ id: String(i + 1), label: t.label, task: t.task, cwd }));
|
|
97
|
+
|
|
98
|
+
const model = currentModelId(ctx);
|
|
99
|
+
const tracker = new SubCoderTracker(ctx as any);
|
|
100
|
+
tracker.begin(items.map((it) => ({ id: it.id, label: it.label })));
|
|
101
|
+
|
|
102
|
+
const streamToToolCard = (results: SubCoderResult[]) => {
|
|
103
|
+
if (!onUpdate) return;
|
|
104
|
+
const done = results.filter((r) => r.exitCode !== -1).length;
|
|
105
|
+
onUpdate({
|
|
106
|
+
content: [{ type: "text", text: `${done}/${results.length} sub-coders done…` }],
|
|
107
|
+
details: { results },
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
let results: SubCoderResult[];
|
|
112
|
+
try {
|
|
113
|
+
if (hasSingle) {
|
|
114
|
+
const r = await runSubCoder({
|
|
115
|
+
...items[0],
|
|
116
|
+
model,
|
|
117
|
+
signal,
|
|
118
|
+
onUpdate: (live) => {
|
|
119
|
+
tracker.update([live]);
|
|
120
|
+
streamToToolCard([live]);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
results = [r];
|
|
124
|
+
} else {
|
|
125
|
+
results = await runSubCodersConcurrent(items, {
|
|
126
|
+
model,
|
|
127
|
+
signal,
|
|
128
|
+
onUpdate: (all) => {
|
|
129
|
+
tracker.update(all);
|
|
130
|
+
streamToToolCard(all);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
} finally {
|
|
135
|
+
tracker.end();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const anyError = results.some((r) => r.exitCode !== 0);
|
|
139
|
+
const allError = results.every((r) => r.exitCode !== 0);
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: "text", text: buildContent(results) }],
|
|
142
|
+
details: { results },
|
|
143
|
+
isError: allError && anyError,
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
// Renderers return a duck-typed Component ({ render→lines, invalidate }).
|
|
148
|
+
// pi 0.79 stopped hoisting pi-tui, so we build colored lines via `theme`
|
|
149
|
+
// rather than importing pi-tui primitives (same approach as branding).
|
|
150
|
+
renderCall(args: any, theme: any) {
|
|
151
|
+
const lines: string[] = [];
|
|
152
|
+
const title = theme.fg("toolTitle", theme.bold("dispatch "));
|
|
153
|
+
if (Array.isArray(args.tasks) && args.tasks.length > 0) {
|
|
154
|
+
lines.push(title + theme.fg("accent", `${args.tasks.length} sub-coders`));
|
|
155
|
+
for (const t of args.tasks.slice(0, MAX_PARALLEL)) {
|
|
156
|
+
const preview = t.task.length > 48 ? `${t.task.slice(0, 48)}…` : t.task;
|
|
157
|
+
lines.push(` ${theme.fg("accent", t.label)}${theme.fg("dim", ` — ${preview}`)}`);
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
const task = String(args.task ?? "…");
|
|
161
|
+
lines.push(title + theme.fg("dim", task.length > 64 ? `${task.slice(0, 64)}…` : task));
|
|
162
|
+
}
|
|
163
|
+
return makeComponent(lines);
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
renderResult(result: any, options: any, theme: any) {
|
|
167
|
+
const expanded = !!options?.expanded;
|
|
168
|
+
const results: SubCoderResult[] = result?.details?.results ?? [];
|
|
169
|
+
if (results.length === 0) {
|
|
170
|
+
const t = result?.content?.[0];
|
|
171
|
+
return makeComponent([t?.type === "text" ? t.text : "(no output)"]);
|
|
172
|
+
}
|
|
173
|
+
const ok = results.filter((r) => r.exitCode === 0).length;
|
|
174
|
+
const lines: string[] = [
|
|
175
|
+
theme.fg("toolTitle", theme.bold("dispatch ")) + theme.fg("accent", `${ok}/${results.length} sub-coders`),
|
|
176
|
+
];
|
|
177
|
+
for (const r of results) {
|
|
178
|
+
const icon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
179
|
+
lines.push("");
|
|
180
|
+
lines.push(`${icon} ${theme.fg("accent", r.label)}`);
|
|
181
|
+
const body = r.exitCode === 0 ? r.report : r.errorMessage || r.stderr || "(no output)";
|
|
182
|
+
const bodyLines = body.trim() ? body.trim().split("\n") : ["(no output)"];
|
|
183
|
+
const shown = expanded ? bodyLines : bodyLines.slice(0, 4);
|
|
184
|
+
for (const bl of shown) lines.push(theme.fg("toolOutput", bl));
|
|
185
|
+
if (!expanded && bodyLines.length > shown.length) lines.push(theme.fg("muted", " …"));
|
|
186
|
+
}
|
|
187
|
+
if (!expanded) lines.push(theme.fg("muted", "(Ctrl+O to expand)"));
|
|
188
|
+
return makeComponent(lines);
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A minimal pi-tui Component backed by precomputed lines. */
|
|
194
|
+
function makeComponent(lines: string[]) {
|
|
195
|
+
return {
|
|
196
|
+
render(_width: number): string[] {
|
|
197
|
+
return lines;
|
|
198
|
+
},
|
|
199
|
+
invalidate() {},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { runSubCoder, runSubCodersConcurrent } from "./spawn.ts";
|
|
4
|
+
|
|
5
|
+
// Live end-to-end: spawns real child little-coder sessions against the local
|
|
6
|
+
// model. Skipped unless LC_LIVE=1 (needs a running model server). Run with:
|
|
7
|
+
// LC_LIVE=1 LLAMACPP_API_KEY=noop npx vitest run .pi/extensions/subagent/live-spawn.test.ts
|
|
8
|
+
const LIVE = process.env.LC_LIVE === "1";
|
|
9
|
+
const MODEL = process.env.LC_LIVE_MODEL || "llamacpp/qwen3.6-35b-a3b";
|
|
10
|
+
const repoRoot = resolve(__dirname, "..", "..", "..");
|
|
11
|
+
|
|
12
|
+
describe.skipIf(!LIVE)("sub-coder live spawn", () => {
|
|
13
|
+
it(
|
|
14
|
+
"spawns a child that runs on the parent's model and returns a report",
|
|
15
|
+
async () => {
|
|
16
|
+
const r = await runSubCoder({
|
|
17
|
+
id: "1",
|
|
18
|
+
label: "ping",
|
|
19
|
+
task: "Respond with exactly the single word ALIVE. Do not use any tools.",
|
|
20
|
+
cwd: repoRoot,
|
|
21
|
+
model: MODEL,
|
|
22
|
+
});
|
|
23
|
+
expect(r.exitCode).toBe(0);
|
|
24
|
+
expect(r.report.toUpperCase()).toContain("ALIVE");
|
|
25
|
+
expect(r.usage.turns).toBeGreaterThan(0);
|
|
26
|
+
},
|
|
27
|
+
240_000,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
it(
|
|
31
|
+
"runs two sub-coders in parallel and reports per-item",
|
|
32
|
+
async () => {
|
|
33
|
+
const results = await runSubCodersConcurrent(
|
|
34
|
+
[
|
|
35
|
+
{ id: "1", label: "two", task: "What is 2+2? Reply with just the number.", cwd: repoRoot },
|
|
36
|
+
{ id: "2", label: "cap", task: "What is the capital of France? One word.", cwd: repoRoot },
|
|
37
|
+
],
|
|
38
|
+
{ model: MODEL },
|
|
39
|
+
);
|
|
40
|
+
expect(results).toHaveLength(2);
|
|
41
|
+
expect(results.every((r) => r.exitCode === 0)).toBe(true);
|
|
42
|
+
expect(results[0].report).toContain("4");
|
|
43
|
+
expect(results[1].report.toLowerCase()).toContain("paris");
|
|
44
|
+
},
|
|
45
|
+
300_000,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildChildEnv,
|
|
4
|
+
defaultConcurrency,
|
|
5
|
+
getFinalText,
|
|
6
|
+
resolveLauncher,
|
|
7
|
+
summarizeActivity,
|
|
8
|
+
truncateReport,
|
|
9
|
+
SUBCODER_ALLOWED_TOOLS,
|
|
10
|
+
type SubCoderResult,
|
|
11
|
+
} from "./spawn.ts";
|
|
12
|
+
|
|
13
|
+
const base: SubCoderResult = {
|
|
14
|
+
id: "1",
|
|
15
|
+
label: "x",
|
|
16
|
+
task: "t",
|
|
17
|
+
exitCode: -1,
|
|
18
|
+
report: "",
|
|
19
|
+
messages: [],
|
|
20
|
+
stderr: "",
|
|
21
|
+
usage: { input: 0, output: 0, cost: 0, turns: 0, contextTokens: 0 },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("getFinalText", () => {
|
|
25
|
+
it("returns the last assistant text block", () => {
|
|
26
|
+
const messages = [
|
|
27
|
+
{ role: "assistant", content: [{ type: "text", text: "first" }] },
|
|
28
|
+
{ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: {} }] },
|
|
29
|
+
{ role: "assistant", content: [{ type: "text", text: "final answer" }] },
|
|
30
|
+
];
|
|
31
|
+
expect(getFinalText(messages)).toBe("final answer");
|
|
32
|
+
});
|
|
33
|
+
it("returns empty string when there is no assistant text", () => {
|
|
34
|
+
expect(getFinalText([{ role: "user", content: [{ type: "text", text: "hi" }] }])).toBe("");
|
|
35
|
+
expect(getFinalText([])).toBe("");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("truncateReport", () => {
|
|
40
|
+
it("leaves short reports intact", () => {
|
|
41
|
+
expect(truncateReport("short")).toBe("short");
|
|
42
|
+
});
|
|
43
|
+
it("truncates long reports with a notice", () => {
|
|
44
|
+
const out = truncateReport("a".repeat(5000), 100);
|
|
45
|
+
expect(out.length).toBeLessThan(300);
|
|
46
|
+
expect(out).toContain("truncated at 100 chars");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("summarizeActivity", () => {
|
|
51
|
+
it("shows the report's first line when done", () => {
|
|
52
|
+
expect(summarizeActivity({ ...base, exitCode: 0, report: "Found 3 routes\nmore" })).toBe("Found 3 routes");
|
|
53
|
+
});
|
|
54
|
+
it("surfaces the latest tool call while running", () => {
|
|
55
|
+
const r = {
|
|
56
|
+
...base,
|
|
57
|
+
messages: [{ role: "assistant", content: [{ type: "toolCall", name: "grep", arguments: { pattern: "login(" } }] }],
|
|
58
|
+
};
|
|
59
|
+
expect(summarizeActivity(r)).toBe("→ grep login(");
|
|
60
|
+
});
|
|
61
|
+
it("shows the error message on failure", () => {
|
|
62
|
+
expect(summarizeActivity({ ...base, exitCode: 1, errorMessage: "boom" })).toBe("boom");
|
|
63
|
+
});
|
|
64
|
+
it("falls back to working when running with no tool call", () => {
|
|
65
|
+
expect(summarizeActivity(base)).toBe("working…");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("buildChildEnv", () => {
|
|
70
|
+
it("constrains the child to read-only tools and the headless fast-path", () => {
|
|
71
|
+
const env = buildChildEnv();
|
|
72
|
+
expect(env.LITTLE_CODER_ALLOWED_TOOLS).toBe(SUBCODER_ALLOWED_TOOLS);
|
|
73
|
+
expect(env.LITTLE_CODER_ALLOWED_TOOLS).not.toContain("edit");
|
|
74
|
+
expect(env.LITTLE_CODER_ALLOWED_TOOLS).not.toContain("write");
|
|
75
|
+
expect(env.LITTLE_CODER_ALLOWED_TOOLS).not.toContain("dispatch");
|
|
76
|
+
expect(env.LITTLE_CODER_PERMISSION_MODE).toBe("auto");
|
|
77
|
+
expect(env.LITTLE_CODER_SUBAGENT).toBe("1");
|
|
78
|
+
});
|
|
79
|
+
it("merges extra overrides", () => {
|
|
80
|
+
expect(buildChildEnv({ FOO: "bar" }).FOO).toBe("bar");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("resolveLauncher / defaultConcurrency", () => {
|
|
85
|
+
it("points at bin/little-coder.mjs", () => {
|
|
86
|
+
expect(resolveLauncher().replace(/\\/g, "/")).toMatch(/\/bin\/little-coder\.mjs$/);
|
|
87
|
+
});
|
|
88
|
+
it("defaults concurrency to 2", () => {
|
|
89
|
+
const prev = process.env.LITTLE_CODER_SUBCODER_CONCURRENCY;
|
|
90
|
+
delete process.env.LITTLE_CODER_SUBCODER_CONCURRENCY;
|
|
91
|
+
expect(defaultConcurrency()).toBe(2);
|
|
92
|
+
process.env.LITTLE_CODER_SUBCODER_CONCURRENCY = "3";
|
|
93
|
+
expect(defaultConcurrency()).toBe(3);
|
|
94
|
+
if (prev === undefined) delete process.env.LITTLE_CODER_SUBCODER_CONCURRENCY;
|
|
95
|
+
else process.env.LITTLE_CODER_SUBCODER_CONCURRENCY = prev;
|
|
96
|
+
});
|
|
97
|
+
});
|