feat: package Pi extensions as Bun workspaces

This commit is contained in:
jb
2026-09-01 17:24:46 -04:00
parent 90f8090f61
commit 118b5bee06
214 changed files with 19566 additions and 5876 deletions
+49
View File
@@ -0,0 +1,49 @@
import type { TaskState } from "./state.ts";
export const TODO_AUTO_CLEAR_DELAY_MS = 10_000;
export interface CompletedTaskAutoClear {
sync(forceRestart?: boolean): void;
cancel(): void;
}
export function createCompletedTaskAutoClear(
state: TaskState,
tryClear: () => boolean,
delayMs = TODO_AUTO_CLEAR_DELAY_MS,
): CompletedTaskAutoClear {
let timer: ReturnType<typeof setTimeout> | undefined;
let scheduledKey: string | undefined;
const completionKey = () => {
const tasks = state.list();
if (tasks.length === 0 || tasks.some((task) => task.status !== "done")) return undefined;
return tasks.map((task) => `${task.id}:${task.status}`).join("|");
};
const cancel = () => {
if (timer !== undefined) clearTimeout(timer);
timer = undefined;
scheduledKey = undefined;
};
const sync = (forceRestart = false) => {
const nextKey = completionKey();
if (nextKey === undefined) {
cancel();
return;
}
if (!forceRestart && timer !== undefined && scheduledKey === nextKey) return;
cancel();
scheduledKey = nextKey;
timer = setTimeout(() => {
timer = undefined;
if (completionKey() !== scheduledKey) return;
if (!tryClear()) sync();
}, Math.max(0, delayMs));
timer.unref?.();
};
return { sync, cancel };
}
+104
View File
@@ -0,0 +1,104 @@
import net from "node:net";
import type { Task } from "./types.ts";
import { orderedTreeRows, snapshotStats } from "./ui.ts";
const SOURCE = "pi-todo-herdr";
const TASK_TOKEN = "task";
const COUNT_TOKEN = "task_count";
const PROGRESS_TOKEN = "task_progress";
let reportSeq = Date.now() * 1000;
function nextSeq(): number {
reportSeq += 1;
return reportSeq;
}
export function isHerdrEnvironment(): boolean {
return process.env.HERDR_ENV === "1" && !!process.env.HERDR_SOCKET_PATH && !!process.env.HERDR_PANE_ID;
}
function hasDescendantWithStatus(task: Task, status: Task["status"], byId: Map<number, Task>): boolean {
for (const childId of task.childrenIds) {
const child = byId.get(childId);
if (!child) continue;
if (child.status === status || hasDescendantWithStatus(child, status, byId)) return true;
}
return false;
}
export function currentTaskTokens(tasks: Task[]): Record<string, string | null> {
const byId = new Map(tasks.map((task) => [task.id, task]));
const stats = snapshotStats(tasks);
const ordered = orderedTreeRows(tasks).map(({ task }) => task);
const inProgress = ordered.filter(
(task) => task.status === "in-progress" && !hasDescendantWithStatus(task, "in-progress", byId),
);
const candidates =
inProgress.length > 0
? inProgress
: ordered.filter((task) => task.status === "blocked" && !hasDescendantWithStatus(task, "blocked", byId));
return {
[TASK_TOKEN]: candidates.length > 0 ? `#${candidates[0]!.id} ${candidates[0]!.name}` : null,
[COUNT_TOKEN]: candidates.length > 1 ? `+${candidates.length - 1}` : null,
[PROGRESS_TOKEN]: stats.total > 0 && stats.done < stats.total ? `${stats.done}/${stats.total}` : null,
};
}
export function currentTaskLabel(tasks: Task[]): string | null {
const tokens = currentTaskTokens(tasks);
const task = tokens[TASK_TOKEN];
const count = tokens[COUNT_TOKEN];
return task ? `${task}${count ? ` ${count}` : ""}` : null;
}
function sendAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
if (!isHerdrEnvironment()) return Promise.resolve(true);
const socketPath = process.env.HERDR_SOCKET_PATH!;
const endpoint = process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
return new Promise((resolve) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const socket = net.createConnection(endpoint);
const finish = (delivered: boolean) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
socket.destroy();
resolve(delivered);
};
socket.on("error", () => finish(false));
socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
socket.on("data", () => finish(true));
socket.on("end", () => finish(false));
timer = setTimeout(() => finish(false), timeoutMs);
timer.unref?.();
});
}
async function send(request: unknown): Promise<void> {
if (await sendAttempt(request, 500)) return;
await sendAttempt(request, 1500);
}
async function reportTokens(tokens: Record<string, string | null>): Promise<void> {
if (!isHerdrEnvironment()) return;
await send({
id: `${SOURCE}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
method: "pane.report_metadata",
params: {
pane_id: process.env.HERDR_PANE_ID,
source: SOURCE,
tokens,
seq: nextSeq(),
},
});
}
export function reportCurrentTask(tasks: Task[]): Promise<void> {
return reportTokens(currentTaskTokens(tasks));
}
export function clearCurrentTask(): Promise<void> {
return reportTokens({ [TASK_TOKEN]: null, [COUNT_TOKEN]: null, [PROGRESS_TOKEN]: null });
}
+21
View File
@@ -0,0 +1,21 @@
export const MAX_TASKS = 200;
export const MAX_TASKS_PER_MUTATION = 50;
export const MAX_TASK_DEPTH = 3;
export const MAX_TASK_NAME_LENGTH = 200;
export const MAX_TASK_DESCRIPTION_LENGTH = 4_000;
export const MAX_WIDGET_TASK_ROWS = 12;
export const TASK_NAME_PATTERN =
"^[^\\u0000-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F]*$";
export const TASK_DESCRIPTION_PATTERN =
"^[^\\u0000-\\u0009\\u000B-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F]*$";
const UNSAFE_TASK_TEXT = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u206F]/u;
export function hasUnsafeTaskText(value: string): boolean {
return UNSAFE_TASK_TEXT.test(value);
}
export function taskTextLength(value: string): number {
return [...value].length;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Task } from "./types.ts";
import { orderedTreeRows } from "./ui.ts";
export const COMPACTION_REMINDER_TYPE = "pi-todo-herdr-compaction";
export const COMPACTION_REMINDER =
"Active tasks exist. Call list_task now; treat its result as authoritative.";
export const TASK_PROMPT_GUIDELINES = [
"For work with 3+ distinct steps, explore enough to understand the scope, then call set_tasks before implementation; otherwise skip tracking. Start one task in-progress.",
"Use patch_tasks when tasks start, block, or finish; keep statuses accurate before the final response.",
];
export function formatCompactTaskTree(tasks: Task[]): string {
if (tasks.length === 0) return "No tasks.";
return orderedTreeRows(tasks)
.map(({ task, prefix }) => `${prefix}${task.id} [${task.status}] ${task.name}`)
.join("\n");
}
export function hasUnfinishedTasks(tasks: Task[]): boolean {
return tasks.some((task) => task.status !== "done");
}
+682
View File
@@ -0,0 +1,682 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import {
hasUnsafeTaskText,
MAX_TASK_DEPTH,
MAX_TASK_DESCRIPTION_LENGTH,
MAX_TASK_NAME_LENGTH,
MAX_TASKS,
MAX_TASKS_PER_MUTATION,
taskTextLength,
} from "./limits.ts";
import type {
AddTaskDraft,
Task,
TaskPatch,
TaskSnapshot,
TaskStats,
TaskStatus,
TaskTimingSnapshot,
TaskToolDetails,
} from "./types.ts";
import { TaskValidationError } from "./types.ts";
const MUTATION_TOOLS = new Set(["add_task", "set_tasks", "update_task", "patch_tasks", "rm_task"]);
export const TASK_STATE_ENTRY_TYPE = "pi-todo-herdr-state";
const VALID_STATUSES = new Set<TaskStatus>(["pending", "in-progress", "blocked", "done"]);
const MAX_DATE_MS = 8_640_000_000_000_000;
interface TaskRecord {
id: number;
name: string;
description?: string;
status: TaskStatus;
parentId: number | null;
startedAt?: number;
activeStartedAt?: number;
stoppedAt?: number;
accumulatedMs: number;
}
function cloneRecords(records: TaskRecord[]): TaskRecord[] {
return records.map((task) => ({ ...task }));
}
function indexRecords(records: TaskRecord[]): Map<number, TaskRecord> {
return new Map(records.map((task) => [task.id, task]));
}
function childIds(records: TaskRecord[], parentId: number | null): number[] {
return records.filter((task) => task.parentId === parentId).map((task) => task.id);
}
function snapshotTasks(records: TaskRecord[]): Task[] {
return records.map((task) => ({
id: task.id,
name: task.name,
...(task.description !== undefined ? { description: task.description } : {}),
status: task.status,
parentId: task.parentId,
childrenIds: childIds(records, task.id),
}));
}
function durationMs(task: TaskRecord, now: number): number {
const activeMs = task.activeStartedAt === undefined ? 0 : Math.max(0, now - task.activeStartedAt);
return Math.min(MAX_DATE_MS, task.accumulatedMs + activeMs);
}
function publicTasks(records: TaskRecord[], now: number): Task[] {
const byId = indexRecords(records);
return snapshotTasks(records).map((task) => {
const record = byId.get(task.id);
if (!record || record.startedAt === undefined) return task;
return {
...task,
startedAt: new Date(record.startedAt).toISOString(),
...(record.stoppedAt !== undefined ? { stoppedAt: new Date(record.stoppedAt).toISOString() } : {}),
durationMs: durationMs(record, now),
};
});
}
function timingSnapshots(records: TaskRecord[]): TaskTimingSnapshot[] {
const timings: TaskTimingSnapshot[] = [];
for (const task of records) {
if (task.startedAt === undefined) continue;
timings.push({
taskId: task.id,
startedAt: task.startedAt,
...(task.activeStartedAt !== undefined ? { activeStartedAt: task.activeStartedAt } : {}),
...(task.stoppedAt !== undefined ? { stoppedAt: task.stoppedAt } : {}),
accumulatedMs: task.accumulatedMs,
});
}
return timings;
}
function startTimer(task: TaskRecord, now: number): void {
const startedAt = Math.max(now, task.stoppedAt ?? task.startedAt ?? 0);
task.startedAt ??= startedAt;
task.activeStartedAt = startedAt;
task.stoppedAt = undefined;
}
function stopTimer(task: TaskRecord, now: number): void {
if (task.activeStartedAt === undefined) return;
const stoppedAt = Math.max(now, task.activeStartedAt);
task.accumulatedMs = Math.min(MAX_DATE_MS, task.accumulatedMs + stoppedAt - task.activeStartedAt);
task.activeStartedAt = undefined;
task.stoppedAt = stoppedAt;
}
function isNonNegativeSafeInteger(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
function isValidTimestamp(value: unknown): value is number {
return isNonNegativeSafeInteger(value) && value <= MAX_DATE_MS;
}
function collectDescendantIds(records: TaskRecord[], rootId: number): number[] {
const result: number[] = [];
const visit = (id: number) => {
for (const childId of childIds(records, id)) {
result.push(childId);
visit(childId);
}
};
visit(rootId);
return result;
}
function validateGraph(records: TaskRecord[]): string[] {
const errors: string[] = [];
const byId = indexRecords(records);
const ids = new Set<number>();
for (const task of records) {
if (!Number.isSafeInteger(task.id) || task.id < 1) {
errors.push(`task ${task.id}: id must be a positive safe integer`);
}
if (ids.has(task.id)) errors.push(`task ${task.id}: duplicate id`);
ids.add(task.id);
if (!task.name.trim()) errors.push(`task ${task.id}: name must not be empty`);
if (/[\r\n]/.test(task.name)) errors.push(`task ${task.id}: name must be a single line`);
if (hasUnsafeTaskText(task.name)) errors.push(`task ${task.id}: name contains an unsafe control character`);
if (taskTextLength(task.name) > MAX_TASK_NAME_LENGTH) {
errors.push(`task ${task.id}: name must be at most ${MAX_TASK_NAME_LENGTH} characters`);
}
if (task.description !== undefined) {
if (!task.description.trim()) errors.push(`task ${task.id}: description must not be empty`);
if (taskTextLength(task.description) > MAX_TASK_DESCRIPTION_LENGTH) {
errors.push(`task ${task.id}: description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`);
}
if (hasUnsafeTaskText(task.description)) {
errors.push(`task ${task.id}: description contains an unsafe control character`);
}
}
if (!VALID_STATUSES.has(task.status)) errors.push(`task ${task.id}: invalid status '${task.status}'`);
if (!isNonNegativeSafeInteger(task.accumulatedMs) || task.accumulatedMs > MAX_DATE_MS) {
errors.push(`task ${task.id}: accumulatedMs must be an integer between 0 and ${MAX_DATE_MS}`);
}
if (task.startedAt === undefined) {
if (task.activeStartedAt !== undefined || task.stoppedAt !== undefined || task.accumulatedMs !== 0) {
errors.push(`task ${task.id}: timer fields require startedAt`);
}
if (task.status === "in-progress") errors.push(`task ${task.id}: in-progress task is missing timer data`);
} else {
if (!isValidTimestamp(task.startedAt)) {
errors.push(`task ${task.id}: startedAt must be a valid non-negative timestamp`);
}
if (task.activeStartedAt !== undefined && !isValidTimestamp(task.activeStartedAt)) {
errors.push(`task ${task.id}: activeStartedAt must be a valid non-negative timestamp`);
}
if (task.stoppedAt !== undefined && !isValidTimestamp(task.stoppedAt)) {
errors.push(`task ${task.id}: stoppedAt must be a valid non-negative timestamp`);
}
if (task.activeStartedAt !== undefined && task.activeStartedAt < task.startedAt) {
errors.push(`task ${task.id}: activeStartedAt must not precede startedAt`);
}
if (task.stoppedAt !== undefined && task.stoppedAt < task.startedAt) {
errors.push(`task ${task.id}: stoppedAt must not precede startedAt`);
}
if (task.status === "in-progress") {
if (task.activeStartedAt === undefined) errors.push(`task ${task.id}: active timer is missing its segment start`);
if (task.stoppedAt !== undefined) errors.push(`task ${task.id}: active timer must not have stoppedAt`);
} else {
if (task.activeStartedAt !== undefined) errors.push(`task ${task.id}: inactive task has an active timer segment`);
if (task.stoppedAt === undefined) errors.push(`task ${task.id}: paused timer is missing stoppedAt`);
}
}
if (task.parentId !== null && !byId.has(task.parentId)) {
errors.push(`task ${task.id}: parent ${task.parentId} does not exist`);
}
}
const visiting = new Set<number>();
const visited = new Set<number>();
const visit = (id: number): boolean => {
if (visiting.has(id)) return true;
if (visited.has(id)) return false;
visiting.add(id);
const parentId = byId.get(id)?.parentId;
if (parentId !== null && parentId !== undefined && visit(parentId)) return true;
visiting.delete(id);
visited.add(id);
return false;
};
for (const task of records) {
if (visit(task.id)) {
errors.push(`task ${task.id}: parentId creates a cycle`);
break;
}
}
if (errors.length > 0) return errors;
const depths = new Map<number, number>();
const depthOf = (task: TaskRecord): number => {
const cached = depths.get(task.id);
if (cached !== undefined) return cached;
const parent = task.parentId === null ? undefined : byId.get(task.parentId);
const depth = parent === undefined ? 1 : depthOf(parent) + 1;
depths.set(task.id, depth);
return depth;
};
for (const task of records) {
if (depthOf(task) > MAX_TASK_DEPTH) {
errors.push(`task ${task.id}: depth must not exceed ${MAX_TASK_DEPTH} levels`);
}
}
if (errors.length > 0) return errors;
for (const task of records) {
if (task.status !== "done") continue;
const unfinished = collectDescendantIds(records, task.id).filter((id) => byId.get(id)?.status !== "done");
if (unfinished.length > 0) {
errors.push(`task ${task.id}: done task has unfinished descendants ${unfinished.join(", ")}`);
}
}
return errors;
}
function insertAtSiblingPosition(records: TaskRecord[], id: number, position: number): string | undefined {
const byId = indexRecords(records);
const task = byId.get(id);
if (!task) return `task ${id}: does not exist`;
const siblings = childIds(records, task.parentId).filter((siblingId) => siblingId !== id);
if (!Number.isInteger(position) || position < 0 || position > siblings.length) {
return `task ${id}: position must be between 0 and ${siblings.length}`;
}
const recordIndex = records.findIndex((record) => record.id === id);
const [record] = records.splice(recordIndex, 1);
if (!record) return `task ${id}: does not exist`;
if (position < siblings.length) {
const targetIndex = records.findIndex((candidate) => candidate.id === siblings[position]);
records.splice(targetIndex, 0, record);
return;
}
if (siblings.length === 0) {
records.push(record);
return;
}
const lastSiblingIndex = records.findIndex((candidate) => candidate.id === siblings[siblings.length - 1]);
records.splice(lastSiblingIndex + 1, 0, record);
}
function moveToSiblingEnd(records: TaskRecord[], id: number): void {
const task = records.find((candidate) => candidate.id === id);
if (!task) return;
const siblings = childIds(records, task.parentId).filter((siblingId) => siblingId !== id);
const currentIndex = records.findIndex((candidate) => candidate.id === id);
const [record] = records.splice(currentIndex, 1);
if (!record) return;
if (siblings.length === 0) {
records.push(record);
return;
}
const lastSiblingIndex = records.findIndex((candidate) => candidate.id === siblings[siblings.length - 1]);
records.splice(lastSiblingIndex + 1, 0, record);
}
export class TaskState {
private records: TaskRecord[] = [];
private nextId = 1;
private readonly now: () => number;
constructor(now: () => number = Date.now) {
this.now = now;
}
private currentTime(): number {
const now = Math.floor(this.now());
if (!isValidTimestamp(now)) throw new Error("task clock must return a valid non-negative timestamp");
return now;
}
list(): Task[] {
const now = this.records.some((task) => task.startedAt !== undefined) ? this.currentTime() : 0;
return publicTasks(this.records, now);
}
get(ids: number[]): Task[] {
const byId = new Map(this.list().map((task) => [task.id, task]));
return ids.map((id) => byId.get(id)).filter((task): task is Task => task !== undefined);
}
snapshot(): TaskSnapshot {
const timings = timingSnapshots(this.records);
return {
tasks: snapshotTasks(this.records),
nextId: this.nextId,
...(timings.length > 0 ? { timings } : {}),
};
}
stats(): TaskStats {
const tasks = this.records;
return {
total: tasks.length,
pending: tasks.filter((task) => task.status === "pending").length,
inProgress: tasks.filter((task) => task.status === "in-progress").length,
blocked: tasks.filter((task) => task.status === "blocked").length,
done: tasks.filter((task) => task.status === "done").length,
};
}
add(drafts: AddTaskDraft[]): Task[] {
const errors: string[] = [];
if (!Array.isArray(drafts) || drafts.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
if (drafts.length > MAX_TASKS_PER_MUTATION) {
throw new TaskValidationError([`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`]);
}
const working = cloneRecords(this.records);
const existingIds = new Set(working.map((task) => task.id));
const now = this.currentTime();
let nextId = this.nextId;
const addedIds: number[] = [];
let mutationTaskCount = 0;
const append = (
draft: AddTaskDraft,
implicitParentId: number | null,
path: string,
nested: boolean,
depth: number,
) => {
mutationTaskCount += 1;
if (mutationTaskCount > MAX_TASKS_PER_MUTATION) {
errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`);
return;
}
if (depth > MAX_TASK_DEPTH) {
errors.push(`${path}: depth must not exceed ${MAX_TASK_DEPTH} levels`);
return;
}
if (working.length >= MAX_TASKS) {
errors.push(`task session is limited to ${MAX_TASKS} tasks`);
return;
}
if (!draft || typeof draft !== "object") {
errors.push(`${path}: task must be an object`);
return;
}
if (typeof draft.name !== "string" || !draft.name.trim()) errors.push(`${path}.name must not be empty`);
if (typeof draft.name === "string" && /[\r\n]/.test(draft.name)) errors.push(`${path}.name must be a single line`);
if (typeof draft.name === "string" && hasUnsafeTaskText(draft.name)) {
errors.push(`${path}.name contains an unsafe control character`);
}
if (typeof draft.name === "string" && taskTextLength(draft.name.trim()) > MAX_TASK_NAME_LENGTH) {
errors.push(`${path}.name must be at most ${MAX_TASK_NAME_LENGTH} characters`);
}
if (draft.description !== undefined && draft.description !== null) {
if (typeof draft.description !== "string" || !draft.description.trim()) {
errors.push(`${path}.description must not be empty`);
} else {
if (hasUnsafeTaskText(draft.description)) {
errors.push(`${path}.description contains an unsafe control character`);
}
if (taskTextLength(draft.description.trim()) > MAX_TASK_DESCRIPTION_LENGTH) {
errors.push(`${path}.description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`);
}
}
}
if (draft.status !== undefined && draft.status !== null && !VALID_STATUSES.has(draft.status)) {
errors.push(`${path}.status is invalid`);
}
if (nested && draft.parentId !== undefined && draft.parentId !== null) {
errors.push(`${path}.parentId is not allowed inside children`);
}
if (draft.children !== undefined && !Array.isArray(draft.children)) errors.push(`${path}.children must be an array`);
let parentId = implicitParentId;
if (!nested && draft.parentId !== undefined && draft.parentId !== null) {
if (!Number.isInteger(draft.parentId) || !existingIds.has(draft.parentId)) {
errors.push(`${path}.parentId ${draft.parentId} does not exist`);
} else {
parentId = draft.parentId;
}
}
if (nextId >= Number.MAX_SAFE_INTEGER) {
errors.push("no safe task IDs remain");
return;
}
const id = nextId++;
const status = draft.status ?? "pending";
addedIds.push(id);
working.push({
id,
name: typeof draft.name === "string" ? draft.name.trim() : "",
...(typeof draft.description === "string" ? { description: draft.description.trim() } : {}),
status,
parentId,
accumulatedMs: 0,
...(status === "in-progress" ? { startedAt: now, activeStartedAt: now } : {}),
});
const children = Array.isArray(draft.children) ? draft.children : [];
for (const [index, child] of children.entries()) {
if (mutationTaskCount >= MAX_TASKS_PER_MUTATION) {
errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`);
break;
}
append(child, id, `${path}.children[${index}]`, true, depth + 1);
}
};
for (const [index, draft] of drafts.entries()) {
if (mutationTaskCount >= MAX_TASKS_PER_MUTATION) {
errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`);
break;
}
append(draft, null, `tasks[${index}]`, false, 1);
}
errors.push(...validateGraph(working));
if (errors.length > 0) throw new TaskValidationError([...new Set(errors)]);
this.records = working;
this.nextId = nextId;
return this.get(addedIds);
}
set(mode: "append" | "replace", drafts: AddTaskDraft[], force?: boolean): Task[] {
if (mode === "append") {
if (force === true) throw new TaskValidationError(["force must be false in append mode"]);
return this.add(drafts);
}
if (mode !== "replace") throw new TaskValidationError(["mode must be append or replace"]);
if (!Array.isArray(drafts)) throw new TaskValidationError(["tasks must be an array"]);
const errors: string[] = [];
if (this.records.some((task) => task.status !== "done") && force !== true) {
errors.push("replace requires force when unfinished tasks exist");
}
drafts.forEach((draft, index) => {
if (draft?.parentId !== undefined && draft.parentId !== null) {
errors.push(`tasks[${index}].parentId is not allowed in replace mode`);
}
});
if (errors.length > 0) throw new TaskValidationError(errors);
const replacement = new TaskState(this.now);
const affected = drafts.length === 0 ? [] : replacement.add(drafts);
this.restore(replacement.snapshot());
return affected;
}
update(patches: TaskPatch[]): Task[] {
const errors: string[] = [];
if (!Array.isArray(patches) || patches.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
if (patches.length > MAX_TASKS_PER_MUTATION) {
throw new TaskValidationError([`tasks must contain at most ${MAX_TASKS_PER_MUTATION} patches`]);
}
const duplicateIds = patches
.map((patch) => patch.id)
.filter((id, index, ids) => ids.indexOf(id) !== index);
if (duplicateIds.length > 0) errors.push(`duplicate task patches: ${[...new Set(duplicateIds)].join(", ")}`);
const working = cloneRecords(this.records);
const now = this.currentTime();
for (const [index, patch] of patches.entries()) {
const path = `tasks[${index}]`;
const task = working.find((candidate) => candidate.id === patch.id);
if (!task) {
errors.push(`${path}.id ${patch.id} does not exist`);
continue;
}
const hasNameChange = patch.name !== undefined && patch.name !== null;
const hasDescriptionChange = patch.description !== undefined && patch.description !== null;
const hasStatusChange = patch.status !== undefined && patch.status !== null;
const hasParentChange = patch.parentId !== undefined && patch.parentId !== -1;
const hasPositionChange = patch.position !== undefined && patch.position !== null && patch.position !== -1;
if (!hasNameChange && !hasDescriptionChange && !hasStatusChange && !hasParentChange && !hasPositionChange) {
errors.push(`${path} must include at least one field to update`);
}
if (hasNameChange && (typeof patch.name !== "string" || !patch.name.trim())) {
errors.push(`${path}.name must not be empty`);
}
if (typeof patch.name === "string" && /[\r\n]/.test(patch.name)) errors.push(`${path}.name must be a single line`);
if (typeof patch.name === "string" && hasUnsafeTaskText(patch.name)) {
errors.push(`${path}.name contains an unsafe control character`);
}
if (typeof patch.name === "string" && taskTextLength(patch.name.trim()) > MAX_TASK_NAME_LENGTH) {
errors.push(`${path}.name must be at most ${MAX_TASK_NAME_LENGTH} characters`);
}
if (hasDescriptionChange && (typeof patch.description !== "string" || !patch.description.trim())) {
errors.push(`${path}.description must not be empty`);
}
if (typeof patch.description === "string") {
if (hasUnsafeTaskText(patch.description)) {
errors.push(`${path}.description contains an unsafe control character`);
}
if (taskTextLength(patch.description.trim()) > MAX_TASK_DESCRIPTION_LENGTH) {
errors.push(`${path}.description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`);
}
}
if (hasStatusChange && !VALID_STATUSES.has(patch.status as TaskStatus)) errors.push(`${path}.status is invalid`);
if (
hasParentChange &&
patch.parentId !== null &&
patch.parentId !== 0 &&
(!Number.isInteger(patch.parentId) || !working.some((candidate) => candidate.id === patch.parentId))
) {
errors.push(`${path}.parentId ${patch.parentId} does not exist`);
}
if (patch.name !== undefined && patch.name !== null) task.name = patch.name.trim();
if (patch.description !== undefined && patch.description !== null) task.description = patch.description.trim();
if (patch.status !== undefined && patch.status !== null && patch.status !== task.status) {
if (task.status === "in-progress" && patch.status !== "in-progress") stopTimer(task, now);
if (task.status !== "in-progress" && patch.status === "in-progress") startTimer(task, now);
task.status = patch.status;
}
if (
patch.parentId !== undefined &&
patch.parentId !== -1 &&
(patch.parentId === null ||
patch.parentId === 0 ||
working.some((item) => item.id === patch.parentId))
) {
task.parentId = patch.parentId === 0 ? null : patch.parentId;
moveToSiblingEnd(working, task.id);
}
if (patch.position !== undefined && patch.position !== null && patch.position !== -1) {
const positionError = insertAtSiblingPosition(working, task.id, patch.position);
if (positionError) errors.push(`${path}.position: ${positionError}`);
}
}
errors.push(...validateGraph(working));
if (errors.length > 0) throw new TaskValidationError([...new Set(errors)]);
this.records = working;
return this.get(patches.map((patch) => patch.id));
}
remove(ids: number[]): Task[] {
if (!Array.isArray(ids) || ids.length === 0) throw new TaskValidationError(["ids must not be empty"]);
if (ids.length > MAX_TASKS_PER_MUTATION) {
throw new TaskValidationError([`ids must contain at most ${MAX_TASKS_PER_MUTATION} entries`]);
}
const uniqueIds = [...new Set(ids)];
const missing = uniqueIds.filter((id) => !this.records.some((task) => task.id === id));
if (missing.length > 0) throw new TaskValidationError([`task ids do not exist: ${missing.join(", ")}`]);
const removedIds = new Set<number>();
for (const id of uniqueIds) {
removedIds.add(id);
for (const descendantId of collectDescendantIds(this.records, id)) removedIds.add(descendantId);
}
const now = this.currentTime();
for (const task of this.records) {
if (removedIds.has(task.id) && task.status === "in-progress") stopTimer(task, now);
}
const removed = publicTasks(this.records, now).filter((task) => removedIds.has(task.id));
this.records = this.records.filter((task) => !removedIds.has(task.id));
return removed;
}
restore(snapshot: TaskSnapshot): void {
if (!snapshot || !Array.isArray(snapshot.tasks)) {
throw new TaskValidationError(["invalid task snapshot"]);
}
if (!Number.isSafeInteger(snapshot.nextId) || snapshot.nextId < 1) {
throw new TaskValidationError(["nextId must be a positive safe integer"]);
}
if (snapshot.timings !== undefined && !Array.isArray(snapshot.timings)) {
throw new TaskValidationError(["invalid task snapshot timings"]);
}
if (snapshot.tasks.length > MAX_TASKS) {
throw new TaskValidationError([`task session is limited to ${MAX_TASKS} tasks`]);
}
if ((snapshot.timings?.length ?? 0) > MAX_TASKS) {
throw new TaskValidationError([`task snapshot timings are limited to ${MAX_TASKS} entries`]);
}
const records = snapshot.tasks.map(({ id, name, description, status, parentId }) => ({
id,
name,
description,
status,
parentId,
accumulatedMs: 0,
}));
const byId = indexRecords(records);
const timingErrors: string[] = [];
const timedIds = new Set<number>();
for (const [index, timing] of (snapshot.timings ?? []).entries()) {
const path = `timings[${index}]`;
if (!timing || typeof timing !== "object") {
timingErrors.push(`${path} must be an object`);
continue;
}
if (!Number.isSafeInteger(timing.taskId) || timing.taskId < 1) {
timingErrors.push(`${path}.taskId must be a positive safe integer`);
continue;
}
if (timedIds.has(timing.taskId)) {
timingErrors.push(`${path}.taskId ${timing.taskId} is duplicated`);
continue;
}
timedIds.add(timing.taskId);
const task = byId.get(timing.taskId);
if (!task) {
timingErrors.push(`${path}.taskId ${timing.taskId} does not exist`);
continue;
}
task.startedAt = timing.startedAt;
task.activeStartedAt = timing.activeStartedAt;
task.stoppedAt = timing.stoppedAt;
task.accumulatedMs = timing.accumulatedMs;
}
if (timingErrors.length > 0) throw new TaskValidationError(timingErrors);
if (snapshot.timings === undefined && records.some((task) => task.status === "in-progress")) {
const now = this.currentTime();
for (const task of records) {
if (task.status === "in-progress") startTimer(task, now);
}
}
const errors = validateGraph(records);
const maxId = records.reduce((max, task) => Math.max(max, task.id), 0);
if (snapshot.nextId <= maxId) errors.push(`nextId must be greater than ${maxId}`);
if (errors.length > 0) throw new TaskValidationError(errors);
this.records = cloneRecords(records);
this.nextId = snapshot.nextId;
}
loadFromSession(ctx: ExtensionContext): void {
this.records = [];
this.nextId = 1;
for (const entry of ctx.sessionManager.getBranch()) {
let snapshot: TaskSnapshot | undefined;
if (entry.type === "custom" && entry.customType === TASK_STATE_ENTRY_TYPE) {
snapshot = entry.data as TaskSnapshot | undefined;
} else if (entry.type === "message") {
const message = entry.message;
if (message.role === "toolResult" && MUTATION_TOOLS.has(message.toolName)) {
snapshot = (message.details as TaskToolDetails | undefined)?.snapshot;
}
}
if (!snapshot) continue;
try {
this.restore(snapshot);
} catch {
// Ignore corrupt historical snapshots and keep the latest valid state.
}
}
}
}
+471
View File
@@ -0,0 +1,471 @@
import { StringEnum } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { Type, type Static } from "typebox";
import {
MAX_TASK_DEPTH,
MAX_TASK_DESCRIPTION_LENGTH,
MAX_TASK_NAME_LENGTH,
MAX_TASKS_PER_MUTATION,
TASK_DESCRIPTION_PATTERN,
TASK_NAME_PATTERN,
} from "./limits.ts";
import { formatCompactTaskTree, TASK_PROMPT_GUIDELINES } from "./prompt.ts";
import type { TaskState } from "./state.ts";
import type { AddTaskDraft, Task, TaskPatch, TaskToolDetails } from "./types.ts";
import { TaskValidationError } from "./types.ts";
import { renderToolResult } from "./ui.ts";
const TaskStatusSchema = StringEnum(["pending", "in-progress", "blocked", "done"] as const, {
description: "Task status.",
});
const SetTaskModeSchema = StringEnum(["append", "replace"] as const);
const RemovalReasonSchema = StringEnum(["mistaken", "duplicate", "canceled"] as const);
/**
* TypeBox's Type.Cyclic emits bare local refs ("AddTask") instead of URI
* references ("#/$defs/AddTask"). Strict JSON Schema gateways (e.g. the
* moonshot-flavored validator behind Console Go) reject those with a 400.
* Rewrite every local $ref in place to the #/$defs/ form.
*/
function normalizeLocalRefs<T>(schema: T): T {
if (Array.isArray(schema)) {
for (const item of schema) normalizeLocalRefs(item);
return schema;
}
if (schema && typeof schema === "object") {
for (const [key, value] of Object.entries(schema)) {
if (key === "$ref" && typeof value === "string" && !value.startsWith("#") && !value.includes("://")) {
(schema as Record<string, unknown>)[key] = `#/$defs/${value}`;
} else {
normalizeLocalRefs(value);
}
}
}
return schema;
}
/**
* TypeBox's Type.Cyclic nests $defs inside the referenced item schema, but
* refs like "#/$defs/AddTask" resolve from the document root. Strict gateways
* (moonshot-flavored) reject a root-style ref when the definition is nested.
* Hoist every $defs entry to the schema root and drop the synthetic $id, which
* would otherwise change fragment resolution inside the definition.
*/
function hoistDefsToRoot<T>(schema: T): T {
const defs = new Map<string, unknown>();
function walk(node: unknown): void {
if (Array.isArray(node)) {
for (const item of node) walk(item);
return;
}
if (!node || typeof node !== "object") return;
const obj = node as Record<string, unknown>;
const nested = obj["$defs"];
if (nested && typeof nested === "object") {
for (const [name, def] of Object.entries(nested)) {
if (!defs.has(name)) defs.set(name, def);
}
delete obj["$defs"];
}
for (const value of Object.values(obj)) walk(value);
}
walk(schema);
for (const def of defs.values()) {
if (def && typeof def === "object") {
delete (def as Record<string, unknown>)["$id"];
}
}
if (defs.size > 0) (schema as Record<string, unknown>)["$defs"] = Object.fromEntries(defs);
return schema;
}
const AddTaskSchema = Type.Cyclic(
{
AddTask: Type.Object(
{
name: Type.String({
minLength: 1,
maxLength: MAX_TASK_NAME_LENGTH,
pattern: TASK_NAME_PATTERN,
}),
description: Type.Union(
[
Type.String({
minLength: 1,
maxLength: MAX_TASK_DESCRIPTION_LENGTH,
pattern: TASK_DESCRIPTION_PATTERN,
}),
Type.Null(),
],
{
description: "Detailed scope, constraints, and done criteria; use null when no detail is needed.",
},
),
status: TaskStatusSchema,
parentId: Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], {
description: "Existing parent ID for a top-level append; use null for roots, nested children, and replace mode.",
}),
children: Type.Array(Type.Ref("AddTask"), { maxItems: MAX_TASKS_PER_MUTATION }),
},
{ additionalProperties: false },
),
},
"AddTask",
);
export const SetTaskParams = hoistDefsToRoot(
normalizeLocalRefs(
Type.Object(
{
mode: SetTaskModeSchema,
tasks: Type.Array(AddTaskSchema, {
maxItems: MAX_TASKS_PER_MUTATION,
description: "Tasks to append or install. May be empty only in replace mode.",
}),
force: Type.Boolean({
description: "Set true only when the user explicitly abandons unfinished tasks; otherwise use false.",
}),
},
{ additionalProperties: false },
),
),
);
const TaskPatchSchema = Type.Object(
{
id: Type.Integer({ minimum: 1 }),
name: Type.Union(
[
Type.String({ minLength: 1, maxLength: MAX_TASK_NAME_LENGTH, pattern: TASK_NAME_PATTERN }),
Type.Null(),
],
{ description: "New name, or null to leave it unchanged." },
),
description: Type.Union(
[
Type.String({
minLength: 1,
maxLength: MAX_TASK_DESCRIPTION_LENGTH,
pattern: TASK_DESCRIPTION_PATTERN,
}),
Type.Null(),
],
{ description: "New description, or null to leave it unchanged." },
),
status: Type.Union([TaskStatusSchema, Type.Null()], {
description: "New status, or null to leave it unchanged.",
}),
parentId: Type.Integer({
minimum: -1,
description: "New parent ID; use -1 to leave it unchanged and 0 to move to root.",
}),
position: Type.Integer({
minimum: -1,
description: "Zero-based position among final siblings, or -1 to leave it unchanged.",
}),
},
{ additionalProperties: false },
);
export const PatchTasksParams = Type.Object(
{
tasks: Type.Array(TaskPatchSchema, {
minItems: 1,
maxItems: MAX_TASKS_PER_MUTATION,
description: "Task patches applied atomically in array order.",
}),
},
{ additionalProperties: false },
);
const TaskIdsSchema = Type.Array(Type.Integer({ minimum: 1 }), {
minItems: 1,
maxItems: MAX_TASKS_PER_MUTATION,
description: "Task IDs to process. Duplicate IDs are deduplicated.",
});
export const RemoveTaskParams = Type.Object(
{
ids: TaskIdsSchema,
reason: RemovalReasonSchema,
},
{ additionalProperties: false },
);
export const IdsParams = Type.Object({ ids: TaskIdsSchema }, { additionalProperties: false });
export const ListTaskParams = Type.Object({}, { additionalProperties: false });
interface DraftPreparationState {
count: number;
}
function prepareTaskDraft(value: unknown, depth: number, state: DraftPreparationState): unknown {
state.count += 1;
if (depth > MAX_TASK_DEPTH || state.count > MAX_TASKS_PER_MUTATION) return {};
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const input = value as Record<string, unknown>;
const children =
input.children === undefined
? []
: Array.isArray(input.children)
? input.children
.slice(0, MAX_TASKS_PER_MUTATION + 1)
.map((child) => prepareTaskDraft(child, depth + 1, state))
: input.children;
return {
...input,
description: input.description === undefined ? null : input.description,
status: input.status === undefined ? "pending" : input.status,
parentId: input.parentId === undefined ? null : input.parentId,
children,
};
}
function prepareSetTaskArguments(value: unknown): Static<typeof SetTaskParams> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value as Static<typeof SetTaskParams>;
}
const input = value as Record<string, unknown>;
const state: DraftPreparationState = { count: 0 };
return {
...input,
tasks: Array.isArray(input.tasks)
? input.tasks
.slice(0, MAX_TASKS_PER_MUTATION + 1)
.map((task) => prepareTaskDraft(task, 1, state))
: input.tasks,
force: input.force === undefined ? false : input.force,
} as Static<typeof SetTaskParams>;
}
function prepareTaskPatch(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const input = value as Record<string, unknown>;
return {
...input,
name: input.name === undefined ? null : input.name,
description: input.description === undefined ? null : input.description,
status: input.status === undefined ? null : input.status,
parentId: input.parentId === undefined ? -1 : input.parentId === null ? 0 : input.parentId,
position: input.position === undefined || input.position === null ? -1 : input.position,
};
}
function preparePatchTasksArguments(value: unknown): Static<typeof PatchTasksParams> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value as Static<typeof PatchTasksParams>;
}
const input = value as Record<string, unknown>;
return {
...input,
tasks: Array.isArray(input.tasks)
? input.tasks.slice(0, MAX_TASKS_PER_MUTATION + 1).map(prepareTaskPatch)
: input.tasks,
} as Static<typeof PatchTasksParams>;
}
interface SetTaskInput {
mode: "append" | "replace";
tasks: AddTaskDraft[];
force?: boolean;
}
interface PatchTasksInput {
tasks: TaskPatch[];
}
interface IdsInput {
ids: number[];
}
interface RemoveTaskInput extends IdsInput {
reason: "mistaken" | "duplicate" | "canceled";
}
type ChangeHandler = (ctx: ExtensionContext, restartAutoClear?: boolean) => void;
function taskIds(tasks: Task[]): string {
return tasks.map((task) => task.id).join(", ");
}
function details(action: TaskToolDetails["action"], state: TaskState, affected: Task[]): TaskToolDetails {
return { action, snapshot: state.snapshot(), affected };
}
function mutationText(verb: string, affected: Task[], state: TaskState): string {
const stats = state.stats();
return `${verb} ${affected.length} task${affected.length === 1 ? "" : "s"}: ${taskIds(affected)}. ${stats.done}/${stats.total} done.`;
}
function setTaskText(mode: SetTaskInput["mode"], affected: Task[], state: TaskState): string {
if (mode === "append") return mutationText("Added", affected, state);
if (affected.length === 0) return "Cleared task tree. Task IDs reset to 1.";
const stats = state.stats();
return `Replaced task tree with ${affected.length} task${affected.length === 1 ? "" : "s"}. ${stats.done}/${stats.total} done. Task IDs restart at 1.`;
}
function throwToolError(label: string, error: unknown): never {
if (error instanceof TaskValidationError) {
const count = error.errors.length;
throw new Error(
`${label} failed · ${count} validation error${count === 1 ? "" : "s"}\n${error.errors.map((item) => `- ${item}`).join("\n")}`,
);
}
throw error;
}
function renderCall(title: string, count: number | undefined, theme: Theme): Text {
const suffix = count === undefined ? "" : theme.fg("dim", ` (${count})`);
return new Text(theme.fg("toolTitle", theme.bold(title)) + suffix, 0, 0);
}
function notifyChange(onChange: ChangeHandler, ctx: ExtensionContext, restartAutoClear = false): void {
try {
onChange(ctx, restartAutoClear);
} catch (error) {
console.error("[pi-todo-herdr] failed to refresh task UI:", error);
}
}
export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange: ChangeHandler): void {
pi.registerTool({
name: "set_tasks",
label: "Set Tasks",
description:
`Atomically append tasks or replace the task tree. Supply at most ${MAX_TASKS_PER_MUTATION} total tasks across ${MAX_TASK_DEPTH} stored levels: root categories, child items, and leaf sub-items beneath the widget's virtual root. Pass force false unless unfinished work is explicitly discarded.`,
promptGuidelines: [...TASK_PROMPT_GUIDELINES],
parameters: SetTaskParams,
prepareArguments: prepareSetTaskArguments,
async execute(_toolCallId, params: SetTaskInput, _signal, _onUpdate, ctx) {
try {
const affected = state.set(params.mode, params.tasks, params.force);
notifyChange(onChange, ctx, params.mode === "replace");
return {
content: [{ type: "text" as const, text: setTaskText(params.mode, affected, state) }],
details: details(params.mode, state, affected),
};
} catch (error) {
throwToolError("Set", error);
}
},
renderCall(args: SetTaskInput, theme: Theme) {
return renderCall(`Set Tasks · ${args.mode}`, args.tasks?.length, theme);
},
renderResult(result, { expanded }, theme) {
return renderToolResult(result, expanded, theme);
},
});
pi.registerTool({
name: "patch_tasks",
label: "Patch Tasks",
description:
"Atomically patch tasks. Use null for unchanged text/status, parentId -1 for unchanged or 0 for root, and position -1 for unchanged. Completed task trees may auto-clear; do not recreate cleared completed tasks.",
parameters: PatchTasksParams,
prepareArguments: preparePatchTasksArguments,
async execute(_toolCallId, params: PatchTasksInput, _signal, _onUpdate, ctx) {
try {
if (state.list().length === 0) {
throw new TaskValidationError([
"task tree is empty and may have auto-cleared after completion; do not recreate completed tasks. Use set_tasks only for genuinely new or remaining work",
]);
}
const affected = state.update(params.tasks);
notifyChange(onChange, ctx);
return {
content: [{ type: "text" as const, text: mutationText("Updated", affected, state) }],
details: details("update", state, affected),
};
} catch (error) {
throwToolError("Patch", error);
}
},
renderCall(args: PatchTasksInput, theme: Theme) {
return renderCall("Patch Tasks", args.tasks?.length, theme);
},
renderResult(result, { expanded }, theme) {
return renderToolResult(result, expanded, theme);
},
});
pi.registerTool({
name: "rm_task",
label: "Remove Tasks",
description:
"Remove tasks only when mistaken, duplicate, or canceled. Removing a parent also removes its descendant subtree.",
parameters: RemoveTaskParams,
async execute(_toolCallId, params: RemoveTaskInput, _signal, _onUpdate, ctx) {
try {
const affected = state.remove(params.ids);
notifyChange(onChange, ctx);
return {
content: [
{
type: "text" as const,
text: `${mutationText("Removed", affected, state)} Reason: ${params.reason}.`,
},
],
details: details("remove", state, affected),
};
} catch (error) {
throwToolError("Remove", error);
}
},
renderCall(args: RemoveTaskInput, theme: Theme) {
return renderCall(`Remove Tasks · ${args.reason}`, args.ids?.length, theme);
},
renderResult(result, { expanded }, theme) {
return renderToolResult(result, expanded, theme);
},
});
pi.registerTool({
name: "list_task",
label: "List Tasks",
description: "List the full task tree compactly with id, name, and status.",
parameters: ListTaskParams,
async execute() {
const affected = state.list();
return {
content: [{ type: "text" as const, text: formatCompactTaskTree(affected) }],
details: details("list", state, affected),
};
},
renderCall(_args, theme: Theme) {
return renderCall("List Tasks", undefined, theme);
},
renderResult(result, { expanded }, theme) {
return renderToolResult(result, expanded, theme);
},
});
pi.registerTool({
name: "get_task",
label: "Get Tasks",
description: "Get complete details for one or more task IDs, including parent and child IDs.",
parameters: IdsParams,
async execute(_toolCallId, params: IdsInput) {
const uniqueIds = [...new Set(params.ids)];
const affected = state.get(uniqueIds);
const found = new Set(affected.map((task) => task.id));
const missing = uniqueIds.filter((id) => !found.has(id));
if (missing.length > 0) {
throwToolError("Get", new TaskValidationError([`task ids do not exist: ${missing.join(", ")}`]));
}
return {
content: [{ type: "text" as const, text: JSON.stringify(affected) }],
details: details("get", state, affected),
};
},
renderCall(args: IdsInput, theme: Theme) {
return renderCall("Get Tasks", args.ids?.length, theme);
},
renderResult(result, { expanded }, theme) {
return renderToolResult(result, expanded, theme);
},
});
}
+69
View File
@@ -0,0 +1,69 @@
export type TaskStatus = "pending" | "in-progress" | "blocked" | "done";
export interface Task {
id: number;
name: string;
description?: string;
status: TaskStatus;
parentId: number | null;
childrenIds: number[];
startedAt?: string;
stoppedAt?: string;
durationMs?: number;
}
export interface TaskTimingSnapshot {
taskId: number;
startedAt: number;
activeStartedAt?: number;
stoppedAt?: number;
accumulatedMs: number;
}
export interface TaskSnapshot {
tasks: Task[];
nextId: number;
timings?: TaskTimingSnapshot[];
}
export interface AddTaskDraft {
name: string;
description?: string | null;
status?: TaskStatus | null;
parentId?: number | null;
children?: AddTaskDraft[];
}
export interface TaskPatch {
id: number;
name?: string | null;
description?: string | null;
status?: TaskStatus | null;
parentId?: number | null;
position?: number | null;
}
export interface TaskStats {
total: number;
pending: number;
inProgress: number;
blocked: number;
done: number;
}
export interface TaskToolDetails {
action: "add" | "append" | "replace" | "update" | "remove" | "list" | "get";
snapshot: TaskSnapshot;
affected: Task[];
errors?: string[];
}
export class TaskValidationError extends Error {
readonly errors: string[];
constructor(errors: string[]) {
super(errors.join("\n"));
this.name = "TaskValidationError";
this.errors = errors;
}
}
+422
View File
@@ -0,0 +1,422 @@
import type { Component } from "@earendil-works/pi-tui";
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
import type { Theme } from "@earendil-works/pi-coding-agent";
import { MAX_WIDGET_TASK_ROWS } from "./limits.ts";
import type { Task, TaskStats, TaskToolDetails } from "./types.ts";
export const STATUS_ICONS = {
pending: "○",
"in-progress": "◉",
blocked: "!",
done: "✓",
} as const;
const IN_PROGRESS_WIDGET_FRAMES = ["◐", "◓", "◑", "◒"] as const;
interface DescendantStats {
done: number;
total: number;
}
interface TreeRow {
task: Task;
prefix: string;
depth: number;
siblingIndex: number;
collapsed?: DescendantStats;
}
function descendantStats(task: Task, byId: Map<number, Task>, memo: Map<number, DescendantStats>): DescendantStats {
const cached = memo.get(task.id);
if (cached) return cached;
let done = 0;
let total = 0;
for (const childId of task.childrenIds) {
const child = byId.get(childId);
if (!child) continue;
total += 1;
if (child.status === "done") done += 1;
const nested = descendantStats(child, byId, memo);
done += nested.done;
total += nested.total;
}
const stats = { done, total };
memo.set(task.id, stats);
return stats;
}
function buildTreeRows(tasks: Task[], collapseCompleted: boolean, connectRoots = false): TreeRow[] {
const byId = new Map(tasks.map((task) => [task.id, task]));
const memo = new Map<number, DescendantStats>();
const rows: TreeRow[] = [];
const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number, siblingIndex: number) => {
const ancestors = ancestorHasNext.map((hasNext) => (hasNext ? "│ " : " ")).join("");
const connector = depth === 0 && !connectRoots ? "" : isLast ? "└─ " : "├─ ";
const stats = collapseCompleted ? descendantStats(task, byId, memo) : undefined;
const collapsed = stats && stats.total > 0 && stats.done === stats.total ? stats : undefined;
rows.push({ task, prefix: `${ancestors}${connector}`, depth, siblingIndex, ...(collapsed ? { collapsed } : {}) });
if (collapsed) return;
const children = task.childrenIds.map((id) => byId.get(id)).filter((child): child is Task => child !== undefined);
children.forEach((child, index) => {
const childAncestors = depth === 0 && !connectRoots ? [] : [...ancestorHasNext, !isLast];
visit(child, childAncestors, index === children.length - 1, depth + 1, index);
});
};
const roots = tasks.filter((task) => task.parentId === null);
roots.forEach((root, index) => visit(root, [], index === roots.length - 1, 0, index));
return rows;
}
export function orderedTreeRows(tasks: Task[]): TreeRow[] {
return buildTreeRows(tasks, false);
}
function styleTaskName(task: Task, theme: Theme): string {
if (task.status === "done") return theme.fg("dim", theme.strikethrough(task.name));
if (task.status === "in-progress") return theme.fg("warning", task.name);
return task.name;
}
function styleStatusIcon(task: Task, theme: Theme, inProgressFrame?: number): string {
const frameIndex = inProgressFrame === undefined ? undefined : Math.abs(Math.trunc(inProgressFrame));
const icon =
task.status === "in-progress" && frameIndex !== undefined
? IN_PROGRESS_WIDGET_FRAMES[frameIndex % IN_PROGRESS_WIDGET_FRAMES.length]
: STATUS_ICONS[task.status];
if (task.status === "done") return theme.fg("success", icon);
if (task.status === "in-progress") return theme.fg("warning", icon);
if (task.status === "blocked") return theme.fg("error", icon);
return theme.fg("dim", icon);
}
export function formatDuration(durationMs: number): string {
const safeMs = Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : 0;
const totalSeconds = Math.floor(safeMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const minuteClock = [minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
return hours === 0 ? minuteClock : `${String(hours).padStart(2, "0")}:${minuteClock}`;
}
function renderTaskLineWithSuffix(
task: Task,
prefix: string,
theme: Theme,
width: number,
suffix?: string,
identifier = `${task.id}.`,
identifierBeforeStatus = false,
inProgressFrame?: number,
): string {
const status = styleStatusIcon(task, theme, inProgressFrame);
const styledIdentifier = identifier.length === 0 ? "" : theme.fg("accent", identifier);
const identifierFits =
!identifierBeforeStatus ||
visibleWidth(prefix) + visibleWidth(styledIdentifier) + 1 + visibleWidth(status) <= width;
const visibleIdentifier = identifierFits ? styledIdentifier : "";
const markers = identifierBeforeStatus ? [visibleIdentifier, status] : [status, visibleIdentifier];
const leading = theme.fg("dim", prefix) + markers.filter((marker) => marker.length > 0).join(" ");
const styledName = styleTaskName(task, theme);
const renderMain = (targetWidth: number): string => {
const safeWidth = Math.max(1, targetWidth);
if (!identifierBeforeStatus) return truncateToWidth(`${leading} ${styledName}`, safeWidth);
const availableNameWidth = safeWidth - visibleWidth(leading) - 1;
if (availableNameWidth < 1) return truncateToWidth(leading, safeWidth);
return `${leading} ${truncateToWidth(styledName, availableNameWidth)}`;
};
const trailing = [
suffix === undefined ? undefined : theme.fg("dim", ` · ${suffix}`),
task.durationMs === undefined ? undefined : theme.fg("dim", ` · ${formatDuration(task.durationMs)}`),
]
.filter((part): part is string => part !== undefined)
.join("");
if (trailing.length === 0) return renderMain(width);
const trailingWidth = visibleWidth(trailing);
const minimumTaskWidth = Math.max(suffix === undefined ? 6 : 12, visibleWidth(leading));
if (width <= trailingWidth + minimumTaskWidth) return renderMain(width);
const taskWidth = Math.max(1, width - trailingWidth);
return renderMain(taskWidth) + trailing;
}
export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: number): string {
return renderTaskLineWithSuffix(task, prefix, theme, width);
}
const STATUS_ICON_WIDTH = 1;
const TRUNCATED_STATUS_WIDTH = STATUS_ICON_WIDTH + 3;
function indentDeepestLeaf(
prefix: string,
depth: number,
maximumDepth: number,
width: number,
minimumContentWidth: number,
): string {
if (depth === 0 || depth !== maximumDepth) return prefix;
const indentedPrefix = `${prefix} `;
return visibleWidth(indentedPrefix) + minimumContentWidth <= width ? indentedPrefix : prefix;
}
export function renderTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] {
const maximumDepth = maximumTaskDepth(tasks);
return orderedTreeRows(tasks).map(({ task, prefix, depth }) =>
renderTaskLine(
task,
indentDeepestLeaf(`${padding}${prefix}`, depth, maximumDepth, width, TRUNCATED_STATUS_WIDTH),
theme,
width,
),
);
}
function toRomanNumeral(value: number): string {
const numerals: Array<[number, string]> = [
[100, "C"],
[90, "XC"],
[50, "L"],
[40, "XL"],
[10, "X"],
[9, "IX"],
[5, "V"],
[4, "IV"],
[1, "I"],
];
let remaining = value;
let result = "";
for (const [amount, numeral] of numerals) {
while (remaining >= amount) {
result += numeral;
remaining -= amount;
}
}
return result;
}
function maximumTaskDepth(tasks: Task[]): number {
const byId = new Map(tasks.map((task) => [task.id, task]));
let maximumDepth = 0;
for (const task of tasks) {
let depth = 0;
let parentId = task.parentId;
while (parentId !== null) {
depth += 1;
parentId = byId.get(parentId)?.parentId ?? null;
}
maximumDepth = Math.max(maximumDepth, depth);
}
return maximumDepth;
}
function widgetOutlineLabel(depth: number, siblingIndex: number, maximumDepth: number): string {
if (depth === maximumDepth) return "";
if (maximumDepth === 2 && depth === 0) return `${toRomanNumeral(siblingIndex + 1)}.`;
return `${siblingIndex + 1}.`;
}
export function renderWidgetTree(
tasks: Task[],
theme: Theme,
width: number,
padding = " ",
inProgressFrame?: number,
maxBodyRows?: number,
): string[] {
const rows = buildTreeRows(tasks, true, true);
const maximumDepth = maximumTaskDepth(tasks);
const outputLimit =
maxBodyRows === undefined ? MAX_WIDGET_TASK_ROWS : Math.max(1, Math.floor(Number.isFinite(maxBodyRows) ? maxBodyRows : 1));
const hasOverflow = rows.length > outputLimit;
// The legacy, standalone widget allows twelve task rows plus one summary.
// A combined panel treats maxBodyRows as a strict total body-line budget.
const taskRowLimit = maxBodyRows === undefined ? outputLimit : hasOverflow ? Math.max(0, outputLimit - 1) : outputLimit;
const visibleRows = rows.slice(0, taskRowLimit);
const lines = visibleRows.map(({ task, prefix, depth, siblingIndex, collapsed }) => {
const summary = collapsed === undefined ? undefined : `${collapsed.done}/${collapsed.total} done`;
const identifier = widgetOutlineLabel(depth, siblingIndex, maximumDepth);
return renderTaskLineWithSuffix(
task,
indentDeepestLeaf(`${padding}${prefix}`, depth, maximumDepth, width, STATUS_ICON_WIDTH),
theme,
width,
summary,
identifier,
true,
inProgressFrame,
);
});
if (!hasOverflow) return lines;
const hiddenTasks = rows.slice(taskRowLimit).reduce(
(count, row) => count + 1 + (row.collapsed?.total ?? 0),
0,
);
const label = `${padding}… ${hiddenTasks} more task${hiddenTasks === 1 ? "" : "s"}`;
return [...lines, truncateToWidth(theme.fg("dim", label), Math.max(1, width))];
}
export function progressLabel(stats: TaskStats): string {
const blocked = stats.blocked > 0 ? ` · !${stats.blocked}` : "";
return ` Todos ${stats.done}/${stats.total}${blocked}`;
}
export function renderProgressHeader(stats: TaskStats, theme: Theme, width: number): string {
const label = progressLabel(stats);
const available = width - visibleWidth(label) - 2;
if (available < 5) return truncateToWidth(theme.fg("accent", label), Math.max(1, width));
const barWidth = Math.min(20, available);
const filled = stats.total === 0 ? 0 : Math.round((stats.done / stats.total) * barWidth);
const filledColor = stats.done === stats.total ? "success" : "accent";
const bar =
theme.fg(filledColor, theme.bold("━".repeat(filled))) + theme.fg("dim", "─".repeat(barWidth - filled));
return truncateToWidth(`${theme.fg("accent", label)} ${bar}`, Math.max(1, width));
}
function compactProgressBar(stats: TaskStats, theme: Theme): string {
const width = 8;
const filled = stats.total === 0 ? 0 : Math.round((stats.done / stats.total) * width);
const filledColor = stats.done === stats.total ? "success" : "accent";
return (
theme.fg(filledColor, theme.bold("━".repeat(filled))) +
theme.fg("borderMuted", "─".repeat(width - filled))
);
}
/** Render the compact, unboxed Todo header and task rows. */
export function renderTodoPanel(
tasks: Task[],
theme: Theme,
width: number,
inProgressFrame?: number,
maxBodyRows?: number,
): string[] {
if (!Number.isFinite(width) || width <= 0) return [];
const safeWidth = Math.max(1, Math.floor(width));
const stats = snapshotStats(tasks);
const title = theme.fg("accent", "Todos");
const count = theme.fg("muted", `${stats.done}/${stats.total}${stats.blocked > 0 ? ` · !${stats.blocked}` : ""}`);
const fullHeader = `${title} ${count} ${compactProgressBar(stats, theme)}`;
const compactHeader = `${title} ${count}`;
const header = visibleWidth(fullHeader) <= safeWidth ? fullHeader : truncateToWidth(compactHeader, safeWidth);
return [header, ...renderWidgetTree(tasks, theme, safeWidth, " ", inProgressFrame, maxBodyRows)];
}
class TaskResultComponent implements Component {
private readonly build: (width: number) => string[];
constructor(build: (width: number) => string[]) {
this.build = build;
}
render(width: number): string[] {
return this.build(Math.max(1, width)).map((line) => truncateToWidth(line, Math.max(1, width)));
}
invalidate(): void {}
}
function firstText(content: Array<{ type: string; text?: string }>): string {
return content.find((item) => item.type === "text")?.text ?? "";
}
function summaryFor(details: TaskToolDetails): string {
const stats = snapshotStats(details.snapshot.tasks);
const progress = `${stats.done}/${stats.total} done`;
switch (details.action) {
case "add":
case "append":
return `${details.affected.length} added · ${progress}`;
case "replace":
return stats.total === 0 ? "task tree cleared" : `${stats.total} installed · ${progress}`;
case "update":
return `${details.affected.length} updated · ${progress}`;
case "remove":
return `${details.affected.length} removed · ${progress}`;
case "list":
return `${stats.total} task${stats.total === 1 ? "" : "s"} · ${progress}`;
case "get":
return `${details.affected.length} task${details.affected.length === 1 ? "" : "s"}`;
}
}
export function snapshotStats(tasks: Task[]): TaskStats {
return {
total: tasks.length,
pending: tasks.filter((task) => task.status === "pending").length,
inProgress: tasks.filter((task) => task.status === "in-progress").length,
blocked: tasks.filter((task) => task.status === "blocked").length,
done: tasks.filter((task) => task.status === "done").length,
};
}
function renderFlatTasks(tasks: Task[], theme: Theme, width: number): string[] {
return tasks.map((task) => renderTaskLine(task, " ", theme, width));
}
function renderTaskDetails(tasks: Task[], theme: Theme, width: number): string[] {
const lines: string[] = [];
for (const [index, task] of tasks.entries()) {
if (index > 0) lines.push("");
lines.push(renderTaskLine(task, " ", theme, width));
const parent = task.parentId === null ? "root" : `#${task.parentId}`;
const children = task.childrenIds.length === 0 ? "none" : task.childrenIds.map((id) => `#${id}`).join(", ");
lines.push(truncateToWidth(theme.fg("dim", ` Parent ${parent} · Children ${children}`), width));
if (task.description) {
const prefix = " ";
const descriptionWidth = Math.max(1, width - visibleWidth(prefix));
for (const sourceLine of task.description.split("\n")) {
const wrapped = wrapTextWithAnsi(theme.fg("muted", sourceLine), descriptionWidth);
if (wrapped.length === 0) lines.push(prefix);
else lines.push(...wrapped.map((line) => `${prefix}${line}`));
}
}
}
return lines;
}
function isValidTaskToolDetails(details: unknown): details is TaskToolDetails {
if (!details || typeof details !== "object") return false;
const d = details as Partial<TaskToolDetails>;
return (
typeof d.action === "string" &&
Array.isArray(d.snapshot?.tasks) &&
Array.isArray(d.affected)
);
}
export function renderToolResult(
result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
expanded: boolean,
theme: Theme,
): Component {
const raw = firstText(result.content);
const details = result.details as TaskToolDetails | undefined;
if (result.isError || !isValidTaskToolDetails(details)) {
const rawLines = raw.split("\n").filter(Boolean);
return new TaskResultComponent((width) => {
const lines = expanded ? rawLines : rawLines.slice(0, 1);
return lines.map((line, index) => theme.fg("error", `${index === 0 ? "✗ " : " "}${line}`));
});
}
return new TaskResultComponent((width) => {
const lines = [theme.fg("success", "✓ ") + theme.fg("muted", summaryFor(details))];
if (!expanded || details.affected.length === 0) return lines;
if (details.action === "list") return [...lines, ...renderTree(details.affected, theme, width)];
if (details.action === "get") return [...lines, ...renderTaskDetails(details.affected, theme, width)];
return [...lines, ...renderFlatTasks(details.affected, theme, width)];
});
}
+469
View File
@@ -0,0 +1,469 @@
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import type { TaskState } from "./state.ts";
import { renderTodoPanel } from "./ui.ts";
const WIDGET_ID = "pi-todo-herdr";
const COMBINED_WIDGET_BREAKPOINT = 120;
const COMBINED_BODY_ROWS = 10;
export const TODO_ANIMATION_INTERVAL_MS = 500;
interface StatusWidgetTheme {
fg(color: string, text: string): string;
}
interface StatusWidget {
readonly source: object;
readonly hasContent: () => boolean;
readonly getBodyRowCount: () => number;
readonly render: (width: number, maxBodyRows?: number, theme?: StatusWidgetTheme) => string[];
readonly claim: (onChange: () => void) => () => void;
readonly isClaimed: (onChange: () => void) => boolean;
}
interface InteractiveSubagentsGlobal {
statusWidget?: unknown;
}
class OptionalIntegrationError extends Error {
constructor(message: string, cause?: unknown) {
super(message, { cause });
this.name = "OptionalIntegrationError";
}
}
class TuiRenderFailure extends Error {
readonly original: unknown;
constructor(original: unknown) {
super("TUI render failed", { cause: original });
this.name = "TuiRenderFailure";
this.original = original;
}
}
function optionalIntegrationFailure(message: string, cause?: unknown): OptionalIntegrationError {
return new OptionalIntegrationError(message, cause);
}
function callOptionalIntegration<T>(message: string, operation: () => T): T {
try {
return operation();
} catch (error) {
if (error instanceof TuiRenderFailure) throw error.original;
if (error instanceof OptionalIntegrationError) throw error;
throw optionalIntegrationFailure(message, error);
}
}
interface ActiveClaim {
owner: object;
source: object;
onChange: () => void;
release: () => void;
generation: number;
}
// Symbols make the claim and generation survive extension reloads. A newly
// loaded module can invalidate a stale component before installing its own.
const ACTIVE_CLAIM_KEY = Symbol.for("pi-todo-herdr/active-subagents-claim");
const WIDGET_GENERATION_KEY = Symbol.for("pi-todo-herdr/widget-generation");
const ACTIVE_RUNTIME_KEY = Symbol.for("pi-todo-herdr/active-runtime");
const RUNTIME_TOKEN = Symbol("pi-todo-herdr-runtime");
const previousClaim = Reflect.get(globalThis, ACTIVE_CLAIM_KEY);
Reflect.set(globalThis, ACTIVE_RUNTIME_KEY, RUNTIME_TOKEN);
if (previousClaim !== null && typeof previousClaim === "object") {
const release = (previousClaim as { release?: unknown }).release;
Reflect.deleteProperty(globalThis, ACTIVE_CLAIM_KEY);
if (typeof release === "function") {
try {
release.call(previousClaim);
} catch {
// A stale claim must not prevent the new extension runtime loading.
}
}
}
function isCurrentRuntime(): boolean {
return Reflect.get(globalThis, ACTIVE_RUNTIME_KEY) === RUNTIME_TOKEN;
}
function getActiveClaim(): ActiveClaim | undefined {
const claim = Reflect.get(globalThis, ACTIVE_CLAIM_KEY);
return claim !== null && typeof claim === "object" ? (claim as ActiveClaim) : undefined;
}
function setActiveClaim(claim: ActiveClaim | undefined): void {
if (claim === undefined) Reflect.deleteProperty(globalThis, ACTIVE_CLAIM_KEY);
else Reflect.set(globalThis, ACTIVE_CLAIM_KEY, claim);
}
function claimBelongsTo(claim: ActiveClaim | undefined, owner: object, generation: number): claim is ActiveClaim {
return claim?.owner === owner && claim.generation === generation;
}
function nextWidgetGeneration(): number {
const current = Reflect.get(globalThis, WIDGET_GENERATION_KEY);
const generation = typeof current === "number" && Number.isSafeInteger(current) ? current + 1 : 1;
Reflect.set(globalThis, WIDGET_GENERATION_KEY, generation);
return generation;
}
function currentWidgetGeneration(): number {
const generation = Reflect.get(globalThis, WIDGET_GENERATION_KEY);
return typeof generation === "number" && Number.isSafeInteger(generation) ? generation : 0;
}
function releaseActiveClaim(owner?: object, generation?: number): void {
const activeClaim = getActiveClaim();
if (
(owner !== undefined && activeClaim?.owner !== owner) ||
(generation !== undefined && activeClaim?.generation !== generation)
) return;
setActiveClaim(undefined);
if (activeClaim === undefined) return;
try {
activeClaim.release();
} catch {
// A broken optional integration must never prevent the Todo widget clearing.
}
}
function getStatusWidget(): StatusWidget | undefined {
try {
const globals = globalThis as typeof globalThis & {
__pi_interactive_subagents?: unknown;
};
const integration = globals.__pi_interactive_subagents;
if (integration === null || typeof integration !== "object") return undefined;
const statusWidget = (integration as InteractiveSubagentsGlobal).statusWidget;
if (statusWidget === null || typeof statusWidget !== "object") return undefined;
const candidate = statusWidget as Record<string, unknown>;
if (
typeof candidate.hasContent !== "function" ||
typeof candidate.getBodyRowCount !== "function" ||
typeof candidate.render !== "function" ||
typeof candidate.claim !== "function" ||
typeof candidate.isClaimed !== "function"
) {
return undefined;
}
const source = statusWidget;
const call = <T>(methodName: string, args: unknown[] = []): T =>
callOptionalIntegration(`Subagents ${methodName} failed`, () => {
const method = candidate[methodName];
if (typeof method !== "function") {
throw optionalIntegrationFailure(`invalid Subagents ${methodName} method`);
}
return method.call(source, ...args) as T;
});
return {
source,
hasContent: () => {
const content = call<unknown>("hasContent");
if (typeof content !== "boolean") throw optionalIntegrationFailure("invalid Subagents content state");
return content;
},
getBodyRowCount: () => {
const count = call<unknown>("getBodyRowCount");
if (typeof count !== "number" || !Number.isFinite(count)) {
throw optionalIntegrationFailure("invalid Subagents body row count");
}
return count;
},
render: (width, maxBodyRows, theme) => {
const lines = call<unknown>("render", [width, maxBodyRows, theme]);
const hasAllEntries =
Array.isArray(lines) &&
lines.length > 0 &&
Array.from({ length: lines.length }, (_, index) => {
return Object.prototype.hasOwnProperty.call(lines, index) && typeof lines[index] === "string";
}).every(Boolean);
if (!hasAllEntries) throw optionalIntegrationFailure("invalid Subagents render output");
const validLines = lines as string[];
if (maxBodyRows !== undefined) {
const isBordered = validLines[0]?.includes("╭") && validLines.at(-1)?.includes("╰");
// Body rows exclude the provider's header. Legacy bordered output has
// top and bottom chrome; the current unboxed output has one header.
const headerAndChromeRows = isBordered ? 2 : 1;
const maxLines = maxBodyRows + headerAndChromeRows;
if (validLines.length > maxLines) {
throw optionalIntegrationFailure("Subagents render output exceeded its body limit");
}
}
return validLines;
},
claim: (onChange) => {
const release = call<unknown>("claim", [onChange]);
if (typeof release !== "function") throw optionalIntegrationFailure("invalid Subagents claim release");
return release as () => void;
},
isClaimed: (onChange) => {
const claimed = call<unknown>("isClaimed", [onChange]);
if (typeof claimed !== "boolean") throw optionalIntegrationFailure("invalid Subagents claim state");
return claimed;
},
};
} catch {
return undefined;
}
}
function claimStatusWidget(
statusWidget: StatusWidget,
owner: object,
generation: number,
tui: { requestRender(): void },
): boolean {
const activeClaim = getActiveClaim();
if (activeClaim?.owner !== undefined && activeClaim.owner !== owner) return false;
if (claimBelongsTo(activeClaim, owner, generation) && activeClaim.source === statusWidget.source) {
try {
if (statusWidget.isClaimed(activeClaim.onChange)) return true;
} catch (error) {
releaseActiveClaim(owner, generation);
if (error instanceof OptionalIntegrationError) return false;
throw error;
}
// The provider may have been reset by a reload or another lifecycle event.
// Drop the stale local handle before asking it for a fresh claim.
}
releaseActiveClaim(owner, generation);
try {
const onChange = () => {
if (!isCurrentRuntime()) {
releaseActiveClaim(owner, generation);
return;
}
const currentClaim = getActiveClaim();
if (!claimBelongsTo(currentClaim, owner, generation) || currentClaim.onChange !== onChange) return;
try {
tui.requestRender();
} catch (error) {
// Let the provider restore its standalone widget when the TUI is gone.
releaseActiveClaim(owner, generation);
throw new TuiRenderFailure(error);
}
};
const release = statusWidget.claim(onChange);
setActiveClaim({ owner, source: statusWidget.source, onChange, release, generation });
return true;
} catch (error) {
if (error instanceof OptionalIntegrationError) return false;
throw error;
}
}
function normalizeWidth(width: number): number {
return Math.max(1, Math.floor(Number.isFinite(width) ? width : 1));
}
function fitLine(line: string, width: number): string {
const fittedWidth = normalizeWidth(width);
const fitted = truncateToWidth(line, fittedWidth);
return `${fitted}${" ".repeat(Math.max(0, fittedWidth - visibleWidth(fitted)))}`;
}
function renderStatusPanel(
statusWidget: StatusWidget,
width: number,
maxBodyRows: number,
theme: StatusWidgetTheme,
): string[] {
return statusWidget.render(width, maxBodyRows, theme).map((line) => fitLine(line, width));
}
function normalizePanelHeight(lines: string[], height: number, blankLine: string): string[] {
if (lines.length >= height) return lines;
const blanks = Array.from({ length: height - lines.length }, () => blankLine);
const bottom = lines.at(-1);
if (bottom?.includes("╰")) return [...lines.slice(0, -1), ...blanks, bottom];
return [...lines, ...blanks];
}
function renderSideBySide(
tasks: ReturnType<TaskState["list"]>,
theme: Theme,
width: number,
inProgressFrame: number,
statusWidget: StatusWidget,
): string[] {
const leftWidth = Math.floor((width - 1) / 2);
const rightWidth = width - 1 - leftWidth;
const todo = renderTodoPanel(tasks, theme, leftWidth, inProgressFrame, COMBINED_BODY_ROWS);
const subagents = renderStatusPanel(statusWidget, rightWidth, COMBINED_BODY_ROWS, theme);
const height = Math.max(todo.length, subagents.length);
const left = normalizePanelHeight(todo, height, " ".repeat(leftWidth)).map((line) => fitLine(line, leftWidth));
const right = normalizePanelHeight(subagents, height, " ".repeat(rightWidth));
return left.map((line, index) => `${line} ${right[index]}`);
}
function bodyRowsForTodo(
tasks: ReturnType<TaskState["list"]>,
theme: Theme,
width: number,
inProgressFrame: number,
): number {
return Math.max(1, renderTodoPanel(tasks, theme, width, inProgressFrame, COMBINED_BODY_ROWS).length - 1);
}
function bodyRowsForSubagents(statusWidget: StatusWidget): number {
return Math.max(1, Math.min(COMBINED_BODY_ROWS, Math.floor(statusWidget.getBodyRowCount())));
}
function distributeBodyRows(todoNeed: number, subagentsNeed: number): { todo: number; subagents: number } {
let todo = Math.min(todoNeed, Math.floor(COMBINED_BODY_ROWS / 2));
let subagents = Math.min(subagentsNeed, Math.floor(COMBINED_BODY_ROWS / 2));
let remaining = COMBINED_BODY_ROWS - todo - subagents;
const todoUnused = Math.max(0, todoNeed - todo);
const todoExtra = Math.min(remaining, todoUnused);
todo += todoExtra;
remaining -= todoExtra;
const subagentsUnused = Math.max(0, subagentsNeed - subagents);
subagents += Math.min(remaining, subagentsUnused);
return { todo: Math.max(1, todo), subagents: Math.max(1, subagents) };
}
function renderStacked(
tasks: ReturnType<TaskState["list"]>,
theme: Theme,
width: number,
inProgressFrame: number,
statusWidget: StatusWidget,
subagentsNeed: number,
): string[] {
const todoNeed = bodyRowsForTodo(tasks, theme, width, inProgressFrame);
const rows = distributeBodyRows(todoNeed, subagentsNeed);
return [
...renderTodoPanel(tasks, theme, width, inProgressFrame, rows.todo),
"",
...renderStatusPanel(statusWidget, width, rows.subagents, theme),
];
}
function renderCombinedWidget(
tasks: ReturnType<TaskState["list"]>,
theme: Theme,
width: number,
inProgressFrame: number,
tui: { requestRender(): void },
owner: object,
generation: number,
composeSubagents: boolean,
): string[] {
if (!Number.isFinite(width) || width <= 0 || tasks.length === 0) {
releaseActiveClaim(owner, generation);
return [];
}
const fittedWidth = normalizeWidth(width);
const renderTodoFallback = (): string[] => {
releaseActiveClaim(owner, generation);
return renderTodoPanel(tasks, theme, fittedWidth, inProgressFrame);
};
if (!composeSubagents) return renderTodoFallback();
const statusWidget = getStatusWidget();
if (statusWidget === undefined) return renderTodoFallback();
try {
if (!statusWidget.hasContent()) return renderTodoFallback();
if (!claimStatusWidget(statusWidget, owner, generation, tui)) return renderTodoFallback();
const subagentsNeed = bodyRowsForSubagents(statusWidget);
return fittedWidth > COMBINED_WIDGET_BREAKPOINT
? renderSideBySide(tasks, theme, fittedWidth, inProgressFrame, statusWidget)
: renderStacked(tasks, theme, fittedWidth, inProgressFrame, statusWidget, subagentsNeed);
} catch (error) {
if (!(error instanceof OptionalIntegrationError)) {
releaseActiveClaim(owner, generation);
throw error;
}
return renderTodoFallback();
}
}
export function clearTaskWidget(ctx: ExtensionContext): void {
if (!isCurrentRuntime()) return;
nextWidgetGeneration();
releaseActiveClaim();
ctx.ui.setWidget(WIDGET_ID, undefined);
}
export function updateTaskWidget(
state: TaskState,
ctx: ExtensionContext,
visible: boolean,
refreshMs = TODO_ANIMATION_INTERVAL_MS,
): void {
if (!isCurrentRuntime()) return;
const tasks = state.list();
if (!visible || tasks.length === 0) {
clearTaskWidget(ctx);
return;
}
const generation = nextWidgetGeneration();
// Subagents owns a native widget outside this extension. Only compose it
// while Pi is rendering the terminal UI, never in RPC/print modes.
const composeSubagents = ctx.mode === "tui";
// Replacing a widget can happen before Pi calls its old dispose hook. Release
// the optional Subagents claim eagerly so it cannot hide a standalone widget.
releaseActiveClaim();
const hasActiveTimer = tasks.some((task) => task.status === "in-progress" && task.startedAt !== undefined);
ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
let inProgressFrame = 0;
let disposed = false;
const owner = {};
const isActive = (): boolean =>
!disposed && isCurrentRuntime() && currentWidgetGeneration() === generation;
let timer: ReturnType<typeof setInterval> | undefined;
if (hasActiveTimer) {
timer = setInterval(() => {
if (!isActive()) {
clearInterval(timer);
return;
}
inProgressFrame += 1;
try {
tui.requestRender();
} catch {
disposed = true;
clearInterval(timer);
releaseActiveClaim(owner, generation);
}
}, Math.max(1, refreshMs));
}
timer?.unref();
return {
render(width: number): string[] {
if (!isActive()) return [];
const currentTasks = state.list();
return renderCombinedWidget(
currentTasks,
theme,
width,
inProgressFrame,
tui,
owner,
generation,
composeSubagents,
);
},
invalidate(): void {},
dispose(): void {
disposed = true;
releaseActiveClaim(owner, generation);
if (timer !== undefined) clearInterval(timer);
},
};
});
}