Harden task state and terminal rendering
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
*.tgz
|
||||
.DS_Store
|
||||
.pi/
|
||||
|
||||
@@ -11,6 +11,7 @@ Hierarchical, session-local task tracking for [Pi](https://pi.dev), with atomic
|
||||
- Five focused tools: `set_tasks`, `update_task`, `rm_task`, `list_task`, and `get_task`
|
||||
- Parent/child task trees with generated integer IDs and atomic append/replace modes
|
||||
- Atomic batch mutations: every requested change succeeds or none of it is applied
|
||||
- Bounded task input and widget output to keep sessions and terminals responsive
|
||||
- Four states: `pending`, `in-progress`, `blocked`, and `done`
|
||||
- Cumulative active-time tracking that pauses outside `in-progress` and survives session restore
|
||||
- Session-branch persistence across reload, resume, fork, tree navigation, and context compaction
|
||||
@@ -89,13 +90,15 @@ Example append call:
|
||||
|
||||
Task input keys are explicit so strict tool-schema bridges do not invent values for omitted properties. Use `description: null` when no detail is needed, `status: "pending"` for the default state, `parentId: null` for root tasks and nested children, and `children: []` for leaves. `prepareArguments` still expands legacy calls that omit these fields.
|
||||
|
||||
To keep model-generated input safe and bounded, a session may contain up to 200 tasks. One mutation may affect up to 50 explicitly supplied tasks, hierarchy depth is limited to eight levels, names to 200 Unicode characters, and descriptions to 4,000 Unicode characters. Names and descriptions reject terminal controls and bidirectional override characters; descriptions may still contain line feeds for normal multi-line detail.
|
||||
|
||||
IDs are not reused by append/remove operations; `replace` deliberately rebuilds IDs from `1`. Replace forbids non-null `parentId`, so new hierarchy must use nested `children`. Multiple tasks may be `in-progress`. A parent can be marked `done` only when all descendants are also `done`.
|
||||
|
||||
A task starts timing the first time it enters `in-progress`. Leaving that state records a stop and pauses the timer; entering it again resumes with a new active segment while preserving the cumulative duration. Repeated updates with the same status, as well as name, description, hierarchy, and position changes, do not reset timing. For started tasks, `get_task` includes the first `startedAt` timestamp, a `stoppedAt` timestamp while paused, and cumulative `durationMs`. Timestamps use ISO 8601.
|
||||
|
||||
## Pi Widget
|
||||
|
||||
The widget appears above Pi's editor while tasks exist. Run `/tasks` to hide or show it for the current Pi runtime.
|
||||
The widget appears above Pi's editor while tasks exist. Run `/tasks` to hide or show it for the current Pi runtime. It shows at most 12 task rows plus a compact count of hidden tasks, so larger plans do not crowd the editor out of an 80×24 terminal.
|
||||
|
||||
```text
|
||||
Tasks 2/6 · !1 ━━━━━────────
|
||||
@@ -128,6 +131,8 @@ Metadata failures never block task operations. The extension does not change Her
|
||||
|
||||
## Development
|
||||
|
||||
Development tests use Node's built-in TypeScript type stripping and require Node.js 22.18 or newer. The extension itself retains its Node.js 20 runtime requirement because Pi loads TypeScript extensions through its own loader.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run typecheck
|
||||
|
||||
+6
-7
@@ -20,18 +20,17 @@
|
||||
"productivity"
|
||||
],
|
||||
"author": {
|
||||
"name": "lesetong",
|
||||
"email": "oscarlbw@qq.com",
|
||||
"url": "https://github.com/leset0ng"
|
||||
"name": "JB",
|
||||
"url": "https://git.jb.codes/jb"
|
||||
},
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/leset0ng/pi-todo-herdr#readme",
|
||||
"homepage": "https://git.jb.codes/jb/pi-todo-herdr#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/leset0ng/pi-todo-herdr.git"
|
||||
"url": "git+ssh://git@git.jb.codes:7722/jb/pi-todo-herdr.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/leset0ng/pi-todo-herdr/issues"
|
||||
"url": "https://git.jb.codes/jb/pi-todo-herdr/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -40,7 +39,7 @@
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --experimental-transform-types --test tests/*.test.ts",
|
||||
"test": "node --test tests/*.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run typecheck && npm test"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export const MAX_TASKS = 200;
|
||||
export const MAX_TASKS_PER_MUTATION = 50;
|
||||
export const MAX_TASK_DEPTH = 8;
|
||||
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;
|
||||
}
|
||||
+137
-20
@@ -1,4 +1,13 @@
|
||||
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,
|
||||
@@ -52,7 +61,7 @@ function snapshotTasks(records: TaskRecord[]): Task[] {
|
||||
|
||||
function durationMs(task: TaskRecord, now: number): number {
|
||||
const activeMs = task.activeStartedAt === undefined ? 0 : Math.max(0, now - task.activeStartedAt);
|
||||
return task.accumulatedMs + activeMs;
|
||||
return Math.min(MAX_DATE_MS, task.accumulatedMs + activeMs);
|
||||
}
|
||||
|
||||
function publicTasks(records: TaskRecord[], now: number): Task[] {
|
||||
@@ -94,7 +103,7 @@ function startTimer(task: TaskRecord, now: number): void {
|
||||
function stopTimer(task: TaskRecord, now: number): void {
|
||||
if (task.activeStartedAt === undefined) return;
|
||||
const stoppedAt = Math.max(now, task.activeStartedAt);
|
||||
task.accumulatedMs += stoppedAt - task.activeStartedAt;
|
||||
task.accumulatedMs = Math.min(MAX_DATE_MS, task.accumulatedMs + stoppedAt - task.activeStartedAt);
|
||||
task.activeStartedAt = undefined;
|
||||
task.stoppedAt = stoppedAt;
|
||||
}
|
||||
@@ -125,17 +134,29 @@ function validateGraph(records: TaskRecord[]): string[] {
|
||||
const ids = new Set<number>();
|
||||
|
||||
for (const task of records) {
|
||||
if (!Number.isInteger(task.id) || task.id < 1) errors.push(`task ${task.id}: id must be a positive integer`);
|
||||
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 (task.description !== undefined && !task.description.trim()) {
|
||||
errors.push(`task ${task.id}: description must not be empty`);
|
||||
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)) {
|
||||
errors.push(`task ${task.id}: accumulatedMs must be a non-negative safe integer`);
|
||||
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) {
|
||||
@@ -193,6 +214,22 @@ function validateGraph(records: TaskRecord[]): string[] {
|
||||
|
||||
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");
|
||||
@@ -251,8 +288,11 @@ function moveToSiblingEnd(records: TaskRecord[], id: number): void {
|
||||
export class TaskState {
|
||||
private records: TaskRecord[] = [];
|
||||
private nextId = 1;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
constructor(now: () => number = Date.now) {
|
||||
this.now = now;
|
||||
}
|
||||
|
||||
private currentTime(): number {
|
||||
const now = Math.floor(this.now());
|
||||
@@ -293,26 +333,60 @@ export class TaskState {
|
||||
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) => {
|
||||
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 (
|
||||
draft.description !== undefined &&
|
||||
draft.description !== null &&
|
||||
(typeof draft.description !== "string" || !draft.description.trim())
|
||||
) {
|
||||
errors.push(`${path}.description must not be empty`);
|
||||
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`);
|
||||
@@ -331,6 +405,10 @@ export class TaskState {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -346,11 +424,21 @@ export class TaskState {
|
||||
|
||||
const children = Array.isArray(draft.children) ? draft.children : [];
|
||||
for (const [index, child] of children.entries()) {
|
||||
append(child, id, `${path}.children[${index}]`, true);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
drafts.forEach((draft, index) => append(draft, null, `tasks[${index}]`, false));
|
||||
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)]);
|
||||
|
||||
@@ -387,6 +475,9 @@ export class TaskState {
|
||||
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)
|
||||
@@ -415,9 +506,23 @@ export class TaskState {
|
||||
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 &&
|
||||
@@ -460,6 +565,9 @@ export class TaskState {
|
||||
|
||||
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(", ")}`]);
|
||||
@@ -479,12 +587,21 @@ export class TaskState {
|
||||
}
|
||||
|
||||
restore(snapshot: TaskSnapshot): void {
|
||||
if (!snapshot || !Array.isArray(snapshot.tasks) || !Number.isInteger(snapshot.nextId)) {
|
||||
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,
|
||||
@@ -504,8 +621,8 @@ export class TaskState {
|
||||
timingErrors.push(`${path} must be an object`);
|
||||
continue;
|
||||
}
|
||||
if (!Number.isInteger(timing.taskId) || timing.taskId < 1) {
|
||||
timingErrors.push(`${path}.taskId must be a positive integer`);
|
||||
if (!Number.isSafeInteger(timing.taskId) || timing.taskId < 1) {
|
||||
timingErrors.push(`${path}.taskId must be a positive safe integer`);
|
||||
continue;
|
||||
}
|
||||
if (timedIds.has(timing.taskId)) {
|
||||
|
||||
+72
-16
@@ -2,6 +2,14 @@ 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";
|
||||
@@ -77,15 +85,29 @@ const AddTaskSchema = Type.Cyclic(
|
||||
{
|
||||
AddTask: Type.Object(
|
||||
{
|
||||
name: Type.String({ minLength: 1 }),
|
||||
description: Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
|
||||
description: "Detailed scope, constraints, and done criteria; use null when no detail is needed.",
|
||||
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")),
|
||||
children: Type.Array(Type.Ref("AddTask"), { maxItems: MAX_TASKS_PER_MUTATION }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
@@ -99,6 +121,7 @@ export const SetTaskParams = hoistDefsToRoot(
|
||||
{
|
||||
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({
|
||||
@@ -113,12 +136,24 @@ export const SetTaskParams = hoistDefsToRoot(
|
||||
const TaskPatchSchema = Type.Object(
|
||||
{
|
||||
id: Type.Integer({ minimum: 1 }),
|
||||
name: Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
|
||||
description: "New name, or null to leave it unchanged.",
|
||||
}),
|
||||
description: Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
|
||||
description: "New description, or null to leave it unchanged.",
|
||||
}),
|
||||
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.",
|
||||
}),
|
||||
@@ -136,13 +171,18 @@ const TaskPatchSchema = Type.Object(
|
||||
|
||||
export const UpdateTaskParams = Type.Object(
|
||||
{
|
||||
tasks: Type.Array(TaskPatchSchema, { minItems: 1, description: "Task patches applied atomically in array order." }),
|
||||
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.",
|
||||
});
|
||||
|
||||
@@ -158,14 +198,23 @@ export const IdsParams = Type.Object({ ids: TaskIdsSchema }, { additionalPropert
|
||||
|
||||
export const ListTaskParams = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
function prepareTaskDraft(value: unknown): unknown {
|
||||
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.map(prepareTaskDraft)
|
||||
? input.children
|
||||
.slice(0, MAX_TASKS_PER_MUTATION + 1)
|
||||
.map((child) => prepareTaskDraft(child, depth + 1, state))
|
||||
: input.children;
|
||||
return {
|
||||
...input,
|
||||
@@ -181,9 +230,14 @@ function prepareSetTaskArguments(value: unknown): Static<typeof SetTaskParams> {
|
||||
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.map(prepareTaskDraft) : input.tasks,
|
||||
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>;
|
||||
}
|
||||
@@ -208,7 +262,9 @@ function prepareUpdateTaskArguments(value: unknown): Static<typeof UpdateTaskPar
|
||||
const input = value as Record<string, unknown>;
|
||||
return {
|
||||
...input,
|
||||
tasks: Array.isArray(input.tasks) ? input.tasks.map(prepareTaskPatch) : input.tasks,
|
||||
tasks: Array.isArray(input.tasks)
|
||||
? input.tasks.slice(0, MAX_TASKS_PER_MUTATION + 1).map(prepareTaskPatch)
|
||||
: input.tasks,
|
||||
} as Static<typeof UpdateTaskParams>;
|
||||
}
|
||||
|
||||
@@ -280,7 +336,7 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
|
||||
name: "set_tasks",
|
||||
label: "Set Tasks",
|
||||
description:
|
||||
"Atomically append tasks or replace the task tree. Pass force false unless unfinished work is explicitly discarded.",
|
||||
`Atomically append tasks or replace the task tree. Supply at most ${MAX_TASKS_PER_MUTATION} total tasks with no more than ${MAX_TASK_DEPTH} hierarchy levels. Pass force false unless unfinished work is explicitly discarded.`,
|
||||
promptGuidelines: [...TASK_PROMPT_GUIDELINES],
|
||||
parameters: SetTaskParams,
|
||||
prepareArguments: prepareSetTaskArguments,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 = {
|
||||
@@ -134,10 +135,20 @@ export function renderTree(tasks: Task[], theme: Theme, width: number, padding =
|
||||
}
|
||||
|
||||
export function renderWidgetTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] {
|
||||
return buildTreeRows(tasks, true).map(({ task, prefix, collapsed }) => {
|
||||
const rows = buildTreeRows(tasks, true);
|
||||
const visibleRows = rows.slice(0, MAX_WIDGET_TASK_ROWS);
|
||||
const lines = visibleRows.map(({ task, prefix, collapsed }) => {
|
||||
const summary = collapsed === undefined ? undefined : `${collapsed.done}/${collapsed.total} done`;
|
||||
return renderTaskLineWithSuffix(task, `${padding}${prefix}`, theme, width, summary);
|
||||
});
|
||||
if (rows.length <= MAX_WIDGET_TASK_ROWS) return lines;
|
||||
|
||||
const hiddenTasks = rows.slice(MAX_WIDGET_TASK_ROWS).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 {
|
||||
@@ -159,9 +170,11 @@ export function renderProgressHeader(stats: TaskStats, theme: Theme, width: numb
|
||||
}
|
||||
|
||||
class TaskResultComponent implements Component {
|
||||
constructor(
|
||||
private readonly build: (width: number) => string[],
|
||||
) {}
|
||||
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)));
|
||||
|
||||
@@ -172,6 +172,50 @@ test("restores timers and starts legacy in-progress snapshots at load time", ()
|
||||
assert.equal(legacy.snapshot().timings?.[0]?.activeStartedAt, now);
|
||||
});
|
||||
|
||||
test("rejects unsafe restored identifiers and oversized timing collections", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Keep" }]);
|
||||
const before = state.snapshot();
|
||||
const task = { id: 1, name: "Task", status: "pending" as const, parentId: null, childrenIds: [] };
|
||||
|
||||
assert.throws(
|
||||
() => state.restore({ tasks: [{ ...task, id: Number.MAX_SAFE_INTEGER + 1 }], nextId: Number.MAX_SAFE_INTEGER }),
|
||||
/id must be a positive safe integer/,
|
||||
);
|
||||
assert.throws(
|
||||
() => state.restore({ tasks: [task], nextId: Number.MAX_SAFE_INTEGER + 1 }),
|
||||
/nextId must be a positive safe integer/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [task],
|
||||
nextId: 2,
|
||||
timings: Array.from({ length: 201 }, () => ({ taskId: 1, startedAt: 0, accumulatedMs: 0 })),
|
||||
}),
|
||||
/timings are limited to 200 entries/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [task],
|
||||
nextId: 2,
|
||||
timings: [{ taskId: 1, startedAt: 0, stoppedAt: 0, accumulatedMs: Number.MAX_SAFE_INTEGER }],
|
||||
}),
|
||||
/accumulatedMs must be an integer between/,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("rejects additions when no safe generated task ID remains", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.restore({ tasks: [], nextId: Number.MAX_SAFE_INTEGER });
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(() => state.add([{ name: "Unsafe ID" }]), /no safe task IDs remain/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("rejects out-of-range persisted timestamps without replacing state", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Keep" }]);
|
||||
@@ -318,6 +362,76 @@ test("loads the latest valid mutation snapshot from a session branch", () => {
|
||||
assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]);
|
||||
});
|
||||
|
||||
test("rejects terminal controls and bidirectional overrides without changing state", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Safe", description: "Line one\nLine two" }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
for (const name of ["Escape\u001b[2J", "Tab\tname", "Arabic mark\u061cname", "Override\u202eevil"]) {
|
||||
assert.throws(() => state.add([{ name }]), /unsafe control/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
}
|
||||
|
||||
assert.throws(() => state.update([{ id: 1, description: "Clipboard\u009d52;c;payload" }]), /unsafe control/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("enforces task name and description length limits by Unicode character", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "😀".repeat(200), description: "d".repeat(4_000) }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(() => state.add([{ name: "n".repeat(201) }]), /name must be at most 200 characters/);
|
||||
assert.throws(() => state.update([{ id: 1, description: "d".repeat(4_001) }]), /description must be at most 4000 characters/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("enforces per-mutation and per-session task limits atomically", () => {
|
||||
const state = new TaskState();
|
||||
const batch = Array.from({ length: 50 }, (_, index) => ({ name: `Task ${index + 1}` }));
|
||||
|
||||
assert.throws(() => state.add([...batch, { name: "Too many" }]), /at most 50 tasks/);
|
||||
assert.equal(state.list().length, 0);
|
||||
|
||||
for (let index = 0; index < 4; index += 1) state.add(batch);
|
||||
assert.equal(state.list().length, 200);
|
||||
const before = state.snapshot();
|
||||
assert.throws(() => state.add([{ name: "Session overflow" }]), /session is limited to 200 tasks/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
new TaskState().restore({
|
||||
tasks: Array.from({ length: 201 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `Restored ${index + 1}`,
|
||||
status: "pending" as const,
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
})),
|
||||
nextId: 202,
|
||||
}),
|
||||
/session is limited to 200 tasks/,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts eight task levels and rejects deeper trees atomically", () => {
|
||||
const nested = (depth: number) => {
|
||||
let task: { name: string; children?: Array<{ name: string; children?: unknown[] }> } = { name: `Level ${depth}` };
|
||||
for (let level = depth - 1; level >= 1; level -= 1) {
|
||||
task = { name: `Level ${level}`, children: [task] };
|
||||
}
|
||||
return task;
|
||||
};
|
||||
|
||||
const state = new TaskState();
|
||||
state.add([nested(8) as never]);
|
||||
assert.equal(state.list().length, 8);
|
||||
const before = state.snapshot();
|
||||
assert.throws(() => state.add([nested(9) as never]), /depth must not exceed 8 levels/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("supports nullable descriptions and reports invalid children as validation errors", () => {
|
||||
const state = new TaskState();
|
||||
assert.throws(() => state.add([{ name: "Bad\nname", description: "Valid" }]), /single line/);
|
||||
|
||||
@@ -51,6 +51,72 @@ test("set_tasks schema hoists $defs to the root with #/$defs/ refs", () => {
|
||||
assert.ok(!/"\$ref":"(?!\#)/.test(serialized), "no bare $ref values remain");
|
||||
});
|
||||
|
||||
test("task schemas publish text and batch limits", () => {
|
||||
const { tools } = registeredTools();
|
||||
const setParams = tools.get("set_tasks").parameters;
|
||||
const addTask = setParams.$defs.AddTask;
|
||||
const updateTask = tools.get("update_task").parameters.properties.tasks.items;
|
||||
|
||||
assert.equal(setParams.properties.tasks.maxItems, 50);
|
||||
assert.equal(addTask.properties.children.maxItems, 50);
|
||||
assert.equal(addTask.properties.name.maxLength, 200);
|
||||
assert.match(addTask.properties.name.pattern, /061C/i);
|
||||
assert.equal(addTask.properties.description.anyOf[0].maxLength, 4_000);
|
||||
assert.match(addTask.properties.description.anyOf[0].pattern, /061C/i);
|
||||
assert.equal(tools.get("update_task").parameters.properties.tasks.maxItems, 50);
|
||||
assert.equal(updateTask.properties.name.anyOf[0].maxLength, 200);
|
||||
assert.equal(updateTask.properties.description.anyOf[0].maxLength, 4_000);
|
||||
assert.equal(tools.get("rm_task").parameters.properties.ids.maxItems, 50);
|
||||
assert.equal(tools.get("get_task").parameters.properties.ids.maxItems, 50);
|
||||
|
||||
const setTool = tools.get("set_tasks");
|
||||
const validUnicode = setTool.prepareArguments({
|
||||
mode: "append",
|
||||
tasks: [{ name: "😀".repeat(200), description: "line one\nline two" }],
|
||||
});
|
||||
assert.doesNotThrow(() =>
|
||||
validateToolArguments(setTool, { name: setTool.name, arguments: validUnicode } as never),
|
||||
);
|
||||
const unsafeText = setTool.prepareArguments({ mode: "append", tasks: [{ name: "unsafe\u061cname" }] });
|
||||
assert.throws(
|
||||
() => validateToolArguments(setTool, { name: setTool.name, arguments: unsafeText } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("set_tasks preparation bounds hostile recursive input before validation", () => {
|
||||
const { tools } = registeredTools();
|
||||
const setTool = tools.get("set_tasks");
|
||||
let nested: Record<string, unknown> = { name: "Leaf" };
|
||||
for (let depth = 0; depth < 10_000; depth += 1) nested = { name: `Level ${depth}`, children: [nested] };
|
||||
|
||||
const prepared = setTool.prepareArguments({ mode: "append", tasks: [nested] });
|
||||
assert.throws(
|
||||
() => validateToolArguments(setTool, { name: setTool.name, arguments: prepared } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
|
||||
const oversized = setTool.prepareArguments({
|
||||
mode: "append",
|
||||
tasks: Array.from({ length: 1_000 }, (_, index) => ({ name: `Task ${index}` })),
|
||||
});
|
||||
assert.equal(oversized.tasks.length, 51);
|
||||
assert.throws(
|
||||
() => validateToolArguments(setTool, { name: setTool.name, arguments: oversized } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
|
||||
const updateTool = tools.get("update_task");
|
||||
const oversizedUpdate = updateTool.prepareArguments({
|
||||
tasks: Array.from({ length: 1_000 }, (_, index) => ({ id: index + 1 })),
|
||||
});
|
||||
assert.equal(oversizedUpdate.tasks.length, 51);
|
||||
assert.throws(
|
||||
() => validateToolArguments(updateTool, { name: updateTool.name, arguments: oversizedUpdate } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("strict task schemas normalize legacy optional arguments", () => {
|
||||
const { tools } = registeredTools();
|
||||
const setTool = tools.get("set_tasks");
|
||||
|
||||
@@ -109,6 +109,18 @@ test("collapses completed widget subtrees while keeping list trees expanded", ()
|
||||
assert.ok(reopened.some((line) => line.includes("Done two")));
|
||||
});
|
||||
|
||||
test("caps the widget at twelve task rows with an overflow summary", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Task ${index + 1}` })));
|
||||
|
||||
const lines = renderWidgetTree(state.list(), plainTheme as never, 60).map(stripAnsi);
|
||||
assert.equal(lines.length, 13);
|
||||
assert.match(lines[0] ?? "", /Task 1/);
|
||||
assert.match(lines[11] ?? "", /Task 12/);
|
||||
assert.equal(lines[12], " … 8 more tasks");
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 60));
|
||||
});
|
||||
|
||||
test("hides a completed widget summary before squeezing the task name", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "A long parent task name", children: [{ name: "Done", status: "done" }] }]);
|
||||
|
||||
Reference in New Issue
Block a user