little-coder 1.8.4 → 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/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 +23 -0
- package/README.md +17 -3
- package/bin/little-coder.mjs +56 -5
- package/package.json +2 -2
- package/skills/tools/dispatch.md +38 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
|
|
7
|
+
// Up-arrow prompt history, persisted across sessions.
|
|
8
|
+
//
|
|
9
|
+
// pi's default editor exposes an `addToHistory` hook (which pi calls on every
|
|
10
|
+
// submit) but ships no actual navigation, so up-arrow does nothing. We provide a
|
|
11
|
+
// custom editor — a subclass of pi's own CustomEditor, so pi copies all the
|
|
12
|
+
// app keybindings, autocomplete, and submit wiring onto it (see
|
|
13
|
+
// interactive-mode.setCustomEditorComponent) — that implements `addToHistory`
|
|
14
|
+
// and recalls history on ↑ / ↓.
|
|
15
|
+
//
|
|
16
|
+
// Why a custom editor and not raw onTerminalInput key-matching: pi runs the
|
|
17
|
+
// Kitty keyboard protocol (flags=7), so arrows arrive as CSI-u sequences with
|
|
18
|
+
// press/repeat/release events. The editor path (a) only sees press/repeat —
|
|
19
|
+
// the TUI filters releases before handleInput — and (b) lets us detect ↑/↓ via
|
|
20
|
+
// keybindings.matches(), which already understands every encoding. Both are
|
|
21
|
+
// brittle to reproduce from raw bytes.
|
|
22
|
+
//
|
|
23
|
+
// History is stored in <agentDir>/little-coder-prompt-history.json so a brand
|
|
24
|
+
// new session (even one with no messages) can recall prompts from earlier runs.
|
|
25
|
+
|
|
26
|
+
const MAX = 100;
|
|
27
|
+
|
|
28
|
+
function agentDir(): string {
|
|
29
|
+
const env = process.env.PI_CODING_AGENT_DIR;
|
|
30
|
+
if (env && env.trim().length > 0) {
|
|
31
|
+
if (env === "~") return homedir();
|
|
32
|
+
if (env.startsWith("~/")) return homedir() + env.slice(1);
|
|
33
|
+
return env;
|
|
34
|
+
}
|
|
35
|
+
return join(homedir(), ".pi", "agent");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function historyFile(): string {
|
|
39
|
+
return join(agentDir(), "little-coder-prompt-history.json");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function loadHistory(): string[] {
|
|
43
|
+
try {
|
|
44
|
+
const raw = JSON.parse(readFileSync(historyFile(), "utf-8"));
|
|
45
|
+
if (Array.isArray(raw)) return raw.filter((x) => typeof x === "string").slice(-MAX);
|
|
46
|
+
} catch {
|
|
47
|
+
// missing / unreadable / corrupt — start empty
|
|
48
|
+
}
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function saveHistory(items: string[]): void {
|
|
53
|
+
try {
|
|
54
|
+
const dir = dirname(historyFile());
|
|
55
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
56
|
+
writeFileSync(historyFile(), JSON.stringify(items.slice(-MAX)));
|
|
57
|
+
} catch {
|
|
58
|
+
// best-effort; recall still works in-process this session
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pure history store. `recall("up"|"down", currentText)` returns the text to
|
|
64
|
+
* place in the editor, or null to let the editor handle the key normally.
|
|
65
|
+
* Navigation only STARTS from an empty prompt (so it never clobbers a draft or
|
|
66
|
+
* fights multi-line cursor movement); once navigating, ↑/↓ walk the list.
|
|
67
|
+
*/
|
|
68
|
+
export function makeHistory(initial: string[] = [], persist: (items: string[]) => void = () => {}) {
|
|
69
|
+
const items = initial.slice(-MAX);
|
|
70
|
+
let nav = -1;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
items,
|
|
74
|
+
add(text: string) {
|
|
75
|
+
const t = (text ?? "").trim();
|
|
76
|
+
if (!t) return;
|
|
77
|
+
if (items[items.length - 1] !== text) {
|
|
78
|
+
items.push(text);
|
|
79
|
+
while (items.length > MAX) items.shift();
|
|
80
|
+
persist(items);
|
|
81
|
+
}
|
|
82
|
+
nav = -1;
|
|
83
|
+
},
|
|
84
|
+
reset() {
|
|
85
|
+
nav = -1;
|
|
86
|
+
},
|
|
87
|
+
recall(dir: "up" | "down", current: string): string | null {
|
|
88
|
+
if (dir === "up") {
|
|
89
|
+
if (nav === -1) {
|
|
90
|
+
if (current !== "" || items.length === 0) return null; // only from empty
|
|
91
|
+
nav = items.length - 1;
|
|
92
|
+
} else if (nav > 0) {
|
|
93
|
+
nav -= 1;
|
|
94
|
+
}
|
|
95
|
+
return items[nav];
|
|
96
|
+
}
|
|
97
|
+
// down
|
|
98
|
+
if (nav === -1) return null;
|
|
99
|
+
if (nav < items.length - 1) {
|
|
100
|
+
nav += 1;
|
|
101
|
+
return items[nav];
|
|
102
|
+
}
|
|
103
|
+
nav = -1;
|
|
104
|
+
return ""; // past the newest → empty prompt
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export default function (pi: ExtensionAPI) {
|
|
110
|
+
const store = makeHistory(loadHistory(), saveHistory);
|
|
111
|
+
|
|
112
|
+
// A CustomEditor that adds history recall. pi copies app keybindings,
|
|
113
|
+
// autocomplete, and submit/change wiring onto it after construction.
|
|
114
|
+
class HistoryEditor extends CustomEditor {
|
|
115
|
+
private kb: any;
|
|
116
|
+
private tuiRef: any;
|
|
117
|
+
constructor(tui: any, theme: any, keybindings: any, options?: any) {
|
|
118
|
+
super(tui, theme, keybindings, options);
|
|
119
|
+
this.kb = keybindings;
|
|
120
|
+
this.tuiRef = tui;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// pi calls this on every submit — our hook to record + persist the prompt.
|
|
124
|
+
addToHistory(text: string): void {
|
|
125
|
+
store.add(text);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
handleInput(data: string): void {
|
|
129
|
+
const up = this.kb?.matches?.(data, "tui.editor.cursorUp");
|
|
130
|
+
const down = this.kb?.matches?.(data, "tui.editor.cursorDown");
|
|
131
|
+
// Don't hijack ↑/↓ while the autocomplete dropdown is open — it owns them.
|
|
132
|
+
const autocompleting = (this as any).isShowingAutocomplete?.() === true;
|
|
133
|
+
if ((up || down) && !autocompleting) {
|
|
134
|
+
const next = store.recall(up ? "up" : "down", this.getText());
|
|
135
|
+
if (next !== null) {
|
|
136
|
+
this.setText(next);
|
|
137
|
+
this.tuiRef?.requestRender?.();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Any non-navigation key ends the current recall walk.
|
|
142
|
+
if (!up && !down) store.reset();
|
|
143
|
+
super.handleInput(data);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
148
|
+
if (!ctx.hasUI || typeof (ctx.ui as any).setEditorComponent !== "function") return;
|
|
149
|
+
store.reset();
|
|
150
|
+
(ctx.ui as any).setEditorComponent(
|
|
151
|
+
(tui: any, theme: any, keybindings: any) => new HistoryEditor(tui, theme, keybindings),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -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
|
+
}
|