@hheei/pi-todo 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -2
- package/dist/extension.d.ts +3 -0
- package/dist/extension.js +18 -0
- package/dist/footer-status.d.ts +24 -0
- package/dist/footer-status.js +111 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/model.d.ts +83 -0
- package/dist/model.js +342 -0
- package/dist/state.d.ts +8 -0
- package/dist/state.js +12 -0
- package/dist/todo.d.ts +45 -0
- package/dist/todo.js +626 -0
- package/package.json +56 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hheei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @hheei/pi-todo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Todo task tracking for Pi. Owns the `todo` tool, the `/todo` command, task scheduling,
|
|
4
|
+
hidden reminders, and the left-side footer status line.
|
|
5
|
+
|
|
6
|
+
Install `@hheei/pi-settings` separately for the Settings UI. `@hheei/pi-ext-core` is a
|
|
7
|
+
production dependency, not a separately loaded Pi extension.
|
|
8
|
+
|
|
9
|
+
## Tool
|
|
10
|
+
|
|
11
|
+
`todo({ operations })` applies an ordered, atomic batch of `create`, `update`, `list`, and
|
|
12
|
+
`delete` operations. The whole batch is validated on a candidate state before anything is
|
|
13
|
+
committed, so a rejected operation reports `No change made.` and leaves the live state
|
|
14
|
+
untouched.
|
|
15
|
+
|
|
16
|
+
- Exactly one task is `in_progress`; the active task is always set for you.
|
|
17
|
+
- Starting a pending or blocked task demotes the previous active task to `pending`.
|
|
18
|
+
- `completed` is terminal; create a new task to redo work.
|
|
19
|
+
- `blocked` tasks stay visible but drive no guidance or reminder.
|
|
20
|
+
|
|
21
|
+
The tool registers with `defaultActive: false`. Enable it through the Pi host's active tool
|
|
22
|
+
set; the extension only starts its runtime, reminders, and footer when `todo` is active.
|
|
23
|
+
|
|
24
|
+
## Command
|
|
25
|
+
|
|
26
|
+
`/todo` shows active and recent tasks, `/todo list` shows every task, `/todo clear` resets
|
|
27
|
+
the list and ids, and `/todo cancel #ID...` atomically suppresses tasks (advancing to the
|
|
28
|
+
next pending task when the active one is cancelled). Cancelled tasks cannot be modified by
|
|
29
|
+
the agent.
|
|
30
|
+
|
|
31
|
+
## Footer status
|
|
32
|
+
|
|
33
|
+
The footer status key is `pi-ext-tools:todo`. A recently completed task wins for 15s when later work is
|
|
34
|
+
queued and 3 minutes otherwise, then the `in_progress` task, then the first pending task,
|
|
35
|
+
then a recently blocked task for 15s. No visible task clears the status entirely.
|
|
36
|
+
|
|
37
|
+
## Persistence
|
|
38
|
+
|
|
39
|
+
Session state restores from the current branch: the newest `todo` tool result snapshot, or
|
|
40
|
+
the `pi-ext-tools:todo:state` custom entry written by `/todo clear` and `/todo cancel`.
|
|
41
|
+
Both forms are validated before use. Session tree navigation restores the branch state and
|
|
42
|
+
refreshes the footer.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { registerExtensionLifecycle } from "@hheei/pi-ext-core";
|
|
2
|
+
import { createTodoFeature, TODO_TOOL_NAME } from "./todo.js";
|
|
3
|
+
/** Registers the todo tool, /todo command, reminders, and footer status line. */
|
|
4
|
+
export default function piTodoExtension(pi) {
|
|
5
|
+
const todo = createTodoFeature(pi);
|
|
6
|
+
registerExtensionLifecycle(pi, {
|
|
7
|
+
key: "@hheei/pi-todo",
|
|
8
|
+
start: async ({ extension, resources, signal }) => {
|
|
9
|
+
// The tool stays defaultActive: false, so its runtime, reminders, and footer only
|
|
10
|
+
// exist when the Pi host's active tool set already contains `todo`.
|
|
11
|
+
if (!pi.getActiveTools().includes(TODO_TOOL_NAME))
|
|
12
|
+
return;
|
|
13
|
+
await todo.start(extension, signal);
|
|
14
|
+
const sessionId = extension.sessionManager.getSessionId();
|
|
15
|
+
resources.add("todo", () => todo.dispose(sessionId));
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type TaskState } from "./model.js";
|
|
3
|
+
export declare const TODO_STATUS_KEY = "pi-ext-tools:todo";
|
|
4
|
+
export declare const COMPLETED_DISPLAY_DURATION_MS: number;
|
|
5
|
+
export declare const COMPLETED_WITH_SUBSEQUENT_DURATION_MS: number;
|
|
6
|
+
export declare const BLOCKED_DISPLAY_DURATION_MS: number;
|
|
7
|
+
export interface FooterStatusDecision {
|
|
8
|
+
readonly text: string | undefined;
|
|
9
|
+
readonly expiresAt?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Footer text for the current task state, plus when that text expires.
|
|
13
|
+
*
|
|
14
|
+
* An in-progress task wins, then a recently completed one, then pending, then recently
|
|
15
|
+
* blocked; with no in-progress task a recent completion and a recent block compete on
|
|
16
|
+
* recency. A completion stays visible for `COMPLETED_WITH_SUBSEQUENT_DURATION_MS` when
|
|
17
|
+
* work is already queued behind it, otherwise for `COMPLETED_DISPLAY_DURATION_MS`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function computeFooterStatus(state: TaskState, theme: Theme, now?: number): FooterStatusDecision;
|
|
20
|
+
export interface TodoFooterStatusController {
|
|
21
|
+
update(state: TaskState): void;
|
|
22
|
+
dispose(): void;
|
|
23
|
+
}
|
|
24
|
+
export declare function createTodoFooterStatusController(ui: Pick<ExtensionUIContext, "setStatus" | "theme">, signal?: AbortSignal, now?: () => number): TodoFooterStatusController;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { TASK_GLYPH, TASK_TONE } from "./model.js";
|
|
2
|
+
export const TODO_STATUS_KEY = "pi-ext-tools:todo";
|
|
3
|
+
export const COMPLETED_DISPLAY_DURATION_MS = 3 * 60 * 1000; // 3 minutes
|
|
4
|
+
export const COMPLETED_WITH_SUBSEQUENT_DURATION_MS = 15 * 1000; // 15 seconds
|
|
5
|
+
export const BLOCKED_DISPLAY_DURATION_MS = 15 * 1000; // 15 seconds
|
|
6
|
+
/** Most recently updated task in `status` whose update is still inside `window`. */
|
|
7
|
+
function mostRecent(tasks, status, window, now) {
|
|
8
|
+
let newest;
|
|
9
|
+
for (const task of tasks) {
|
|
10
|
+
if (task.status !== status || task.updatedAt === undefined)
|
|
11
|
+
continue;
|
|
12
|
+
if (now - task.updatedAt >= window)
|
|
13
|
+
continue;
|
|
14
|
+
if (newest === undefined || task.updatedAt >= (newest.updatedAt ?? 0))
|
|
15
|
+
newest = task;
|
|
16
|
+
}
|
|
17
|
+
return newest;
|
|
18
|
+
}
|
|
19
|
+
function formatTaskStatus(task, theme) {
|
|
20
|
+
const tone = TASK_TONE[task.status];
|
|
21
|
+
const subject = task.subject.trim();
|
|
22
|
+
const styledSubject = tone === "dim" || tone === "success" ? theme.fg("dim", subject) : theme.fg("text", subject);
|
|
23
|
+
return `${theme.fg(tone, TASK_GLYPH[task.status])} ${theme.fg("accent", `#${task.id}`)} ${styledSubject}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Footer text for the current task state, plus when that text expires.
|
|
27
|
+
*
|
|
28
|
+
* An in-progress task wins, then a recently completed one, then pending, then recently
|
|
29
|
+
* blocked; with no in-progress task a recent completion and a recent block compete on
|
|
30
|
+
* recency. A completion stays visible for `COMPLETED_WITH_SUBSEQUENT_DURATION_MS` when
|
|
31
|
+
* work is already queued behind it, otherwise for `COMPLETED_DISPLAY_DURATION_MS`.
|
|
32
|
+
*/
|
|
33
|
+
export function computeFooterStatus(state, theme, now = Date.now()) {
|
|
34
|
+
const inProgress = state.tasks.find((task) => task.status === "in_progress");
|
|
35
|
+
const completedDuration = inProgress
|
|
36
|
+
? COMPLETED_WITH_SUBSEQUENT_DURATION_MS
|
|
37
|
+
: COMPLETED_DISPLAY_DURATION_MS;
|
|
38
|
+
const recentCompleted = mostRecent(state.tasks, "completed", completedDuration, now);
|
|
39
|
+
const recentBlocked = mostRecent(state.tasks, "blocked", BLOCKED_DISPLAY_DURATION_MS, now);
|
|
40
|
+
if (!inProgress && recentCompleted && recentBlocked) {
|
|
41
|
+
const completedTime = recentCompleted.updatedAt ?? 0;
|
|
42
|
+
const blockedTime = recentBlocked.updatedAt ?? 0;
|
|
43
|
+
if (completedTime >= blockedTime) {
|
|
44
|
+
return {
|
|
45
|
+
text: formatTaskStatus(recentCompleted, theme),
|
|
46
|
+
expiresAt: completedTime + COMPLETED_DISPLAY_DURATION_MS,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
text: formatTaskStatus(recentBlocked, theme),
|
|
51
|
+
expiresAt: blockedTime + BLOCKED_DISPLAY_DURATION_MS,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (recentCompleted) {
|
|
55
|
+
return {
|
|
56
|
+
text: formatTaskStatus(recentCompleted, theme),
|
|
57
|
+
expiresAt: (recentCompleted.updatedAt ?? 0) + completedDuration,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (inProgress) {
|
|
61
|
+
return { text: formatTaskStatus(inProgress, theme) };
|
|
62
|
+
}
|
|
63
|
+
const pending = state.tasks.find((task) => task.status === "pending");
|
|
64
|
+
if (pending) {
|
|
65
|
+
return { text: formatTaskStatus(pending, theme) };
|
|
66
|
+
}
|
|
67
|
+
if (recentBlocked) {
|
|
68
|
+
return {
|
|
69
|
+
text: formatTaskStatus(recentBlocked, theme),
|
|
70
|
+
expiresAt: (recentBlocked.updatedAt ?? 0) + BLOCKED_DISPLAY_DURATION_MS,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { text: undefined };
|
|
74
|
+
}
|
|
75
|
+
export function createTodoFooterStatusController(ui, signal, now = () => Date.now()) {
|
|
76
|
+
let currentTimer;
|
|
77
|
+
let isDisposed = false;
|
|
78
|
+
const clearTimer = () => {
|
|
79
|
+
if (currentTimer !== undefined)
|
|
80
|
+
clearTimeout(currentTimer);
|
|
81
|
+
currentTimer = undefined;
|
|
82
|
+
};
|
|
83
|
+
/** Renders the state, and re-renders when text that is only shown for a while expires. */
|
|
84
|
+
const scheduleNext = (state) => {
|
|
85
|
+
clearTimer();
|
|
86
|
+
const decision = computeFooterStatus(state, ui.theme, now());
|
|
87
|
+
ui.setStatus(TODO_STATUS_KEY, decision.text);
|
|
88
|
+
if (decision.expiresAt !== undefined) {
|
|
89
|
+
currentTimer = setTimeout(() => scheduleNext(state), decision.expiresAt - now());
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const dispose = () => {
|
|
93
|
+
if (isDisposed)
|
|
94
|
+
return;
|
|
95
|
+
isDisposed = true;
|
|
96
|
+
clearTimer();
|
|
97
|
+
ui.setStatus(TODO_STATUS_KEY, undefined);
|
|
98
|
+
};
|
|
99
|
+
if (signal?.aborted === true)
|
|
100
|
+
dispose();
|
|
101
|
+
else
|
|
102
|
+
signal?.addEventListener("abort", dispose, { once: true });
|
|
103
|
+
return {
|
|
104
|
+
update(state) {
|
|
105
|
+
if (isDisposed)
|
|
106
|
+
return;
|
|
107
|
+
scheduleNext(state);
|
|
108
|
+
},
|
|
109
|
+
dispose,
|
|
110
|
+
};
|
|
111
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./extension.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./extension.js";
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export type TaskStatus = "pending" | "in_progress" | "blocked" | "completed" | "suppressed";
|
|
2
|
+
export type AgentTaskStatus = "pending" | "in_progress" | "blocked" | "completed";
|
|
3
|
+
/** How each status is drawn wherever a task is rendered: glyph plus semantic tone. */
|
|
4
|
+
export declare const TASK_GLYPH: Record<TaskStatus, string>;
|
|
5
|
+
export declare const TASK_TONE: Record<TaskStatus, "success" | "warning" | "dim" | "muted">;
|
|
6
|
+
export type TodoAction = "create" | "update" | "list" | "delete";
|
|
7
|
+
export interface Task {
|
|
8
|
+
readonly id: number;
|
|
9
|
+
readonly subject: string;
|
|
10
|
+
readonly status: TaskStatus;
|
|
11
|
+
readonly updatedAt?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface TaskState {
|
|
14
|
+
readonly tasks: readonly Task[];
|
|
15
|
+
readonly nextId: number;
|
|
16
|
+
}
|
|
17
|
+
export type TodoOperation = {
|
|
18
|
+
readonly action: "create";
|
|
19
|
+
readonly subject: string;
|
|
20
|
+
readonly status?: AgentTaskStatus;
|
|
21
|
+
} | {
|
|
22
|
+
readonly action: "update";
|
|
23
|
+
readonly id: number;
|
|
24
|
+
readonly subject?: string;
|
|
25
|
+
readonly status?: AgentTaskStatus;
|
|
26
|
+
} | {
|
|
27
|
+
readonly action: "list";
|
|
28
|
+
readonly status?: TaskStatus;
|
|
29
|
+
} | {
|
|
30
|
+
readonly action: "delete";
|
|
31
|
+
readonly id: number;
|
|
32
|
+
};
|
|
33
|
+
export interface TodoParams {
|
|
34
|
+
readonly operations: readonly TodoOperation[];
|
|
35
|
+
}
|
|
36
|
+
export interface TodoOperationResult {
|
|
37
|
+
readonly index: number;
|
|
38
|
+
readonly action: TodoAction;
|
|
39
|
+
readonly changed: boolean;
|
|
40
|
+
readonly id?: number;
|
|
41
|
+
}
|
|
42
|
+
export type ApplyTodoResult = {
|
|
43
|
+
readonly ok: true;
|
|
44
|
+
readonly changed: boolean;
|
|
45
|
+
readonly state: TaskState;
|
|
46
|
+
readonly operations: readonly TodoOperationResult[];
|
|
47
|
+
} | {
|
|
48
|
+
readonly ok: false;
|
|
49
|
+
readonly state: TaskState;
|
|
50
|
+
readonly error: string;
|
|
51
|
+
readonly operationIndex?: number;
|
|
52
|
+
};
|
|
53
|
+
export declare function freshTaskState(): TaskState;
|
|
54
|
+
export declare function validateTaskState(value: unknown): TaskState | undefined;
|
|
55
|
+
export declare function isValidTimestamp(now: unknown): now is number;
|
|
56
|
+
export declare function activateFirstPending(state: TaskState, now?: number): TaskState;
|
|
57
|
+
/**
|
|
58
|
+
* Applies a tool batch as one transaction. Every operation mutates only `draft`;
|
|
59
|
+
* any validation or transition failure returns the original state, so callers
|
|
60
|
+
* can safely render "No change made" without compensating partial writes.
|
|
61
|
+
*/
|
|
62
|
+
export declare function applyTodo(state: TaskState, params: TodoParams, now?: number): ApplyTodoResult;
|
|
63
|
+
export type CancelTodosResult = {
|
|
64
|
+
readonly ok: true;
|
|
65
|
+
readonly changed: boolean;
|
|
66
|
+
readonly state: TaskState;
|
|
67
|
+
readonly cancelledIds: readonly number[];
|
|
68
|
+
} | {
|
|
69
|
+
readonly ok: false;
|
|
70
|
+
readonly state: TaskState;
|
|
71
|
+
readonly error: string;
|
|
72
|
+
};
|
|
73
|
+
export declare function cancelTodosByUser(state: TaskState, ids: readonly number[], now?: number): CancelTodosResult;
|
|
74
|
+
export type SuppressTodoResult = {
|
|
75
|
+
readonly ok: true;
|
|
76
|
+
readonly changed: boolean;
|
|
77
|
+
readonly state: TaskState;
|
|
78
|
+
} | {
|
|
79
|
+
readonly ok: false;
|
|
80
|
+
readonly state: TaskState;
|
|
81
|
+
readonly error: string;
|
|
82
|
+
};
|
|
83
|
+
export declare function suppressTodoByUser(state: TaskState, id: number, now?: number): SuppressTodoResult;
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { isRecord } from "@hheei/pi-ext-core";
|
|
2
|
+
/** How each status is drawn wherever a task is rendered: glyph plus semantic tone. */
|
|
3
|
+
export const TASK_GLYPH = {
|
|
4
|
+
in_progress: "",
|
|
5
|
+
pending: "",
|
|
6
|
+
blocked: "",
|
|
7
|
+
completed: "",
|
|
8
|
+
suppressed: "",
|
|
9
|
+
};
|
|
10
|
+
export const TASK_TONE = {
|
|
11
|
+
in_progress: "warning",
|
|
12
|
+
pending: "muted",
|
|
13
|
+
blocked: "dim",
|
|
14
|
+
completed: "success",
|
|
15
|
+
suppressed: "muted",
|
|
16
|
+
};
|
|
17
|
+
export function freshTaskState() {
|
|
18
|
+
return { tasks: [], nextId: 1 };
|
|
19
|
+
}
|
|
20
|
+
const STATUSES = [
|
|
21
|
+
"pending",
|
|
22
|
+
"in_progress",
|
|
23
|
+
"blocked",
|
|
24
|
+
"completed",
|
|
25
|
+
"suppressed",
|
|
26
|
+
];
|
|
27
|
+
const ACTIONS = ["create", "update", "list", "delete"];
|
|
28
|
+
function subjectError(subject) {
|
|
29
|
+
if (subject.trim() === "")
|
|
30
|
+
return "Subject must not be empty";
|
|
31
|
+
for (const character of subject) {
|
|
32
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
33
|
+
if (codePoint <= 0x1f ||
|
|
34
|
+
(codePoint >= 0x7f && codePoint <= 0x9f) ||
|
|
35
|
+
codePoint === 0x2028 ||
|
|
36
|
+
codePoint === 0x2029)
|
|
37
|
+
return "Subject must be one printable line";
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
function isPositiveInteger(value) {
|
|
42
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
43
|
+
}
|
|
44
|
+
function isAgentTaskStatus(value) {
|
|
45
|
+
return (value === "pending" || value === "in_progress" || value === "blocked" || value === "completed");
|
|
46
|
+
}
|
|
47
|
+
function hasOnlyKeys(value, keys) {
|
|
48
|
+
return Object.keys(value).every((key) => keys.includes(key));
|
|
49
|
+
}
|
|
50
|
+
function cloneState(state) {
|
|
51
|
+
return {
|
|
52
|
+
tasks: [...state.tasks],
|
|
53
|
+
nextId: state.nextId,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function validateTaskState(value) {
|
|
57
|
+
if (!isRecord(value) || !Array.isArray(value.tasks) || !isPositiveInteger(value.nextId))
|
|
58
|
+
return undefined;
|
|
59
|
+
const ids = new Set();
|
|
60
|
+
const tasks = [];
|
|
61
|
+
for (const valueTask of value.tasks) {
|
|
62
|
+
if (!isRecord(valueTask) || !isPositiveInteger(valueTask.id) || ids.has(valueTask.id))
|
|
63
|
+
return undefined;
|
|
64
|
+
if (typeof valueTask.subject !== "string" || subjectError(valueTask.subject))
|
|
65
|
+
return undefined;
|
|
66
|
+
if (!STATUSES.includes(valueTask.status))
|
|
67
|
+
return undefined;
|
|
68
|
+
if (valueTask.updatedAt !== undefined &&
|
|
69
|
+
(typeof valueTask.updatedAt !== "number" ||
|
|
70
|
+
!Number.isFinite(valueTask.updatedAt) ||
|
|
71
|
+
valueTask.updatedAt < 0)) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
ids.add(valueTask.id);
|
|
75
|
+
tasks.push({
|
|
76
|
+
id: valueTask.id,
|
|
77
|
+
subject: valueTask.subject,
|
|
78
|
+
status: valueTask.status,
|
|
79
|
+
...(valueTask.updatedAt !== undefined ? { updatedAt: valueTask.updatedAt } : {}),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const maxId = tasks.reduce((max, task) => Math.max(max, task.id), 0);
|
|
83
|
+
if (value.nextId <= maxId || tasks.filter((task) => task.status === "in_progress").length > 1)
|
|
84
|
+
return undefined;
|
|
85
|
+
return { tasks, nextId: value.nextId };
|
|
86
|
+
}
|
|
87
|
+
function findTask(tasks, id) {
|
|
88
|
+
return tasks.find((task) => task.id === id);
|
|
89
|
+
}
|
|
90
|
+
function firstPending(tasks) {
|
|
91
|
+
return tasks
|
|
92
|
+
.filter((task) => task.status === "pending")
|
|
93
|
+
.sort((left, right) => left.id - right.id)[0];
|
|
94
|
+
}
|
|
95
|
+
export function isValidTimestamp(now) {
|
|
96
|
+
return typeof now === "number" && Number.isFinite(now) && now >= 0;
|
|
97
|
+
}
|
|
98
|
+
export function activateFirstPending(state, now) {
|
|
99
|
+
if (state.tasks.some((task) => task.status === "in_progress"))
|
|
100
|
+
return state;
|
|
101
|
+
const next = firstPending(state.tasks);
|
|
102
|
+
if (!next)
|
|
103
|
+
return state;
|
|
104
|
+
const timestamp = isValidTimestamp(now) ? Math.floor(now) : undefined;
|
|
105
|
+
return {
|
|
106
|
+
tasks: state.tasks.map((task) => task.id === next.id
|
|
107
|
+
? {
|
|
108
|
+
...task,
|
|
109
|
+
status: "in_progress",
|
|
110
|
+
...(timestamp !== undefined ? { updatedAt: timestamp } : {}),
|
|
111
|
+
}
|
|
112
|
+
: task),
|
|
113
|
+
nextId: state.nextId,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Applies a tool batch as one transaction. Every operation mutates only `draft`;
|
|
118
|
+
* any validation or transition failure returns the original state, so callers
|
|
119
|
+
* can safely render "No change made" without compensating partial writes.
|
|
120
|
+
*/
|
|
121
|
+
export function applyTodo(state, params, now) {
|
|
122
|
+
if (now !== undefined && !isValidTimestamp(now)) {
|
|
123
|
+
return { ok: false, state, error: "Invalid timestamp" };
|
|
124
|
+
}
|
|
125
|
+
const timestamp = now !== undefined ? Math.floor(now) : undefined;
|
|
126
|
+
if (!isRecord(params) || !Array.isArray(params.operations) || params.operations.length === 0) {
|
|
127
|
+
return { ok: false, state, error: "operations must be a non-empty array" };
|
|
128
|
+
}
|
|
129
|
+
if (!validateTaskState(state))
|
|
130
|
+
return { ok: false, state, error: "Invalid task state" };
|
|
131
|
+
const draft = cloneState(state);
|
|
132
|
+
const operations = [];
|
|
133
|
+
let changed = false;
|
|
134
|
+
const fail = (error, operationIndex) => ({
|
|
135
|
+
ok: false,
|
|
136
|
+
state,
|
|
137
|
+
error,
|
|
138
|
+
operationIndex,
|
|
139
|
+
});
|
|
140
|
+
for (let index = 0; index < params.operations.length; index++) {
|
|
141
|
+
const operation = params.operations[index];
|
|
142
|
+
if (!isRecord(operation) ||
|
|
143
|
+
typeof operation.action !== "string" ||
|
|
144
|
+
!ACTIONS.includes(operation.action))
|
|
145
|
+
return fail("Invalid action", index);
|
|
146
|
+
const action = operation.action;
|
|
147
|
+
if (action === "list") {
|
|
148
|
+
if (params.operations.length !== 1 ||
|
|
149
|
+
!hasOnlyKeys(operation, ["action", "status"]) ||
|
|
150
|
+
(operation.status !== undefined && !STATUSES.includes(operation.status)))
|
|
151
|
+
return fail("Invalid list operation", index);
|
|
152
|
+
operations.push({ index, action, changed: false });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (action === "create") {
|
|
156
|
+
if (!hasOnlyKeys(operation, ["action", "subject", "status"]) ||
|
|
157
|
+
typeof operation.subject !== "string")
|
|
158
|
+
return fail("Invalid create fields", index);
|
|
159
|
+
const subjectIssue = subjectError(operation.subject);
|
|
160
|
+
if (subjectIssue)
|
|
161
|
+
return fail(subjectIssue, index);
|
|
162
|
+
if (operation.status !== undefined && !isAgentTaskStatus(operation.status))
|
|
163
|
+
return fail("Invalid status", index);
|
|
164
|
+
const subject = operation.subject.trim();
|
|
165
|
+
const status = operation.status ?? "pending";
|
|
166
|
+
if (draft.nextId >= Number.MAX_SAFE_INTEGER)
|
|
167
|
+
return fail("Task id space exhausted", index);
|
|
168
|
+
const task = {
|
|
169
|
+
id: draft.nextId,
|
|
170
|
+
subject,
|
|
171
|
+
status,
|
|
172
|
+
...(timestamp !== undefined ? { updatedAt: timestamp } : {}),
|
|
173
|
+
};
|
|
174
|
+
const existingTasks = status === "in_progress"
|
|
175
|
+
? draft.tasks.map((existing) => existing.status === "in_progress"
|
|
176
|
+
? {
|
|
177
|
+
...existing,
|
|
178
|
+
status: "pending",
|
|
179
|
+
...(timestamp !== undefined ? { updatedAt: timestamp } : {}),
|
|
180
|
+
}
|
|
181
|
+
: existing)
|
|
182
|
+
: draft.tasks;
|
|
183
|
+
draft.tasks = [...existingTasks, task];
|
|
184
|
+
draft.nextId++;
|
|
185
|
+
changed = true;
|
|
186
|
+
operations.push({ index, action, changed: true, id: task.id });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (action === "delete") {
|
|
190
|
+
if (!hasOnlyKeys(operation, ["action", "id"]) || !isPositiveInteger(operation.id))
|
|
191
|
+
return fail("Invalid delete fields", index);
|
|
192
|
+
const id = operation.id;
|
|
193
|
+
const task = findTask(draft.tasks, id);
|
|
194
|
+
if (!task)
|
|
195
|
+
return fail(`Task #${id} does not exist`, index);
|
|
196
|
+
if (task.status === "suppressed")
|
|
197
|
+
return fail(`Task #${id} is suppressed`, index);
|
|
198
|
+
draft.tasks = draft.tasks.filter((candidate) => candidate.id !== id);
|
|
199
|
+
changed = true;
|
|
200
|
+
operations.push({ index, action, changed: true, id });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (!hasOnlyKeys(operation, ["action", "id", "subject", "status"]) ||
|
|
204
|
+
!isPositiveInteger(operation.id))
|
|
205
|
+
return fail("Invalid update fields", index);
|
|
206
|
+
const id = operation.id;
|
|
207
|
+
const current = findTask(draft.tasks, id);
|
|
208
|
+
if (!current)
|
|
209
|
+
return fail(`Task #${id} does not exist`, index);
|
|
210
|
+
if (current.status === "suppressed")
|
|
211
|
+
return fail(`Task #${id} is suppressed`, index);
|
|
212
|
+
const hasSubject = operation.subject !== undefined;
|
|
213
|
+
const hasStatus = operation.status !== undefined;
|
|
214
|
+
if (!hasSubject && !hasStatus)
|
|
215
|
+
return fail("Update requires a mutable field", index);
|
|
216
|
+
if (hasSubject && typeof operation.subject !== "string")
|
|
217
|
+
return fail("Subject must not be empty", index);
|
|
218
|
+
if (hasSubject) {
|
|
219
|
+
const subjectIssue = subjectError(operation.subject);
|
|
220
|
+
if (subjectIssue)
|
|
221
|
+
return fail(subjectIssue, index);
|
|
222
|
+
}
|
|
223
|
+
if (hasStatus && !isAgentTaskStatus(operation.status))
|
|
224
|
+
return fail("Invalid status", index);
|
|
225
|
+
const subject = hasSubject ? operation.subject.trim() : current.subject;
|
|
226
|
+
const status = isAgentTaskStatus(operation.status) ? operation.status : current.status;
|
|
227
|
+
if (current.status === "completed" && status !== "completed") {
|
|
228
|
+
return fail(`Invalid status transition from completed to ${status}`, index);
|
|
229
|
+
}
|
|
230
|
+
const opTimestamp = timestamp !== undefined
|
|
231
|
+
? timestamp + (params.operations.length > 1 && action === "update" ? index : 0)
|
|
232
|
+
: undefined;
|
|
233
|
+
const next = {
|
|
234
|
+
...current,
|
|
235
|
+
subject,
|
|
236
|
+
status,
|
|
237
|
+
...(opTimestamp !== undefined ? { updatedAt: opTimestamp } : {}),
|
|
238
|
+
};
|
|
239
|
+
// Pi permits exactly one active task. Starting a pending or blocked task is
|
|
240
|
+
// an explicit switch, not an error: demote the previous active task inside
|
|
241
|
+
// this same draft so the invariant never leaks between batch operations.
|
|
242
|
+
const candidateTasks = draft.tasks.map((task) => {
|
|
243
|
+
if (task.id === id)
|
|
244
|
+
return next;
|
|
245
|
+
if (status === "in_progress" && task.status === "in_progress") {
|
|
246
|
+
return {
|
|
247
|
+
...task,
|
|
248
|
+
status: "pending",
|
|
249
|
+
...(timestamp !== undefined ? { updatedAt: timestamp } : {}),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return task;
|
|
253
|
+
});
|
|
254
|
+
const isChanged = candidateTasks.some((task, taskIndex) => {
|
|
255
|
+
const previous = draft.tasks[taskIndex];
|
|
256
|
+
return previous?.subject !== task.subject || previous.status !== task.status;
|
|
257
|
+
});
|
|
258
|
+
if (isChanged) {
|
|
259
|
+
draft.tasks = candidateTasks;
|
|
260
|
+
changed = true;
|
|
261
|
+
}
|
|
262
|
+
operations.push({ index, action, changed: isChanged, id });
|
|
263
|
+
}
|
|
264
|
+
if (changed && !draft.tasks.some((task) => task.status === "in_progress")) {
|
|
265
|
+
const next = firstPending(draft.tasks);
|
|
266
|
+
if (next) {
|
|
267
|
+
draft.tasks = draft.tasks.map((task) => task.id === next.id
|
|
268
|
+
? {
|
|
269
|
+
...task,
|
|
270
|
+
status: "in_progress",
|
|
271
|
+
...(timestamp !== undefined ? { updatedAt: timestamp } : {}),
|
|
272
|
+
}
|
|
273
|
+
: task);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (!changed)
|
|
277
|
+
return { ok: true, changed: false, state, operations };
|
|
278
|
+
return { ok: true, changed: true, state: draft, operations };
|
|
279
|
+
}
|
|
280
|
+
export function cancelTodosByUser(state, ids, now) {
|
|
281
|
+
if (now !== undefined && !isValidTimestamp(now)) {
|
|
282
|
+
return { ok: false, state, error: "Invalid timestamp" };
|
|
283
|
+
}
|
|
284
|
+
const timestamp = now !== undefined ? Math.floor(now) : undefined;
|
|
285
|
+
if (!validateTaskState(state))
|
|
286
|
+
return { ok: false, state, error: "Invalid task state" };
|
|
287
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
288
|
+
return { ok: false, state, error: "At least one task id is required" };
|
|
289
|
+
}
|
|
290
|
+
for (const id of ids) {
|
|
291
|
+
if (!isPositiveInteger(id))
|
|
292
|
+
return { ok: false, state, error: `Invalid task id: ${id}` };
|
|
293
|
+
const current = findTask(state.tasks, id);
|
|
294
|
+
if (!current)
|
|
295
|
+
return { ok: false, state, error: `Task #${id} does not exist` };
|
|
296
|
+
if (current.status === "completed") {
|
|
297
|
+
return { ok: false, state, error: `Task #${id} is already completed` };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const targetIds = new Set(ids);
|
|
301
|
+
const cancelledIds = [];
|
|
302
|
+
let hasChanges = false;
|
|
303
|
+
let tasks = state.tasks.map((task) => {
|
|
304
|
+
if (targetIds.has(task.id) && task.status !== "suppressed") {
|
|
305
|
+
cancelledIds.push(task.id);
|
|
306
|
+
hasChanges = true;
|
|
307
|
+
return {
|
|
308
|
+
...task,
|
|
309
|
+
status: "suppressed",
|
|
310
|
+
...(timestamp !== undefined ? { updatedAt: timestamp } : {}),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
return task;
|
|
314
|
+
});
|
|
315
|
+
if (!hasChanges) {
|
|
316
|
+
return { ok: true, changed: false, state, cancelledIds: [] };
|
|
317
|
+
}
|
|
318
|
+
if (!tasks.some((task) => task.status === "in_progress")) {
|
|
319
|
+
const next = firstPending(tasks);
|
|
320
|
+
if (next) {
|
|
321
|
+
tasks = tasks.map((task) => task.id === next.id
|
|
322
|
+
? {
|
|
323
|
+
...task,
|
|
324
|
+
status: "in_progress",
|
|
325
|
+
...(timestamp !== undefined ? { updatedAt: timestamp } : {}),
|
|
326
|
+
}
|
|
327
|
+
: task);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
ok: true,
|
|
332
|
+
changed: true,
|
|
333
|
+
state: { tasks, nextId: state.nextId },
|
|
334
|
+
cancelledIds,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
export function suppressTodoByUser(state, id, now) {
|
|
338
|
+
const result = cancelTodosByUser(state, [id], now);
|
|
339
|
+
if (!result.ok)
|
|
340
|
+
return result;
|
|
341
|
+
return { ok: true, changed: result.changed, state: result.state };
|
|
342
|
+
}
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Task, type TaskState } from "./model.js";
|
|
2
|
+
export interface TodoSnapshot {
|
|
3
|
+
readonly tasks: readonly Task[];
|
|
4
|
+
readonly nextId: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function snapshotFromState(state: TaskState): TodoSnapshot;
|
|
7
|
+
/** Validates one tool result's display snapshot; it never restores session state. */
|
|
8
|
+
export declare function stateFromSnapshot(value: unknown, now?: number): TaskState | undefined;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { activateFirstPending, validateTaskState } from "./model.js";
|
|
2
|
+
export function snapshotFromState(state) {
|
|
3
|
+
return {
|
|
4
|
+
tasks: state.tasks.map((task) => ({ ...task })),
|
|
5
|
+
nextId: state.nextId,
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
/** Validates one tool result's display snapshot; it never restores session state. */
|
|
9
|
+
export function stateFromSnapshot(value, now) {
|
|
10
|
+
const state = validateTaskState(value);
|
|
11
|
+
return state ? activateFirstPending(state, now) : undefined;
|
|
12
|
+
}
|