@rahularya01/pi-essentials 0.1.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/LICENSE +21 -0
- package/README.md +324 -0
- package/examples/mcp.json +30 -0
- package/examples/pi-essentials.json +32 -0
- package/examples/pi-settings.json +5 -0
- package/package.json +88 -0
- package/skills/pi-essentials/SKILL.md +50 -0
- package/src/config.ts +351 -0
- package/src/errors.ts +96 -0
- package/src/index.ts +43 -0
- package/src/mcp/commands.ts +390 -0
- package/src/mcp/config.ts +157 -0
- package/src/mcp/credential-store.ts +153 -0
- package/src/mcp/index.ts +67 -0
- package/src/mcp/manager.ts +941 -0
- package/src/mcp/oauth.ts +262 -0
- package/src/mcp/proxy-tool.ts +213 -0
- package/src/mcp/render.ts +164 -0
- package/src/mcp/types.ts +63 -0
- package/src/paths.ts +48 -0
- package/src/questions/ask.ts +134 -0
- package/src/questions/index.ts +72 -0
- package/src/questions/render.ts +69 -0
- package/src/questions/validate.ts +85 -0
- package/src/security/env.ts +132 -0
- package/src/security/limits.ts +20 -0
- package/src/security/ssrf.ts +237 -0
- package/src/subagents/activity.ts +132 -0
- package/src/subagents/builtins/oracle.md +11 -0
- package/src/subagents/builtins/reviewer.md +11 -0
- package/src/subagents/builtins/scout.md +12 -0
- package/src/subagents/builtins/worker.md +11 -0
- package/src/subagents/discover.ts +54 -0
- package/src/subagents/herdr.ts +150 -0
- package/src/subagents/index.ts +642 -0
- package/src/subagents/inspector-tail.d.mts +1 -0
- package/src/subagents/inspector-tail.mjs +140 -0
- package/src/subagents/render.ts +464 -0
- package/src/subagents/runner.ts +468 -0
- package/src/subagents/schema.ts +107 -0
- package/src/subagents/types.ts +131 -0
- package/src/subagents/worktree.ts +131 -0
- package/src/todos/index.ts +170 -0
- package/src/todos/render.ts +198 -0
- package/src/todos/state.ts +310 -0
- package/src/ui/render.ts +215 -0
- package/src/web/activity.ts +91 -0
- package/src/web/cache.ts +153 -0
- package/src/web/extract.ts +75 -0
- package/src/web/fetch.ts +167 -0
- package/src/web/html-to-markdown.ts +284 -0
- package/src/web/http.ts +238 -0
- package/src/web/index.ts +214 -0
- package/src/web/providers/brave.ts +27 -0
- package/src/web/providers/duckduckgo.ts +60 -0
- package/src/web/providers/exa.ts +29 -0
- package/src/web/providers/jina.ts +25 -0
- package/src/web/providers/searxng.ts +29 -0
- package/src/web/providers/tavily.ts +31 -0
- package/src/web/providers/types.ts +75 -0
- package/src/web/render.ts +130 -0
- package/src/web/search.ts +108 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
export interface AgentDefinition {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
tools?: string[];
|
|
5
|
+
model?: string;
|
|
6
|
+
systemPrompt: string;
|
|
7
|
+
source: "builtin" | "user" | "project";
|
|
8
|
+
filePath?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type AgentScope = "user" | "project" | "both";
|
|
12
|
+
|
|
13
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
14
|
+
|
|
15
|
+
/** Agent names appear in CLI-visible text and file names, so keep them boring. */
|
|
16
|
+
function sanitizeName(raw: string): string {
|
|
17
|
+
return raw
|
|
18
|
+
.trim()
|
|
19
|
+
.replace(/[^\w.-]+/g, "-")
|
|
20
|
+
.replace(/[.-]{2,}/g, "-")
|
|
21
|
+
.replace(/^[.\-]+|[.\-]+$/g, "")
|
|
22
|
+
.slice(0, 64);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function toStringList(value: unknown): string[] | undefined {
|
|
26
|
+
const items =
|
|
27
|
+
typeof value === "string"
|
|
28
|
+
? value.split(",")
|
|
29
|
+
: Array.isArray(value)
|
|
30
|
+
? value.map((item) => String(item))
|
|
31
|
+
: undefined;
|
|
32
|
+
if (!items) return undefined;
|
|
33
|
+
const cleaned = items.map((item) => item.trim()).filter(Boolean);
|
|
34
|
+
return cleaned.length > 0 ? cleaned : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseAgentMarkdown(
|
|
38
|
+
markdown: string,
|
|
39
|
+
fallbackName: string,
|
|
40
|
+
source: AgentDefinition["source"],
|
|
41
|
+
filePath?: string,
|
|
42
|
+
): AgentDefinition {
|
|
43
|
+
const match = markdown.match(FRONTMATTER);
|
|
44
|
+
const body = match ? markdown.slice(match[0].length) : markdown;
|
|
45
|
+
const fm = match ? parseFrontmatter(match[1]) : {};
|
|
46
|
+
const name = sanitizeName(String(fm.name ?? fallbackName)) || sanitizeName(fallbackName);
|
|
47
|
+
return {
|
|
48
|
+
name,
|
|
49
|
+
description: String(fm.description ?? "").trim(),
|
|
50
|
+
tools: toStringList(fm.tools),
|
|
51
|
+
model: typeof fm.model === "string" && fm.model.trim() ? fm.model.trim() : undefined,
|
|
52
|
+
systemPrompt: body.trim(),
|
|
53
|
+
source,
|
|
54
|
+
filePath,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Minimal YAML subset: `key: value`, inline `[a, b]` lists, and `- item` block lists. */
|
|
59
|
+
function parseFrontmatter(raw: string): Record<string, unknown> {
|
|
60
|
+
const out: Record<string, unknown> = {};
|
|
61
|
+
const lines = raw.split(/\r?\n/);
|
|
62
|
+
let listKey: string | undefined;
|
|
63
|
+
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
const blockItem = /^\s*-\s+(.*)$/.exec(line);
|
|
66
|
+
if (blockItem && listKey) {
|
|
67
|
+
const items = (out[listKey] as string[]) ?? [];
|
|
68
|
+
const value = unquote(blockItem[1].trim());
|
|
69
|
+
if (value) items.push(value);
|
|
70
|
+
out[listKey] = items;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const trimmed = line.trim();
|
|
75
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
76
|
+
const idx = trimmed.indexOf(":");
|
|
77
|
+
if (idx <= 0) continue;
|
|
78
|
+
const key = trimmed.slice(0, idx).trim();
|
|
79
|
+
const value = unquote(trimmed.slice(idx + 1).trim());
|
|
80
|
+
|
|
81
|
+
if (!value) {
|
|
82
|
+
listKey = key;
|
|
83
|
+
out[key] = [];
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
listKey = undefined;
|
|
87
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
88
|
+
out[key] = value
|
|
89
|
+
.slice(1, -1)
|
|
90
|
+
.split(",")
|
|
91
|
+
.map((item) => unquote(item.trim()))
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
} else {
|
|
94
|
+
out[key] = value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function unquote(value: string): string {
|
|
101
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
102
|
+
return value.slice(1, -1);
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function validateSubagentParams(params: {
|
|
108
|
+
agent?: string;
|
|
109
|
+
task?: string;
|
|
110
|
+
tasks?: Array<{ agent: string; task: string }>;
|
|
111
|
+
chain?: Array<{ agent: string; task: string }>;
|
|
112
|
+
}): { mode: "single" | "parallel" | "chain" } | { error: string } {
|
|
113
|
+
const hasChain = (params.chain?.length ?? 0) > 0;
|
|
114
|
+
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
115
|
+
const hasSingle = Boolean(params.agent?.trim() && params.task?.trim());
|
|
116
|
+
const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
|
|
117
|
+
if (modeCount === 0) {
|
|
118
|
+
const partial = params.agent || params.task;
|
|
119
|
+
return {
|
|
120
|
+
error: partial
|
|
121
|
+
? "Single mode needs both { agent, task }. Otherwise pass { tasks: [...] } or { chain: [...] }."
|
|
122
|
+
: "Provide exactly one mode: { agent, task }, { tasks: [...] }, or { chain: [...] }.",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (modeCount > 1) {
|
|
126
|
+
return { error: "Provide exactly one mode: { agent, task }, { tasks: [...] }, or { chain: [...] }." };
|
|
127
|
+
}
|
|
128
|
+
if (hasSingle) return { mode: "single" };
|
|
129
|
+
if (hasTasks) return { mode: "parallel" };
|
|
130
|
+
return { mode: "chain" };
|
|
131
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export type Isolation = "none" | "worktree";
|
|
7
|
+
|
|
8
|
+
export interface WorktreeMetadata {
|
|
9
|
+
isolation: "worktree";
|
|
10
|
+
repoRoot: string;
|
|
11
|
+
worktreePath: string;
|
|
12
|
+
baseCommit: string;
|
|
13
|
+
changedFiles: string[];
|
|
14
|
+
patchPath?: string;
|
|
15
|
+
captureError?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TemporaryWorktree {
|
|
19
|
+
repoRoot: string;
|
|
20
|
+
path: string;
|
|
21
|
+
parentDir: string;
|
|
22
|
+
baseCommit: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function gitRaw(cwd: string, args: string[]): string {
|
|
26
|
+
return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function git(cwd: string, args: string[]): string {
|
|
30
|
+
return gitRaw(cwd, args).trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Parse porcelain v1 -z output, including the destination and source of renames. */
|
|
34
|
+
export function parseChangedFiles(porcelain: string): string[] {
|
|
35
|
+
const records = porcelain.split("\0");
|
|
36
|
+
const files: string[] = [];
|
|
37
|
+
for (let i = 0; i < records.length; i++) {
|
|
38
|
+
const record = records[i];
|
|
39
|
+
if (!record) continue;
|
|
40
|
+
const status = record.slice(0, 2);
|
|
41
|
+
const file = record.slice(3);
|
|
42
|
+
if (file) files.push(file);
|
|
43
|
+
if (status.includes("R") || status.includes("C")) {
|
|
44
|
+
const source = records[++i];
|
|
45
|
+
if (source) files.push(source);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return [...new Set(files)].sort();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createTemporaryWorktree(sourceCwd: string): TemporaryWorktree {
|
|
52
|
+
let repoRoot: string;
|
|
53
|
+
try {
|
|
54
|
+
repoRoot = git(sourceCwd, ["rev-parse", "--show-toplevel"]);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error(`Worktree isolation requires a git repository: ${sourceCwd}`);
|
|
57
|
+
}
|
|
58
|
+
if (git(repoRoot, ["status", "--porcelain", "--untracked-files=all"])) {
|
|
59
|
+
throw new Error(`Worktree isolation requires a clean source tree: ${repoRoot}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const baseCommit = git(repoRoot, ["rev-parse", "HEAD"]);
|
|
63
|
+
const parentDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-essentials-worktree-"));
|
|
64
|
+
const worktreePath = path.join(parentDir, "checkout");
|
|
65
|
+
try {
|
|
66
|
+
execFileSync("git", ["-C", repoRoot, "worktree", "add", "--detach", worktreePath, baseCommit], {
|
|
67
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
68
|
+
});
|
|
69
|
+
} catch (error) {
|
|
70
|
+
fs.rmSync(parentDir, { recursive: true, force: true });
|
|
71
|
+
throw new Error(`Could not create temporary git worktree: ${(error as Error).message}`);
|
|
72
|
+
}
|
|
73
|
+
return { repoRoot, path: worktreePath, parentDir, baseCommit };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function remapWorktreeCwd(sourceCwd: string, worktree: TemporaryWorktree): string {
|
|
77
|
+
const relative = path.relative(worktree.repoRoot, path.resolve(sourceCwd));
|
|
78
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
79
|
+
throw new Error(`Working directory is outside the isolated repository: ${sourceCwd}`);
|
|
80
|
+
}
|
|
81
|
+
return path.join(worktree.path, relative);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Capture a --binary patch outside the checkout, then remove the detached worktree. */
|
|
85
|
+
export function finishTemporaryWorktree(worktree: TemporaryWorktree): WorktreeMetadata {
|
|
86
|
+
let changedFiles: string[] = [];
|
|
87
|
+
let patchPath: string | undefined;
|
|
88
|
+
let captureError: string | undefined;
|
|
89
|
+
try {
|
|
90
|
+
const porcelain = gitRaw(worktree.path, ["status", "--porcelain", "-z", "--untracked-files=all"]);
|
|
91
|
+
const statusFiles = parseChangedFiles(porcelain);
|
|
92
|
+
const committedFiles = gitRaw(worktree.path, ["diff", "--name-only", "-z", worktree.baseCommit])
|
|
93
|
+
.split("\0")
|
|
94
|
+
.filter(Boolean);
|
|
95
|
+
changedFiles = [...new Set([...statusFiles, ...committedFiles])].sort();
|
|
96
|
+
if (statusFiles.length > 0) {
|
|
97
|
+
// Intent-to-add makes untracked files visible to diff without changing the source repository's index.
|
|
98
|
+
execFileSync("git", ["-C", worktree.path, "add", "-N", "--", "."], { stdio: "ignore" });
|
|
99
|
+
}
|
|
100
|
+
const patch = execFileSync("git", ["-C", worktree.path, "diff", "--binary", "--no-ext-diff", worktree.baseCommit], {
|
|
101
|
+
encoding: "buffer",
|
|
102
|
+
maxBuffer: 100 * 1024 * 1024,
|
|
103
|
+
});
|
|
104
|
+
const artifactDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-essentials-subagent-patch-"));
|
|
105
|
+
patchPath = path.join(artifactDir, "changes.patch");
|
|
106
|
+
fs.writeFileSync(patchPath, patch, { mode: 0o600 });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
// The child already completed; artifact failures must not erase its answer.
|
|
109
|
+
captureError = `Could not capture isolated changes: ${(error as Error).message}`;
|
|
110
|
+
} finally {
|
|
111
|
+
try {
|
|
112
|
+
execFileSync("git", ["-C", worktree.repoRoot, "worktree", "remove", "--force", worktree.path], { stdio: "ignore" });
|
|
113
|
+
} catch (error) {
|
|
114
|
+
captureError ??= `Could not remove temporary worktree: ${(error as Error).message}`;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
fs.rmSync(worktree.parentDir, { recursive: true, force: true });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
captureError ??= `Could not remove temporary worktree directory: ${(error as Error).message}`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
isolation: "worktree",
|
|
124
|
+
repoRoot: worktree.repoRoot,
|
|
125
|
+
worktreePath: worktree.path,
|
|
126
|
+
baseCommit: worktree.baseCommit,
|
|
127
|
+
changedFiles,
|
|
128
|
+
patchPath,
|
|
129
|
+
captureError,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
import { errorMessage, toolFailure, toolText } from "../errors.ts";
|
|
6
|
+
import { MAX_TODOS } from "../security/limits.ts";
|
|
7
|
+
import { formatTodosGrouped, renderTodoCall, renderTodoResult, todoWidget } from "./render.ts";
|
|
8
|
+
import { applyTodoMutation, cloneState, emptyTodoState, formatTodos, type TodoItem, type TodoState } from "./state.ts";
|
|
9
|
+
|
|
10
|
+
interface TodoDetails {
|
|
11
|
+
todos: TodoState["todos"];
|
|
12
|
+
nextId: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Rows shown in the editor widget before it collapses into a "+N more" line. */
|
|
16
|
+
const WIDGET_ROWS = 8;
|
|
17
|
+
const TODO_STATE_ENTRY = "pi-essentials-todos";
|
|
18
|
+
|
|
19
|
+
export function registerTodos(pi: ExtensionAPI): void {
|
|
20
|
+
const bySession = new Map<string, TodoState>();
|
|
21
|
+
let collapsed = false;
|
|
22
|
+
|
|
23
|
+
const sessionKey = (ctx: ExtensionContext): string =>
|
|
24
|
+
ctx.sessionManager?.getSessionFile?.() || ctx.sessionManager?.getSessionId?.() || "default";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Rebuild state from the session branch so todos survive /reload, compaction,
|
|
28
|
+
* and /tree navigation, which can move to a branch with different history.
|
|
29
|
+
*/
|
|
30
|
+
const reconstruct = (ctx: ExtensionContext): TodoState => {
|
|
31
|
+
let state = emptyTodoState();
|
|
32
|
+
let branch: unknown[] = [];
|
|
33
|
+
try {
|
|
34
|
+
branch = ctx.sessionManager?.getBranch?.() ?? [];
|
|
35
|
+
} catch {
|
|
36
|
+
branch = [];
|
|
37
|
+
}
|
|
38
|
+
for (const entry of branch) {
|
|
39
|
+
const record = entry as { type?: string; customType?: string; data?: Partial<TodoDetails>; message?: unknown };
|
|
40
|
+
if (record?.type === "custom" && record.customType === TODO_STATE_ENTRY) {
|
|
41
|
+
if (Array.isArray(record.data?.todos) && typeof record.data?.nextId === "number") {
|
|
42
|
+
state = { todos: record.data.todos, nextId: record.data.nextId };
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (record?.type !== "message") continue;
|
|
47
|
+
const msg = record.message as { role?: string; toolName?: string; details?: Partial<TodoDetails> };
|
|
48
|
+
if (msg?.role !== "toolResult" || msg.toolName !== "todo") continue;
|
|
49
|
+
if (Array.isArray(msg.details?.todos) && typeof msg.details?.nextId === "number") {
|
|
50
|
+
state = { todos: msg.details.todos, nextId: msg.details.nextId };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const restored = cloneState(state);
|
|
54
|
+
bySession.set(sessionKey(ctx), restored);
|
|
55
|
+
return restored;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const current = (ctx: ExtensionContext): TodoState => bySession.get(sessionKey(ctx)) ?? reconstruct(ctx);
|
|
59
|
+
|
|
60
|
+
const renderWidget = (ctx: ExtensionContext, state: TodoState) => {
|
|
61
|
+
if (!ctx.hasUI) return;
|
|
62
|
+
if (state.todos.length === 0) {
|
|
63
|
+
ctx.ui.setWidget("pi-essentials-todos", undefined);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// A component factory receives the live theme, so the widget picks up
|
|
67
|
+
// strikethrough, the progress meter, and theme colors.
|
|
68
|
+
ctx.ui.setWidget(
|
|
69
|
+
"pi-essentials-todos",
|
|
70
|
+
(_tui, theme) => new Text(todoWidget(theme, state, { collapsed, maxRows: WIDGET_ROWS }).join("\n"), 0, 0),
|
|
71
|
+
{ placement: "aboveEditor" },
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
76
|
+
bySession.clear();
|
|
77
|
+
renderWidget(ctx, reconstruct(ctx));
|
|
78
|
+
});
|
|
79
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
80
|
+
renderWidget(ctx, reconstruct(ctx));
|
|
81
|
+
});
|
|
82
|
+
pi.on("session_shutdown", async () => {
|
|
83
|
+
bySession.clear();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
pi.registerShortcut("ctrl+shift+t", {
|
|
87
|
+
description: "Collapse or expand the pi-essentials todo panel",
|
|
88
|
+
handler: async (ctx) => {
|
|
89
|
+
collapsed = !collapsed;
|
|
90
|
+
renderWidget(ctx, current(ctx));
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
pi.registerCommand("todos", {
|
|
95
|
+
description: "Show the current todo list. Usage: /todos [clear]",
|
|
96
|
+
handler: async (args, ctx) => {
|
|
97
|
+
if (args.trim().toLowerCase() === "clear") {
|
|
98
|
+
const applied = applyTodoMutation(current(ctx), { action: "clear" });
|
|
99
|
+
bySession.set(sessionKey(ctx), applied.state);
|
|
100
|
+
pi.appendEntry(TODO_STATE_ENTRY, cloneState(applied.state));
|
|
101
|
+
renderWidget(ctx, applied.state);
|
|
102
|
+
ctx.ui.notify(applied.message, "info");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
ctx.ui.notify(formatTodosGrouped(current(ctx).todos), "info");
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const StatusSchema = StringEnum(["pending", "in_progress", "completed", "blocked", "abandoned"] as const);
|
|
110
|
+
const SnapshotTodoSchema = Type.Object({
|
|
111
|
+
id: Type.Integer({ minimum: 1, description: "Stable numeric todo id" }),
|
|
112
|
+
content: Type.String(),
|
|
113
|
+
status: StatusSchema,
|
|
114
|
+
blockedBy: Type.Optional(Type.Array(Type.Integer({ minimum: 1 }))),
|
|
115
|
+
phase: Type.Optional(Type.String()),
|
|
116
|
+
blocker: Type.Optional(Type.String()),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
pi.registerTool({
|
|
120
|
+
name: "todo",
|
|
121
|
+
label: "Todo",
|
|
122
|
+
description:
|
|
123
|
+
"Track multi-step coding work. Actions: list, create, update, complete, reopen, block, unblock, abandon, delete, clear, sync. Keep at most one item in_progress.",
|
|
124
|
+
promptSnippet: "Track multi-step work with a structured todo list",
|
|
125
|
+
promptGuidelines: [
|
|
126
|
+
"Use todo to plan and track multi-step coding tasks instead of keeping an informal checklist only in your head.",
|
|
127
|
+
"Keep at most one todo in_progress. Mark work complete as soon as it is done.",
|
|
128
|
+
],
|
|
129
|
+
parameters: Type.Object({
|
|
130
|
+
action: StringEnum(["list", "create", "update", "complete", "reopen", "block", "unblock", "abandon", "delete", "clear", "sync"] as const),
|
|
131
|
+
content: Type.Optional(Type.String({ description: "Todo text for create/update" })),
|
|
132
|
+
id: Type.Optional(Type.Number({ description: "Todo id for update/complete/reopen/block/unblock/abandon/delete" })),
|
|
133
|
+
status: Type.Optional(StatusSchema),
|
|
134
|
+
blockedBy: Type.Optional(Type.Array(Type.Number(), { description: `Ids that must finish first (max ${MAX_TODOS})` })),
|
|
135
|
+
phase: Type.Optional(Type.String({ description: "Optional workflow phase" })),
|
|
136
|
+
blocker: Type.Optional(Type.String({ description: "Optional reason the todo is blocked" })),
|
|
137
|
+
todos: Type.Optional(Type.Array(SnapshotTodoSchema, { description: "Complete replacement snapshot for sync" })),
|
|
138
|
+
|
|
139
|
+
}),
|
|
140
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
141
|
+
const before = current(ctx);
|
|
142
|
+
try {
|
|
143
|
+
const applied = applyTodoMutation(before, {
|
|
144
|
+
action: params.action,
|
|
145
|
+
content: params.content,
|
|
146
|
+
id: params.id,
|
|
147
|
+
status: params.status,
|
|
148
|
+
blockedBy: params.blockedBy,
|
|
149
|
+
phase: params.phase,
|
|
150
|
+
blocker: params.blocker,
|
|
151
|
+
todos: params.todos as TodoItem[] | undefined,
|
|
152
|
+
});
|
|
153
|
+
bySession.set(sessionKey(ctx), applied.state);
|
|
154
|
+
renderWidget(ctx, applied.state);
|
|
155
|
+
const listing = params.action === "list" ? "" : `\n\n${formatTodos(applied.state.todos)}`;
|
|
156
|
+
return toolText(`${applied.message}${listing}`, {
|
|
157
|
+
todos: applied.state.todos,
|
|
158
|
+
nextId: applied.state.nextId,
|
|
159
|
+
action: params.action,
|
|
160
|
+
} satisfies TodoDetails & { action: string });
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// The state is unchanged on failure, so the last successful result is
|
|
163
|
+
// still the correct one for branch reconstruction.
|
|
164
|
+
toolFailure(`${errorMessage(error)}\n\n${formatTodos(before.todos)}`, "TODO_INVALID");
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
renderCall: renderTodoCall,
|
|
168
|
+
renderResult: renderTodoResult,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
failLine,
|
|
5
|
+
firstText,
|
|
6
|
+
GLYPH,
|
|
7
|
+
headingRule,
|
|
8
|
+
meta,
|
|
9
|
+
oneLine,
|
|
10
|
+
progressBar,
|
|
11
|
+
safeKeyHint,
|
|
12
|
+
safeRender,
|
|
13
|
+
titleLine,
|
|
14
|
+
type RenderableResult,
|
|
15
|
+
type RenderSlot,
|
|
16
|
+
} from "../ui/render.ts";
|
|
17
|
+
import type { TodoItem, TodoState } from "./state.ts";
|
|
18
|
+
|
|
19
|
+
interface TodoDetails {
|
|
20
|
+
todos?: TodoItem[];
|
|
21
|
+
nextId?: number;
|
|
22
|
+
action?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const STATUS_GLYPH: Record<TodoItem["status"], string> = {
|
|
26
|
+
completed: GLYPH.done,
|
|
27
|
+
in_progress: GLYPH.running,
|
|
28
|
+
pending: GLYPH.pending,
|
|
29
|
+
blocked: "!",
|
|
30
|
+
abandoned: "–",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const STATUS_COLOR = {
|
|
34
|
+
completed: "success",
|
|
35
|
+
in_progress: "accent",
|
|
36
|
+
pending: "muted",
|
|
37
|
+
blocked: "warning",
|
|
38
|
+
abandoned: "dim",
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
/** One task line: glyph, id, text. Completed work is struck through. */
|
|
42
|
+
export function todoLine(theme: Theme, todo: TodoItem, width = 60): string {
|
|
43
|
+
const color = STATUS_COLOR[todo.status];
|
|
44
|
+
const label = oneLine(todo.content, width);
|
|
45
|
+
const text = todo.status === "completed" || todo.status === "abandoned"
|
|
46
|
+
? theme.strikethrough(theme.fg("dim", label))
|
|
47
|
+
: theme.fg("text", label);
|
|
48
|
+
const dependencies = todo.blockedBy?.length ? `needs ${todo.blockedBy.map((id) => `#${id}`).join(",")}` : undefined;
|
|
49
|
+
const details = [todo.phase && `phase ${todo.phase}`, todo.blocker && `blocked: ${todo.blocker}`, dependencies]
|
|
50
|
+
.filter((part): part is string => Boolean(part));
|
|
51
|
+
const metadata = details.length > 0 ? theme.fg("dim", ` ${GLYPH.sep} ${details.join(` ${GLYPH.sep} `)}`) : "";
|
|
52
|
+
return `${theme.fg(color, STATUS_GLYPH[todo.status])} ${theme.fg("dim", `#${todo.id}`)} ${text}${metadata}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function renderTodoCall(
|
|
56
|
+
args: { action?: string; content?: string; id?: number; status?: string },
|
|
57
|
+
theme: Theme,
|
|
58
|
+
context: RenderSlot,
|
|
59
|
+
): Text {
|
|
60
|
+
return safeRender(
|
|
61
|
+
() => {
|
|
62
|
+
const subject = args?.content ? `"${oneLine(args.content, 48)}"` : args?.id !== undefined ? `#${args.id}` : undefined;
|
|
63
|
+
return (
|
|
64
|
+
titleLine(theme, "todo") +
|
|
65
|
+
` ${theme.fg("accent", args?.action ?? "")}` +
|
|
66
|
+
(subject ? ` ${theme.fg("text", subject)}` : "") +
|
|
67
|
+
meta(theme, [args?.status])
|
|
68
|
+
);
|
|
69
|
+
},
|
|
70
|
+
"todo",
|
|
71
|
+
context,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function renderTodoResult(
|
|
76
|
+
result: RenderableResult<TodoDetails | undefined>,
|
|
77
|
+
options: { expanded: boolean; isPartial: boolean },
|
|
78
|
+
theme: Theme,
|
|
79
|
+
context: RenderSlot,
|
|
80
|
+
): Text {
|
|
81
|
+
return safeRender(
|
|
82
|
+
() => {
|
|
83
|
+
if (context.isError) return failLine(theme, oneLine(firstText(result) || "todo update failed", 96));
|
|
84
|
+
|
|
85
|
+
const todos = result?.details?.todos ?? [];
|
|
86
|
+
if (todos.length === 0) {
|
|
87
|
+
return `${theme.fg("muted", GLYPH.pending)} ${theme.fg("muted", "no todos")}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const done = todos.filter((todo) => todo.status === "completed").length;
|
|
91
|
+
const header =
|
|
92
|
+
`${theme.fg("success", GLYPH.ok)} ${progressBar(theme, done, todos.length)} ` +
|
|
93
|
+
theme.fg("text", `${done}/${todos.length}`) +
|
|
94
|
+
meta(theme, [firstMessage(firstText(result))]);
|
|
95
|
+
|
|
96
|
+
// Unfinished work is what the reader needs; completed rows fill the rest.
|
|
97
|
+
const open = todos.filter((todo) => todo.status !== "completed");
|
|
98
|
+
const closed = todos.filter((todo) => todo.status === "completed");
|
|
99
|
+
const ordered = [...open, ...closed];
|
|
100
|
+
const limit = options.expanded ? ordered.length : Math.min(ordered.length, 6);
|
|
101
|
+
const shown = ordered.slice(0, limit);
|
|
102
|
+
|
|
103
|
+
let text = header;
|
|
104
|
+
for (const todo of shown) text += `\n ${todoLine(theme, todo)}`;
|
|
105
|
+
|
|
106
|
+
const hidden = ordered.length - shown.length;
|
|
107
|
+
if (hidden > 0) {
|
|
108
|
+
const hiddenDone = shown.length >= open.length ? hidden : closed.length;
|
|
109
|
+
text += theme.fg(
|
|
110
|
+
"dim",
|
|
111
|
+
`\n +${hidden} more (${hiddenDone} completed) ${GLYPH.sep} ${safeKeyHint("app.tools.expand", "to expand")}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return text;
|
|
115
|
+
},
|
|
116
|
+
oneLine(firstText(result), 120),
|
|
117
|
+
context,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The mutation message is the first line of the tool text ("Created #3: ..."). */
|
|
122
|
+
function firstMessage(text: string): string | undefined {
|
|
123
|
+
const line = text.split("\n")[0]?.trim();
|
|
124
|
+
if (!line || line.startsWith("Todos (")) return undefined;
|
|
125
|
+
return oneLine(line, 48);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface WidgetOptions {
|
|
129
|
+
collapsed: boolean;
|
|
130
|
+
maxRows: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Editor widget. Unfinished work sorts first, completed rows are struck
|
|
135
|
+
* through, and anything dropped is accounted for on the overflow line.
|
|
136
|
+
*/
|
|
137
|
+
export function todoWidget(theme: Theme, state: TodoState, options: WidgetOptions): string[] {
|
|
138
|
+
const todos = state.todos;
|
|
139
|
+
if (todos.length === 0) return [];
|
|
140
|
+
|
|
141
|
+
const done = todos.filter((todo) => todo.status === "completed").length;
|
|
142
|
+
const active = todos.find((todo) => todo.status === "in_progress");
|
|
143
|
+
const heading =
|
|
144
|
+
headingRule(theme, "Todos") +
|
|
145
|
+
` ${progressBar(theme, done, todos.length)} ${theme.fg("muted", `${done}/${todos.length}`)}`;
|
|
146
|
+
|
|
147
|
+
if (options.collapsed) {
|
|
148
|
+
const hint = active ? oneLine(active.content, 40) : `${todos.length - done} remaining`;
|
|
149
|
+
return [`${heading} ${theme.fg("dim", `${GLYPH.sep} ${hint} ${GLYPH.sep} ${safeKeyHint("app.tools.expand", "expand")}`)}`];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const open = todos.filter((todo) => todo.status !== "completed");
|
|
153
|
+
const closed = todos.filter((todo) => todo.status === "completed");
|
|
154
|
+
const ordered = [...open, ...closed];
|
|
155
|
+
const rows = Math.max(1, options.maxRows);
|
|
156
|
+
const shown = ordered.slice(0, rows);
|
|
157
|
+
|
|
158
|
+
const lines = [heading, ...shown.map((todo) => ` ${todoLine(theme, todo, 52)}`)];
|
|
159
|
+
const hidden = ordered.slice(rows);
|
|
160
|
+
if (hidden.length > 0) {
|
|
161
|
+
// Say exactly what was dropped rather than a bare "+N more".
|
|
162
|
+
const hiddenDone = hidden.filter((todo) => todo.status === "completed").length;
|
|
163
|
+
const hiddenOpen = hidden.length - hiddenDone;
|
|
164
|
+
const parts = [hiddenDone > 0 && `${hiddenDone} completed`, hiddenOpen > 0 && `${hiddenOpen} pending`].filter(
|
|
165
|
+
(part): part is string => Boolean(part),
|
|
166
|
+
);
|
|
167
|
+
lines.push(theme.fg("dim", ` +${hidden.length} more (${parts.join(", ")})`));
|
|
168
|
+
}
|
|
169
|
+
return lines;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `/todos` output, grouped by status. */
|
|
173
|
+
export function formatTodosGrouped(todos: TodoItem[]): string {
|
|
174
|
+
if (todos.length === 0) return "No todos yet. Ask the agent to plan the work.";
|
|
175
|
+
const groups: Array<[string, TodoItem["status"]]> = [
|
|
176
|
+
["In progress", "in_progress"],
|
|
177
|
+
["Blocked", "blocked"],
|
|
178
|
+
["Pending", "pending"],
|
|
179
|
+
["Completed", "completed"],
|
|
180
|
+
["Abandoned", "abandoned"],
|
|
181
|
+
];
|
|
182
|
+
const done = todos.filter((todo) => todo.status === "completed").length;
|
|
183
|
+
const out: string[] = [`Todos ${done}/${todos.length} completed`];
|
|
184
|
+
for (const [label, status] of groups) {
|
|
185
|
+
const rows = todos.filter((todo) => todo.status === status);
|
|
186
|
+
if (rows.length === 0) continue;
|
|
187
|
+
out.push("", `${label} (${rows.length})`);
|
|
188
|
+
for (const todo of rows) {
|
|
189
|
+
const details = [
|
|
190
|
+
todo.phase && `phase ${todo.phase}`,
|
|
191
|
+
todo.blocker && `blocked: ${todo.blocker}`,
|
|
192
|
+
todo.blockedBy?.length && `needs ${todo.blockedBy.map((id) => `#${id}`).join(",")}`,
|
|
193
|
+
].filter((part): part is string => Boolean(part));
|
|
194
|
+
out.push(` ${STATUS_GLYPH[todo.status]} #${todo.id} ${todo.content}${details.length ? ` ${details.join("; ")}` : ""}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out.join("\n");
|
|
198
|
+
}
|