pi-quests 0.6.2 → 0.7.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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.0] - 2026-04-26
6
+
7
+ - feat: add batch toggle and batch delete support for quests (#2) — @pascal-de-ladurantaye
8
+ - feat: add redo action and rename revert to undo
9
+ - chore: migrate from taskfile to npm scripts and update dependencies
10
+
5
11
  ## [0.6.2] - 2026-04-17
6
12
 
7
13
  - fix: system prompt was too weak
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-quests
2
2
 
3
- [![version 0.6.2](https://img.shields.io/badge/version-0.6.2-blue)](CHANGELOG.md)
3
+ [![version 0.7.0](https://img.shields.io/badge/version-0.7.0-blue)](CHANGELOG.md)
4
4
  [![MIT license](https://img.shields.io/badge/license-MIT-green)](LICENSE.md)
5
5
  [![pi extension](https://img.shields.io/badge/pi-extension-purple)](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent)
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-quests",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "A quest-log for your pi. Keep your agent on track, one quest at a time.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -35,22 +35,27 @@
35
35
  "@mariozechner/pi-ai": "*",
36
36
  "@mariozechner/pi-coding-agent": "*",
37
37
  "@mariozechner/pi-tui": "*",
38
- "@sinclair/typebox": "*"
38
+ "typebox": "*"
39
39
  },
40
40
  "devDependencies": {
41
- "@biomejs/biome": "^2.4.10",
42
- "@mariozechner/pi-coding-agent": "^0.63.2",
43
- "@mariozechner/pi-tui": "^0.63.0",
44
- "typescript": "^5.7.0",
45
- "vitest": "^3.0.0"
41
+ "@biomejs/biome": "^2.4.13",
42
+ "@mariozechner/pi-coding-agent": "^0.70.2",
43
+ "@mariozechner/pi-tui": "^0.70.2",
44
+ "typebox": "^1.1.33",
45
+ "typescript": "^6.0.3",
46
+ "vitest": "^4.1.5"
46
47
  },
47
48
  "scripts": {
48
49
  "test": "vitest run",
49
50
  "test:watch": "vitest",
50
51
  "typecheck": "tsc --noEmit",
51
- "style": "biome check ./src ./test",
52
- "style:fix": "biome check --write --unsafe --no-errors-on-unmatched ./src ./test",
52
+ "style": "biome check --error-on-warnings ./src ./test",
53
+ "style:fix": "biome check --write --unsafe --no-errors-on-unmatched --error-on-warnings ./src ./test",
53
54
  "format": "biome format --write --no-errors-on-unmatched ./src ./test",
54
- "prepublishOnly": "npx tsc --noEmit && npx vitest run"
55
+ "prepublishOnly": "npx tsc --noEmit && npx vitest run",
56
+ "prepare": "bash scripts/setup.sh",
57
+ "cleanup": "bash scripts/cleanup.sh",
58
+ "dev": "bash scripts/dev.sh",
59
+ "logs": "bash scripts/logs.sh"
55
60
  }
56
61
  }
@@ -25,7 +25,8 @@ type MutatingCommand = Extract<
25
25
  | typeof QUEST_ACTIONS.clear
26
26
  | typeof QUEST_ACTIONS.reorder
27
27
  | typeof QUEST_ACTIONS.reparent
28
- | typeof QUEST_ACTIONS.revert;
28
+ | typeof QUEST_ACTIONS.undo
29
+ | typeof QUEST_ACTIONS.redo;
29
30
  }
30
31
  >["action"];
31
32
 
@@ -41,13 +42,13 @@ const commandActionBuilders: {
41
42
  id: p.id,
42
43
  descriptions: p.descriptions,
43
44
  }),
44
- [QUEST_ACTIONS.toggle]: (p) => ({ type: QUEST_ACTIONS.toggle, id: p.id }),
45
+ [QUEST_ACTIONS.toggle]: (p) => ({ type: QUEST_ACTIONS.toggle, ids: p.ids }),
45
46
  [QUEST_ACTIONS.update]: (p) => ({
46
47
  type: QUEST_ACTIONS.update,
47
48
  id: p.id,
48
49
  description: p.description,
49
50
  }),
50
- [QUEST_ACTIONS.delete]: (p) => ({ type: QUEST_ACTIONS.delete, id: p.id }),
51
+ [QUEST_ACTIONS.delete]: (p) => ({ type: QUEST_ACTIONS.delete, ids: p.ids }),
51
52
  [QUEST_ACTIONS.clear]: (p) => ({ type: QUEST_ACTIONS.clear, all: p.all }),
52
53
  [QUEST_ACTIONS.reorder]: (p) => ({
53
54
  type: QUEST_ACTIONS.reorder,
@@ -59,7 +60,8 @@ const commandActionBuilders: {
59
60
  id: p.id,
60
61
  parentId: p.parentId,
61
62
  }),
62
- [QUEST_ACTIONS.revert]: () => ({ type: QUEST_ACTIONS.revert }),
63
+ [QUEST_ACTIONS.undo]: () => ({ type: QUEST_ACTIONS.undo }),
64
+ [QUEST_ACTIONS.redo]: () => ({ type: QUEST_ACTIONS.redo }),
63
65
  };
64
66
 
65
67
  export function openQuestList(
@@ -143,7 +145,8 @@ export function createQuestsHandler(
143
145
  case QUEST_ACTIONS.clear:
144
146
  case QUEST_ACTIONS.reorder:
145
147
  case QUEST_ACTIONS.reparent:
146
- case QUEST_ACTIONS.revert: {
148
+ case QUEST_ACTIONS.undo:
149
+ case QUEST_ACTIONS.redo: {
147
150
  const builder = commandActionBuilders[parsed.action];
148
151
  const action = builder(parsed as never);
149
152
  const result = questLog.execute(action);
@@ -161,12 +164,13 @@ export function createQuestsHandler(
161
164
  lines.push(" add <description> - Add a new top-level quest");
162
165
  lines.push(" add-step <id> <description> - Split a quest into a step");
163
166
  lines.push(" list - List all quests");
164
- lines.push(" toggle <id> - Toggle quest completion");
165
- lines.push(" delete <id> - Delete a quest");
167
+ lines.push(" toggle <id> [moreIds...] - Toggle one or more quests");
168
+ lines.push(" delete <id> [moreIds...] - Delete one or more quests");
166
169
  lines.push(" update <id> <desc> - Update a quest description");
167
170
  lines.push(" reparent <id> [parentId] - Reparent a quest/step (omit parentId to promote)");
168
171
  lines.push(" reorder <id> <targetId> - Reorder a quest before the target quest");
169
- lines.push(" revert - Revert the last quest change");
172
+ lines.push(" undo - Undo the last quest change");
173
+ lines.push(" redo - Redo the last undone action");
170
174
  lines.push(" clear [all] - Clear completed quests, or optionally all quests");
171
175
  lines.push(" version - Show version");
172
176
  lines.push(" changelog - Show changelog");
@@ -5,12 +5,13 @@ export type ParsedArgs =
5
5
  | { action: typeof QUEST_ACTIONS.add; descriptions: string[] }
6
6
  | { action: typeof QUEST_ACTIONS.split; id: string; descriptions: string[] }
7
7
  | { action: typeof QUEST_ACTIONS.list }
8
- | { action: typeof QUEST_ACTIONS.toggle; id: string }
8
+ | { action: typeof QUEST_ACTIONS.toggle; ids: string[] }
9
9
  | { action: typeof QUEST_ACTIONS.update; id: string; description: string }
10
- | { action: typeof QUEST_ACTIONS.delete; id: string }
10
+ | { action: typeof QUEST_ACTIONS.delete; ids: string[] }
11
11
  | { action: typeof QUEST_ACTIONS.clear; all?: boolean }
12
12
  | { action: typeof QUEST_ACTIONS.reorder; id: string; targetId: string }
13
- | { action: typeof QUEST_ACTIONS.revert }
13
+ | { action: typeof QUEST_ACTIONS.undo }
14
+ | { action: typeof QUEST_ACTIONS.redo }
14
15
  | { action: typeof QUEST_ACTIONS.reparent; id: string; parentId?: string }
15
16
  | { action: "help" }
16
17
  | { action: "version" }
@@ -40,13 +41,14 @@ export function parseQuestArgs(args: string, idLength = 2): ParsedArgs {
40
41
  // Quest actions without arguments
41
42
  if (command === QUEST_ACTIONS.list) return { action: QUEST_ACTIONS.list };
42
43
  if (command === QUEST_ACTIONS.clear) return parseClearArgs(rest);
43
- if (command === QUEST_ACTIONS.revert) return { action: QUEST_ACTIONS.revert };
44
+ if (command === QUEST_ACTIONS.undo) return { action: QUEST_ACTIONS.undo };
45
+ if (command === QUEST_ACTIONS.redo) return { action: QUEST_ACTIONS.redo };
44
46
 
45
47
  // Quest actions with arguments
46
48
  if (command === QUEST_ACTIONS.add) return parseAddArgs(rest);
47
49
  if (command === "add-step") return parseAddStepArgs(rest, idLength);
48
- if (command === QUEST_ACTIONS.toggle) return parseIdAction(QUEST_ACTIONS.toggle, rest, idLength);
49
- if (command === QUEST_ACTIONS.delete) return parseIdAction(QUEST_ACTIONS.delete, rest, idLength);
50
+ if (command === QUEST_ACTIONS.toggle) return parseToggleArgs(rest, idLength);
51
+ if (command === QUEST_ACTIONS.delete) return parseDeleteArgs(rest, idLength);
50
52
  if (command === QUEST_ACTIONS.update) return parseUpdateArgs(rest, idLength);
51
53
  if (command === QUEST_ACTIONS.reorder) return parseReorderArgs(rest, idLength);
52
54
  if (command === QUEST_ACTIONS.reparent) return parseReparentArgs(rest, idLength);
@@ -70,16 +72,26 @@ function parseAddStepArgs(tokens: string[], idLength: number): ParsedArgs {
70
72
  return { action: QUEST_ACTIONS.split, id, descriptions: [description] };
71
73
  }
72
74
 
73
- function parseIdAction(
74
- action: typeof QUEST_ACTIONS.toggle | typeof QUEST_ACTIONS.delete,
75
- tokens: string[],
76
- idLength: number,
77
- ): ParsedArgs {
78
- const id = tokens[0] ? tokens[0].toLowerCase() : "";
75
+ function parseToggleArgs(tokens: string[], idLength: number): ParsedArgs {
79
76
  const pattern = new RegExp(`^[0-9a-f]{${idLength}}$`);
80
- if (!pattern.test(id)) return { error: `Usage: /quests ${action} <id>` };
77
+ const ids = tokens.map((token) => token.toLowerCase());
78
+
79
+ if (ids.length === 0 || ids.some((id) => !pattern.test(id))) {
80
+ return { error: "Usage: /quests toggle <id> [moreIds...]" };
81
+ }
82
+
83
+ return { action: QUEST_ACTIONS.toggle, ids };
84
+ }
85
+
86
+ function parseDeleteArgs(tokens: string[], idLength: number): ParsedArgs {
87
+ const pattern = new RegExp(`^[0-9a-f]{${idLength}}$`);
88
+ const ids = tokens.map((token) => token.toLowerCase());
89
+
90
+ if (ids.length === 0 || ids.some((id) => !pattern.test(id))) {
91
+ return { error: "Usage: /quests delete <id> [moreIds...]" };
92
+ }
81
93
 
82
- return { action, id };
94
+ return { action: QUEST_ACTIONS.delete, ids };
83
95
  }
84
96
 
85
97
  function parseUpdateArgs(tokens: string[], idLength: number): ParsedArgs {
@@ -20,7 +20,8 @@ This skill provides guidelines and best practices for using the quest management
20
20
  | `clear` | — | Remove completed quests. `all: true` removes everything. |
21
21
  | `reorder` | `id`, `targetId` | Move a top-level quest before `targetId`. Steps cannot be reordered. |
22
22
  | `reparent` | `id`, `parentId?` | Demote to step under `parentId`, or promote to top-level if `parentId` omitted. |
23
- | `revert` | — | Undo the most recent mutating action. One level only. |
23
+ | `undo` | — | Undo the most recent mutating action. One level only. |
24
+ | `redo` | — | Redo the most recently undone action. |
24
25
  | `rules` / `skill` | — | Return this document. |
25
26
 
26
27
  ## Workflows
@@ -51,7 +52,8 @@ This skill provides guidelines and best practices for using the quest management
51
52
  - NEVER try to nest steps. Only top-level quests can be parents.
52
53
  - NEVER mark a parent as done if they have incomplete steps.
53
54
  - NEVER delete a quest with incomplete steps.
54
- - Use revert if you make a mistake to undo an action.
55
+ - Use undo if you make a mistake to undo an action.
56
+ - Use redo to replay an action you just undid. Redo is cleared when a new mutation happens.
55
57
  - ALWAYS use the hex ID, never the positional number when referring to quests in actions.
56
58
  - ALWAYS Use `toggle` for done states. NEVER use `update` to append "DONE" or any completion marker to a description.
57
59
  - If a parent toggle is blocked, check that all its steps are done first.
@@ -5,6 +5,8 @@ import { getQuestSkillDocument } from "../prompts.js";
5
5
  import {
6
6
  formatAddResult,
7
7
  formatBatchAddResult,
8
+ formatBatchDeleteResult,
9
+ formatBatchToggleResult,
8
10
  formatBlockedBySteps,
9
11
  formatDeleteResult,
10
12
  formatDescriptionRequiredError,
@@ -12,7 +14,8 @@ import {
12
14
  formatIdRequiredError,
13
15
  formatMissingDescriptionsError,
14
16
  formatNotFound,
15
- formatNothingToRevertError,
17
+ formatNothingToRedoError,
18
+ formatNothingToUndoError,
16
19
  formatParentDoneError,
17
20
  formatParentNotFoundError,
18
21
  formatQuestList,
@@ -32,17 +35,17 @@ import {
32
35
  } from "./formatters.js";
33
36
  import { QUEST_ACTIONS, type Quest, type Step } from "./types.js";
34
37
 
38
+ type DeletedQuestSnapshot = {
39
+ deletedIds: string[];
40
+ previousQuests: Quest[];
41
+ previousSteps: Step[];
42
+ };
43
+
35
44
  export type HistoryEntry =
36
45
  | { type: typeof QUEST_ACTIONS.add; id: string; parentId?: string }
37
- | { type: typeof QUEST_ACTIONS.toggle; id: string }
46
+ | { type: typeof QUEST_ACTIONS.toggle; ids: string[] }
38
47
  | { type: typeof QUEST_ACTIONS.update; id: string; previousDescription: string }
39
- | {
40
- type: typeof QUEST_ACTIONS.delete;
41
- quest: Quest | Step;
42
- index: number;
43
- isStep?: boolean;
44
- cascadeDeletedSteps?: Step[];
45
- }
48
+ | ({ type: typeof QUEST_ACTIONS.delete; ids: string[] } & DeletedQuestSnapshot)
46
49
  | {
47
50
  type: typeof QUEST_ACTIONS.clear;
48
51
  previousQuests: Quest[];
@@ -69,17 +72,40 @@ export type QuestAction =
69
72
  | { type: typeof QUEST_ACTIONS.split; id?: string; descriptions?: string[] }
70
73
  | { type: typeof QUEST_ACTIONS.add_step; id?: string; descriptions?: string[] }
71
74
  | { type: typeof QUEST_ACTIONS.list }
72
- | { type: typeof QUEST_ACTIONS.toggle; id?: string }
75
+ | { type: typeof QUEST_ACTIONS.toggle; id?: string; ids?: string[] }
73
76
  | { type: typeof QUEST_ACTIONS.update; id?: string; description?: string }
74
- | { type: typeof QUEST_ACTIONS.delete; id?: string }
77
+ | { type: typeof QUEST_ACTIONS.delete; id?: string; ids?: string[] }
75
78
  | { type: typeof QUEST_ACTIONS.clear; all?: boolean }
76
79
  | { type: typeof QUEST_ACTIONS.reorder; id?: string; targetId?: string }
77
- | { type: typeof QUEST_ACTIONS.revert }
80
+ | { type: typeof QUEST_ACTIONS.undo }
81
+ | { type: typeof QUEST_ACTIONS.redo }
78
82
  | { type: typeof QUEST_ACTIONS.reparent; id?: string; parentId?: string }
79
83
  | { type: typeof QUEST_ACTIONS.rules }
80
84
  | { type: typeof QUEST_ACTIONS.skill };
81
85
 
82
- export type QuestOperationResult = { success: boolean; message: string; quest?: Quest };
86
+ export type RedoEntry =
87
+ | { type: typeof QUEST_ACTIONS.add; quest: Quest | Step }
88
+ | { type: typeof QUEST_ACTIONS.toggle; ids: string[] }
89
+ | { type: typeof QUEST_ACTIONS.update; id: string; description: string }
90
+ | { type: typeof QUEST_ACTIONS.delete; ids: string[] }
91
+ | { type: typeof QUEST_ACTIONS.clear; all: boolean }
92
+ | { type: typeof QUEST_ACTIONS.reorder; id: string; targetId: string }
93
+ | { type: typeof QUEST_ACTIONS.reparent; id: string; parentId?: string };
94
+
95
+ export type QuestOperationResult = {
96
+ success: boolean;
97
+ message: string;
98
+ quest?: Quest;
99
+ quests?: Quest[];
100
+ };
101
+
102
+ type ToggleBatchResult =
103
+ | { quests: (Quest | Step)[]; ids: string[] }
104
+ | { error: "notFound" | "blocked"; id: string };
105
+
106
+ type DeleteBatchResult =
107
+ | { quests: (Quest | Step)[]; ids: string[] }
108
+ | { error: "notFound" | "blocked"; id: string };
83
109
 
84
110
  /**
85
111
  * In-memory quest data plane.
@@ -92,6 +118,8 @@ export class QuestLog {
92
118
  private steps: Step[] = [];
93
119
  private usedIds: Set<string> = new Set();
94
120
  private history: HistoryEntry[] = [];
121
+ private redoStack: RedoEntry[] = [];
122
+ private inRedo = false;
95
123
 
96
124
  private readonly ID_LENGTH: number;
97
125
  private readonly MAX_IDS: number;
@@ -136,16 +164,30 @@ export class QuestLog {
136
164
  return { success: true, message: `Reverted add quest [${entry.id}]` };
137
165
  },
138
166
  [QUEST_ACTIONS.toggle]: (entry) => {
139
- const quest = this.findById(entry.id);
167
+ const quests = entry.ids.map((id) => this.findById(id));
168
+ const missingId = entry.ids.find((_, index) => !quests[index]);
140
169
 
141
- if (quest) {
170
+ if (missingId) {
171
+ logger.debug("quests:state", "revert-toggle-not-found", { ids: entry.ids, missingId });
172
+ return { success: false, message: formatNotFound(missingId) };
173
+ }
174
+
175
+ const resolvedQuests = quests.filter((quest): quest is Quest | Step => quest !== undefined);
176
+ for (const quest of resolvedQuests) {
142
177
  quest.done = !quest.done;
143
- logger.debug("quests:state", "revert-toggle", { id: entry.id, done: quest.done });
144
- return { success: true, message: `Reverted toggle for quest [${entry.id}]` };
145
178
  }
146
179
 
147
- logger.debug("quests:state", "revert-toggle-not-found", { id: entry.id });
148
- return { success: false, message: formatNotFound(entry.id) };
180
+ logger.debug("quests:state", "revert-toggle", {
181
+ ids: entry.ids,
182
+ done: resolvedQuests.map((quest) => quest.done),
183
+ });
184
+ return {
185
+ success: true,
186
+ message:
187
+ entry.ids.length === 1
188
+ ? `Reverted toggle for quest [${entry.ids[0]}]`
189
+ : `Reverted toggle for ${entry.ids.length} quests`,
190
+ };
149
191
  },
150
192
  [QUEST_ACTIONS.update]: (entry) => {
151
193
  const quest = this.findById(entry.id);
@@ -159,25 +201,21 @@ export class QuestLog {
159
201
  return { success: false, message: formatNotFound(entry.id) };
160
202
  },
161
203
  [QUEST_ACTIONS.delete]: (entry) => {
162
- if (entry.isStep) {
163
- this.steps.splice(entry.index, 0, entry.quest as Step);
164
- } else {
165
- this.quests.splice(entry.index, 0, entry.quest);
166
- if (entry.cascadeDeletedSteps) {
167
- for (const sub of entry.cascadeDeletedSteps) {
168
- this.steps.push(sub);
169
- this.usedIds.add(sub.id);
170
- }
171
- }
172
- }
173
- this.usedIds.add(entry.quest.id);
204
+ this.quests = [...entry.previousQuests];
205
+ this.steps = [...entry.previousSteps];
206
+ this.usedIds = new Set([...this.quests, ...this.steps].map((quest) => quest.id));
174
207
 
175
208
  logger.debug("quests:state", "revert-delete", {
176
- id: entry.quest.id,
177
- index: entry.index,
209
+ ids: entry.deletedIds,
178
210
  total: this.quests.length + this.steps.length,
179
211
  });
180
- return { success: true, message: `Reverted delete for quest [${entry.quest.id}]` };
212
+ return {
213
+ success: true,
214
+ message:
215
+ entry.ids.length === 1
216
+ ? `Reverted delete for quest [${entry.ids[0]}]`
217
+ : `Reverted delete for ${entry.ids.length} tasks`,
218
+ };
181
219
  },
182
220
  [QUEST_ACTIONS.clear]: (entry) => {
183
221
  if ("previousQuests" in entry) {
@@ -230,6 +268,114 @@ export class QuestLog {
230
268
  },
231
269
  };
232
270
 
271
+ private redoHandlers: {
272
+ [K in RedoEntry["type"]]: (entry: Extract<RedoEntry, { type: K }>) => {
273
+ success: boolean;
274
+ message: string;
275
+ };
276
+ } = {
277
+ [QUEST_ACTIONS.add]: (entry) => {
278
+ if ("parentId" in entry.quest && entry.quest.parentId) {
279
+ this.steps.push(entry.quest as Step);
280
+ this.history.push({
281
+ type: QUEST_ACTIONS.add,
282
+ id: entry.quest.id,
283
+ parentId: entry.quest.parentId,
284
+ });
285
+ } else {
286
+ this.quests.push(entry.quest);
287
+ this.history.push({ type: QUEST_ACTIONS.add, id: entry.quest.id });
288
+ }
289
+ this.usedIds.add(entry.quest.id);
290
+ return { success: true, message: `Redone add quest [${entry.quest.id}]` };
291
+ },
292
+ [QUEST_ACTIONS.toggle]: (entry) => {
293
+ const result = this.toggleMany(entry.ids);
294
+ if ("error" in result) {
295
+ return {
296
+ success: false,
297
+ message:
298
+ result.error === "blocked"
299
+ ? formatBlockedBySteps(result.id)
300
+ : formatNotFound(result.id),
301
+ };
302
+ }
303
+
304
+ return {
305
+ success: true,
306
+ message:
307
+ entry.ids.length === 1
308
+ ? `Redone toggle for quest [${entry.ids[0]}]`
309
+ : `Redone toggle for ${entry.ids.length} quests`,
310
+ };
311
+ },
312
+ [QUEST_ACTIONS.update]: (entry) => {
313
+ const q = this.update(entry.id, entry.description);
314
+ if (q) return { success: true, message: `Redone update for quest [${entry.id}]` };
315
+ return { success: false, message: formatNotFound(entry.id) };
316
+ },
317
+ [QUEST_ACTIONS.delete]: (entry) => {
318
+ const result = this.deleteMany(entry.ids);
319
+ if ("error" in result) {
320
+ return {
321
+ success: false,
322
+ message:
323
+ result.error === "blocked"
324
+ ? formatBlockedBySteps(result.id)
325
+ : formatNotFound(result.id),
326
+ };
327
+ }
328
+
329
+ const firstQuest = result.quests[0];
330
+ return {
331
+ success: true,
332
+ message:
333
+ entry.ids.length === 1 && firstQuest
334
+ ? `Redone delete for quest [${firstQuest.id}]`
335
+ : `Redone delete for ${entry.ids.length} tasks`,
336
+ };
337
+ },
338
+ [QUEST_ACTIONS.clear]: (entry) => {
339
+ const count = this.clear(entry.all);
340
+ return {
341
+ success: true,
342
+ message: `Redone clear (${count} quests ${entry.all ? "" : "completed "}cleared)`,
343
+ };
344
+ },
345
+ [QUEST_ACTIONS.reorder]: (entry) => {
346
+ const q = this.reorder(entry.id, entry.targetId);
347
+ if (q) return { success: true, message: `Redone reorder for quest [${entry.id}]` };
348
+ return { success: false, message: formatReorderedQuestNotFoundError() };
349
+ },
350
+ [QUEST_ACTIONS.reparent]: (entry) => {
351
+ try {
352
+ const q = this.reparent(entry.id, entry.parentId);
353
+ if (q) return { success: true, message: `Redone reparent for quest [${entry.id}]` };
354
+ return { success: false, message: formatNotFound(entry.id) };
355
+ } catch (err) {
356
+ return {
357
+ success: false,
358
+ message: `Error: ${err instanceof Error ? err.message : String(err)}`,
359
+ };
360
+ }
361
+ },
362
+ };
363
+
364
+ redo(): QuestOperationResult {
365
+ const entry = this.redoStack.pop();
366
+ if (!entry) {
367
+ return { success: false, message: formatNothingToRedoError() };
368
+ }
369
+ const handler = this.redoHandlers[entry.type];
370
+ if (handler) {
371
+ this.inRedo = true;
372
+ const result = handler(entry as never);
373
+ this.inRedo = false;
374
+ return result;
375
+ }
376
+ return { success: false, message: "Unknown redo entry" };
377
+ }
378
+
233
379
  /*
234
380
  * Returns all quests including steps, inserted in order
235
381
  * steps are returned immediately after their parent quest
@@ -265,11 +411,73 @@ export class QuestLog {
265
411
  return this.quests.find((q) => q.id === id) ?? this.steps.find((step) => step.id === id);
266
412
  }
267
413
 
414
+ private normalizeIds(ids: string[]): string[] {
415
+ return [...new Set(ids)];
416
+ }
417
+
418
+ private validateToggleBatch(ids: string[]): ToggleBatchResult {
419
+ const normalizedIds = this.normalizeIds(ids);
420
+ const quests: (Quest | Step)[] = [];
421
+
422
+ for (const id of normalizedIds) {
423
+ const quest = this.findById(id);
424
+ if (!quest) {
425
+ logger.debug("quests:state", "toggle-not-found", { id });
426
+ return { error: "notFound", id };
427
+ }
428
+ quests.push(quest);
429
+ }
430
+
431
+ const toggledIds = new Set(normalizedIds);
432
+ for (const quest of quests) {
433
+ if (quest.done || "parentId" in quest) continue;
434
+
435
+ const steps = this.getSteps(quest.id);
436
+ const hasIncompleteSteps = steps.some((step) => {
437
+ const finalDone = toggledIds.has(step.id) ? !step.done : step.done;
438
+ return !finalDone;
439
+ });
440
+
441
+ if (hasIncompleteSteps) {
442
+ logger.debug("quests:state", "toggle-blocked-steps", { id: quest.id, ids: normalizedIds });
443
+ return { error: "blocked", id: quest.id };
444
+ }
445
+ }
446
+
447
+ return { quests, ids: normalizedIds };
448
+ }
449
+
450
+ private applyToggleBatch(quests: (Quest | Step)[], ids: string[]): Quest[] {
451
+ for (const quest of quests) {
452
+ quest.done = !quest.done;
453
+ }
454
+
455
+ this.history.push({ type: QUEST_ACTIONS.toggle, ids });
456
+ logger.debug("quests:state", QUEST_ACTIONS.toggle, {
457
+ ids,
458
+ total: this.quests.length + this.steps.length,
459
+ });
460
+
461
+ return quests;
462
+ }
463
+
464
+ toggleMany(ids: string[]): ToggleBatchResult {
465
+ if (!this.inRedo) this.redoStack = [];
466
+ const result = this.validateToggleBatch(ids);
467
+ if ("error" in result) return result;
468
+
469
+ return {
470
+ ids: result.ids,
471
+ quests: this.applyToggleBatch(result.quests, result.ids),
472
+ };
473
+ }
474
+
268
475
  getUsedIds(): string[] {
269
476
  return Array.from(this.usedIds);
270
477
  }
271
478
 
272
479
  add(description: string): Quest {
480
+ if (!this.inRedo) this.redoStack = [];
273
481
  const quest: Quest = {
274
482
  id: this.generateId(),
275
483
  description,
@@ -289,6 +497,7 @@ export class QuestLog {
289
497
  }
290
498
 
291
499
  addStep(description: string, parentId: string): Step {
500
+ if (!this.inRedo) this.redoStack = [];
292
501
  if (this.steps.some((step) => step.id === parentId)) {
293
502
  throw new Error(formatStepCannotHaveSteps(parentId));
294
503
  }
@@ -332,32 +541,16 @@ export class QuestLog {
332
541
  }
333
542
 
334
543
  toggle(id: string): Quest | Step | null | undefined {
335
- const quest = this.findById(id);
336
- if (!quest) {
337
- logger.debug("quests:state", "toggle-not-found", { id });
338
- return undefined;
339
- }
340
-
341
- if (!quest.done && !("parentId" in quest)) {
342
- const steps = this.getSteps(id);
343
- if (steps.some((q) => !q.done)) {
344
- logger.debug("quests:state", "toggle-blocked-steps", { id });
345
- return null;
346
- }
544
+ const result = this.toggleMany([id]);
545
+ if ("error" in result) {
546
+ return result.error === "blocked" ? null : undefined;
347
547
  }
348
548
 
349
- quest.done = !quest.done;
350
- this.history.push({ type: QUEST_ACTIONS.toggle, id });
351
-
352
- logger.debug("quests:state", QUEST_ACTIONS.toggle, {
353
- id,
354
- done: quest.done,
355
- total: this.quests.length + this.steps.length,
356
- });
357
- return quest;
549
+ return result.quests[0];
358
550
  }
359
551
 
360
552
  update(id: string, description: string): Quest | Step | undefined {
553
+ if (!this.inRedo) this.redoStack = [];
361
554
  const quest = this.findById(id);
362
555
  if (!quest) {
363
556
  logger.debug("quests:state", "update-not-found", { id });
@@ -375,49 +568,91 @@ export class QuestLog {
375
568
  return quest;
376
569
  }
377
570
 
571
+ private validateDeleteBatch(
572
+ ids: string[],
573
+ ): { ids: string[]; deletedIds: string[] } | { error: "notFound" | "blocked"; id: string } {
574
+ const normalizedIds = this.normalizeIds(ids);
575
+ const selectedIds = new Set(normalizedIds);
576
+ const deletedIds = new Set<string>();
577
+
578
+ for (const id of normalizedIds) {
579
+ const quest = this.findById(id);
580
+ if (!quest) {
581
+ logger.debug("quests:state", "delete-not-found", { id });
582
+ return { error: "notFound", id };
583
+ }
584
+
585
+ if ("parentId" in quest) {
586
+ deletedIds.add(quest.id);
587
+ continue;
588
+ }
589
+
590
+ const steps = this.getSteps(quest.id);
591
+ const hasUnselectedIncompleteStep = steps.some(
592
+ (step) => !step.done && !selectedIds.has(step.id),
593
+ );
594
+ if (hasUnselectedIncompleteStep) {
595
+ logger.debug("quests:state", "delete-blocked-steps", { id: quest.id, ids: normalizedIds });
596
+ return { error: "blocked", id: quest.id };
597
+ }
598
+
599
+ deletedIds.add(quest.id);
600
+ for (const step of steps) {
601
+ deletedIds.add(step.id);
602
+ }
603
+ }
604
+
605
+ return { ids: normalizedIds, deletedIds: [...deletedIds] };
606
+ }
607
+
608
+ private applyDeleteBatch(ids: string[], deletedIds: string[]): Quest[] {
609
+ const deletedIdSet = new Set(deletedIds);
610
+ const previousQuests = [...this.quests];
611
+ const previousSteps = [...this.steps];
612
+ const deletedQuests = this.getAll().filter((quest) => deletedIdSet.has(quest.id));
613
+
614
+ this.quests = this.quests.filter((quest) => !deletedIdSet.has(quest.id));
615
+ this.steps = this.steps.filter((step) => !deletedIdSet.has(step.id));
616
+ this.usedIds = new Set([...this.quests, ...this.steps].map((quest) => quest.id));
617
+ this.history.push({
618
+ type: QUEST_ACTIONS.delete,
619
+ ids,
620
+ deletedIds,
621
+ previousQuests,
622
+ previousSteps,
623
+ });
624
+
625
+ logger.debug("quests:state", QUEST_ACTIONS.delete, {
626
+ ids,
627
+ deletedIds,
628
+ total: this.quests.length + this.steps.length,
629
+ });
630
+
631
+ return deletedQuests;
632
+ }
633
+
634
+ deleteMany(ids: string[]): DeleteBatchResult {
635
+ if (!this.inRedo) this.redoStack = [];
636
+ const result = this.validateDeleteBatch(ids);
637
+ if ("error" in result) return result;
638
+
639
+ return {
640
+ ids: result.ids,
641
+ quests: this.applyDeleteBatch(result.ids, result.deletedIds),
642
+ };
643
+ }
644
+
378
645
  delete(id: string): Quest | Step | null | undefined {
379
- const index = this.quests.findIndex((q) => q.id === id);
380
- if (index !== -1) {
381
- const steps = this.getSteps(id);
382
- if (steps.some((q) => !q.done)) {
383
- logger.debug("quests:state", "delete-blocked-steps", { id });
384
- return null;
385
- }
386
- const [quest] = this.quests.splice(index, 1);
387
- this.usedIds.delete(quest.id);
388
- const cascadeDeletedSteps = this.steps.filter((step) => step.parentId === id);
389
- this.history.push({ type: QUEST_ACTIONS.delete, quest, index, cascadeDeletedSteps });
390
- this.steps = this.steps.filter((step) => {
391
- if (step.parentId === id) {
392
- this.usedIds.delete(step.id);
393
- return false;
394
- }
395
- return true;
396
- });
397
- logger.debug("quests:state", QUEST_ACTIONS.delete, {
398
- id,
399
- index,
400
- total: this.quests.length + this.steps.length,
401
- });
402
- return quest;
403
- }
404
- const stepIndex = this.steps.findIndex((step) => step.id === id);
405
- if (stepIndex !== -1) {
406
- const [quest] = this.steps.splice(stepIndex, 1);
407
- this.usedIds.delete(quest.id);
408
- this.history.push({ type: QUEST_ACTIONS.delete, quest, index: stepIndex, isStep: true });
409
- logger.debug("quests:state", QUEST_ACTIONS.delete, {
410
- id,
411
- index: stepIndex,
412
- total: this.quests.length + this.steps.length,
413
- });
414
- return quest;
646
+ const result = this.deleteMany([id]);
647
+ if ("error" in result) {
648
+ return result.error === "blocked" ? null : undefined;
415
649
  }
416
- logger.debug("quests:state", "delete-not-found", { id });
417
- return undefined;
650
+
651
+ return result.quests[0];
418
652
  }
419
653
 
420
654
  clear(all = false): number {
655
+ if (!this.inRedo) this.redoStack = [];
421
656
  if (!all) {
422
657
  const doneParentIds = new Set(this.quests.filter((q) => q.done).map((q) => q.id));
423
658
  const done = this.quests.filter((q) => q.done);
@@ -458,6 +693,7 @@ export class QuestLog {
458
693
  }
459
694
 
460
695
  reorder(id: string, targetId: string): Quest | undefined {
696
+ if (!this.inRedo) this.redoStack = [];
461
697
  if (this.steps.some((step) => step.id === id || step.id === targetId)) {
462
698
  logger.debug("quests:state", "reorder-step-rejected", { id, targetId });
463
699
  return undefined;
@@ -498,6 +734,7 @@ export class QuestLog {
498
734
  }
499
735
 
500
736
  reparent(id: string, parentId?: string): Quest | Step | undefined {
737
+ if (!this.inRedo) this.redoStack = [];
501
738
  const quest = this.findById(id);
502
739
  if (!quest) return undefined;
503
740
 
@@ -553,29 +790,72 @@ export class QuestLog {
553
790
  return quest;
554
791
  }
555
792
 
556
- revert(): QuestOperationResult {
793
+ undo(): QuestOperationResult {
557
794
  const entry = this.history.pop();
558
795
  if (!entry) {
559
- logger.debug("quests:state", "revert-empty");
560
- return { success: false, message: formatNothingToRevertError() };
796
+ logger.debug("quests:state", "undo-empty");
797
+ return { success: false, message: formatNothingToUndoError() };
561
798
  }
562
799
 
563
- logger.debug("quests:state", "revert", { type: entry.type });
800
+ logger.debug("quests:state", "undo", { type: entry.type });
801
+ this.redoStack.push(this.buildRedoEntry(entry));
564
802
 
565
803
  const handler = this.undoHandlers[entry.type];
566
804
  if (handler) {
567
805
  return handler(entry as never);
568
806
  }
569
807
 
570
- logger.debug("quests:state", "revert-unknown", { type: (entry as { type: string }).type });
808
+ logger.debug("quests:state", "undo-unknown", { type: (entry as { type: string }).type });
571
809
  return { success: false, message: "Unknown history entry" };
572
810
  }
573
811
 
812
+ private buildRedoEntry(entry: HistoryEntry): RedoEntry {
813
+ switch (entry.type) {
814
+ case QUEST_ACTIONS.add: {
815
+ const quest = this.findById(entry.id);
816
+ if (!quest) throw new Error(formatNotFound(entry.id));
817
+ return { type: QUEST_ACTIONS.add, quest };
818
+ }
819
+ case QUEST_ACTIONS.toggle:
820
+ return { type: QUEST_ACTIONS.toggle, ids: entry.ids };
821
+ case QUEST_ACTIONS.update:
822
+ return {
823
+ type: QUEST_ACTIONS.update,
824
+ id: entry.id,
825
+ description: this.findById(entry.id)?.description ?? entry.previousDescription,
826
+ };
827
+ case QUEST_ACTIONS.delete:
828
+ return { type: QUEST_ACTIONS.delete, ids: entry.ids };
829
+ case QUEST_ACTIONS.clear:
830
+ return { type: QUEST_ACTIONS.clear, all: entry.all };
831
+ case QUEST_ACTIONS.reorder:
832
+ return { type: QUEST_ACTIONS.reorder, id: entry.quest.id, targetId: entry.targetId };
833
+ case QUEST_ACTIONS.reparent: {
834
+ const quest = this.findById(entry.id);
835
+ if (!quest) throw new Error(formatNotFound(entry.id));
836
+ return {
837
+ type: QUEST_ACTIONS.reparent,
838
+ id: entry.id,
839
+ parentId: "parentId" in quest ? quest.parentId : undefined,
840
+ };
841
+ }
842
+ }
843
+ }
844
+
574
845
  /**
575
846
  * Execute a quest action and return a presentation-ready result.
576
847
  * This is the primary API for all adapter layers (commands, tools).
577
848
  */
578
849
  execute(action: QuestAction): QuestOperationResult {
850
+ if (
851
+ action.type !== QUEST_ACTIONS.list &&
852
+ action.type !== QUEST_ACTIONS.rules &&
853
+ action.type !== QUEST_ACTIONS.skill &&
854
+ action.type !== QUEST_ACTIONS.undo &&
855
+ action.type !== QUEST_ACTIONS.redo
856
+ ) {
857
+ this.redoStack = [];
858
+ }
579
859
  switch (action.type) {
580
860
  case QUEST_ACTIONS.add: {
581
861
  if (action.descriptions && action.descriptions.length > 0) {
@@ -623,19 +903,42 @@ export class QuestLog {
623
903
  return { success: true, message: formatQuestList(quests) };
624
904
  }
625
905
  case QUEST_ACTIONS.toggle: {
626
- if (action.id === undefined) {
906
+ const ids = action.ids?.length ? action.ids : action.id ? [action.id] : undefined;
907
+ if (!ids || ids.length === 0) {
627
908
  return { success: false, message: formatIdRequiredError("toggle") };
628
909
  }
629
910
 
630
- const q = this.toggle(action.id);
631
- if (q === undefined) {
632
- return { success: false, message: formatNotFound(action.id) };
911
+ const result = this.toggleMany(ids);
912
+ if ("error" in result) {
913
+ return {
914
+ success: false,
915
+ message:
916
+ result.error === "blocked"
917
+ ? formatBlockedBySteps(result.id)
918
+ : formatNotFound(result.id),
919
+ };
633
920
  }
634
- if (q === null) {
635
- return { success: false, message: formatBlockedBySteps(action.id) };
921
+
922
+ if (result.quests.length === 1) {
923
+ const quest = result.quests[0];
924
+ if (!quest) {
925
+ return { success: false, message: formatNotFound(result.ids[0]) };
926
+ }
927
+
928
+ return {
929
+ success: true,
930
+ message: formatToggleResult(result.ids[0], quest.done),
931
+ quest,
932
+ };
636
933
  }
637
934
 
638
- return { success: true, message: formatToggleResult(action.id, q.done), quest: q };
935
+ return {
936
+ success: true,
937
+ message: formatBatchToggleResult(
938
+ result.quests.map((quest, index) => ({ id: result.ids[index], done: quest.done })),
939
+ ),
940
+ quests: result.quests,
941
+ };
639
942
  }
640
943
  case QUEST_ACTIONS.update: {
641
944
  if (action.id === undefined) {
@@ -654,19 +957,38 @@ export class QuestLog {
654
957
  return { success: true, message: formatUpdateResult(q), quest: q };
655
958
  }
656
959
  case QUEST_ACTIONS.delete: {
657
- if (action.id === undefined) {
960
+ const ids = action.ids?.length ? action.ids : action.id ? [action.id] : undefined;
961
+ if (!ids || ids.length === 0) {
658
962
  return { success: false, message: formatIdRequiredError("delete") };
659
963
  }
660
964
 
661
- const q = this.delete(action.id);
662
- if (q === undefined) {
663
- return { success: false, message: formatNotFound(action.id) };
965
+ const result = this.deleteMany(ids);
966
+ if ("error" in result) {
967
+ return {
968
+ success: false,
969
+ message:
970
+ result.error === "blocked"
971
+ ? formatBlockedBySteps(result.id)
972
+ : formatNotFound(result.id),
973
+ };
664
974
  }
665
- if (q === null) {
666
- return { success: false, message: formatBlockedBySteps(action.id) };
975
+
976
+ if (result.ids.length === 1) {
977
+ const quest = result.quests[0];
978
+ if (!quest) {
979
+ return { success: false, message: formatNotFound(result.ids[0]) };
980
+ }
981
+
982
+ return { success: true, message: formatDeleteResult(quest), quest };
667
983
  }
668
984
 
669
- return { success: true, message: formatDeleteResult(q), quest: q };
985
+ return {
986
+ success: true,
987
+ message: formatBatchDeleteResult(
988
+ result.quests.map((quest) => ({ id: quest.id, description: quest.description })),
989
+ ),
990
+ quests: result.quests,
991
+ };
670
992
  }
671
993
  case QUEST_ACTIONS.clear: {
672
994
  const count = this.clear(action.all);
@@ -730,8 +1052,11 @@ export class QuestLog {
730
1052
  case QUEST_ACTIONS.skill: {
731
1053
  return { success: true, message: getQuestSkillDocument() };
732
1054
  }
733
- case QUEST_ACTIONS.revert: {
734
- return this.revert();
1055
+ case QUEST_ACTIONS.undo: {
1056
+ return this.undo();
1057
+ }
1058
+ case QUEST_ACTIONS.redo: {
1059
+ return this.redo();
735
1060
  }
736
1061
  default: {
737
1062
  return {
@@ -787,6 +1112,7 @@ export function makeToolResult(
787
1112
  text: string,
788
1113
  questLog: QuestLog,
789
1114
  displayQuests?: Quest[],
1115
+ snapshotQuests?: Quest[],
790
1116
  ): AgentToolResult<unknown> {
791
1117
  return {
792
1118
  content: [{ type: "text", text }],
@@ -794,6 +1120,7 @@ export function makeToolResult(
794
1120
  quests: questLog.getAll(),
795
1121
  usedIds: questLog.getUsedIds(),
796
1122
  displayQuests,
1123
+ snapshotQuests,
797
1124
  },
798
1125
  };
799
1126
  }
@@ -44,6 +44,12 @@ export function formatToggleResult(id: string, done: boolean): string {
44
44
  return `Quest [${id}] ${done ? "done" : "undone"}`;
45
45
  }
46
46
 
47
+ export function formatBatchToggleResult(toggled: { id: string; done: boolean }[]): string {
48
+ return `Toggled ${toggled.length} tasks:\n${toggled
49
+ .map((quest) => `[${quest.id}] ${quest.done ? "done" : "undone"}`)
50
+ .join("\n")}`;
51
+ }
52
+
47
53
  export function formatUpdateResult(q: { id: string; description: string }): string {
48
54
  return `Updated quest [${q.id}]: ${q.description}`;
49
55
  }
@@ -52,6 +58,10 @@ export function formatDeleteResult(q: { id: string; description: string }): stri
52
58
  return `Deleted quest [${q.id}]: ${q.description}`;
53
59
  }
54
60
 
61
+ export function formatBatchDeleteResult(deleted: { id: string; description: string }[]): string {
62
+ return `Deleted ${deleted.length} tasks:\n${deleted.map((quest) => `[${quest.id}] ${quest.description}`).join("\n")}`;
63
+ }
64
+
55
65
  export function formatNotFound(id: string): string {
56
66
  return `Quest [${id}] not found. IDs are random hex strings shown in brackets. Use the list action to see valid IDs.`;
57
67
  }
@@ -100,8 +110,12 @@ export function formatUnknownActionError(action: string): string {
100
110
  return `Unknown action: ${action}. Use the list action or check the tool schema for supported actions.`;
101
111
  }
102
112
 
103
- export function formatNothingToRevertError(): string {
104
- return `Nothing to revert. The history is empty because no mutating actions have been performed yet.`;
113
+ export function formatNothingToUndoError(): string {
114
+ return `Nothing to undo. The history is empty because no mutating actions have been performed yet.`;
115
+ }
116
+
117
+ export function formatNothingToRedoError(): string {
118
+ return `Nothing to redo. The redo stack is empty because no actions have been undone yet.`;
105
119
  }
106
120
 
107
121
  export function formatReorderedQuestNotFoundError(): string {
@@ -10,7 +10,8 @@ export const QUEST_ACTIONS = {
10
10
  delete: "delete",
11
11
  clear: "clear",
12
12
  reorder: "reorder",
13
- revert: "revert",
13
+ undo: "undo",
14
+ redo: "redo",
14
15
  reparent: "reparent",
15
16
  rules: "rules",
16
17
  skill: "skill",
@@ -26,7 +27,8 @@ export const QUEST_ACTION_VALUES = [
26
27
  QUEST_ACTIONS.delete,
27
28
  QUEST_ACTIONS.clear,
28
29
  QUEST_ACTIONS.reorder,
29
- QUEST_ACTIONS.revert,
30
+ QUEST_ACTIONS.undo,
31
+ QUEST_ACTIONS.redo,
30
32
  QUEST_ACTIONS.reparent,
31
33
  QUEST_ACTIONS.rules,
32
34
  QUEST_ACTIONS.skill,
@@ -11,6 +11,7 @@ type QuestArgs = {
11
11
  descriptions?: string[];
12
12
  targetId?: string;
13
13
  id?: string;
14
+ ids?: string[];
14
15
  all?: boolean;
15
16
  };
16
17
 
@@ -31,12 +32,29 @@ export function renderQuestCall(args: QuestArgs, theme: Theme, _context: unknown
31
32
  );
32
33
  }
33
34
 
34
- if (
35
- (args.action === QUEST_ACTIONS.toggle ||
36
- args.action === QUEST_ACTIONS.update ||
37
- args.action === QUEST_ACTIONS.delete) &&
38
- args.id !== undefined
39
- ) {
35
+ if (args.action === QUEST_ACTIONS.toggle) {
36
+ if (args.ids && args.ids.length > 1) {
37
+ return new Text(`${actionText} ${theme.fg("muted", `[${args.ids.length} tasks]`)}`, 0, 0);
38
+ }
39
+
40
+ const toggleId = args.id ?? args.ids?.[0];
41
+ if (toggleId !== undefined) {
42
+ return new Text(`${actionText} ${theme.fg("muted", `[${toggleId}]`)}`, 0, 0);
43
+ }
44
+ }
45
+
46
+ if (args.action === QUEST_ACTIONS.delete) {
47
+ if (args.ids && args.ids.length > 1) {
48
+ return new Text(`${actionText} ${theme.fg("muted", `[${args.ids.length} tasks]`)}`, 0, 0);
49
+ }
50
+
51
+ const deleteId = args.id ?? args.ids?.[0];
52
+ if (deleteId !== undefined) {
53
+ return new Text(`${actionText} ${theme.fg("muted", `[${deleteId}]`)}`, 0, 0);
54
+ }
55
+ }
56
+
57
+ if (args.action === QUEST_ACTIONS.update && args.id !== undefined) {
40
58
  return new Text(`${actionText} ${theme.fg("muted", `[${args.id}]`)}`, 0, 0);
41
59
  }
42
60
 
@@ -59,8 +77,11 @@ export function renderQuestResult(config: ResolvedConfig) {
59
77
  isPartial: options.isPartial,
60
78
  });
61
79
  const details = result.details as Record<string, unknown> | undefined;
62
- const allQuests = details?.quests as Quest[] | undefined;
63
- const questsToRender = (details?.displayQuests ?? details?.quests) as Quest[] | undefined;
80
+ const snapshotQuests = details?.snapshotQuests as Quest[] | undefined;
81
+ const allQuests = (snapshotQuests ?? details?.quests) as Quest[] | undefined;
82
+ const questsToRender = (snapshotQuests ?? details?.displayQuests ?? details?.quests) as
83
+ | Quest[]
84
+ | undefined;
64
85
 
65
86
  if (
66
87
  !Array.isArray(questsToRender) ||
@@ -1,17 +1,18 @@
1
- import type { AgentToolResult, ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { type Static, Type } from "@sinclair/typebox";
1
+ import type { AgentToolResult, ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
2
+ import { type Static, Type } from "typebox";
3
3
  import type { ResolvedConfig } from "../config.js";
4
4
  import { logger } from "../logger.js";
5
5
  import { QUEST_PROMPT_GATE, QUEST_PROMPT_REMINDER } from "../prompts.js";
6
6
  import { makeToolResult, type QuestAction, type QuestLog } from "../quest/dataplane.js";
7
- import { QUEST_ACTIONS } from "../quest/types.js";
7
+ import { QUEST_ACTIONS, type QuestActionType } from "../quest/types.js";
8
8
 
9
9
  const SPLIT_DISPLAY_ACTIONS = [
10
10
  QUEST_ACTIONS.add,
11
11
  QUEST_ACTIONS.list,
12
12
  QUEST_ACTIONS.split,
13
13
  QUEST_ACTIONS.add_step,
14
- QUEST_ACTIONS.revert,
14
+ QUEST_ACTIONS.undo,
15
+ QUEST_ACTIONS.redo,
15
16
  ] as const;
16
17
 
17
18
  import { invalidateQuestListWidget } from "../renderers/commands.js";
@@ -20,75 +21,72 @@ import { createQuestParams, type QuestParamsType } from "./params.js";
20
21
 
21
22
  type QuestToolParams = Static<QuestParamsType>;
22
23
 
23
- const toolHandlers: {
24
- [K in QuestToolParams["action"]]: (
25
- questLog: QuestLog,
26
- params: QuestToolParams,
27
- toolCallId: string,
28
- ) => AgentToolResult<unknown>;
29
- } = {
30
- [QUEST_ACTIONS.add](questLog, params, toolCallId) {
31
- return runTool(questLog, toolCallId, {
24
+ type ToolHandler = (
25
+ questLog: QuestLog,
26
+ params: QuestToolParams,
27
+ toolCallId: string,
28
+ ) => AgentToolResult<unknown>;
29
+
30
+ const toolHandlers: Record<QuestActionType, ToolHandler> = {
31
+ [QUEST_ACTIONS.add]: (questLog, params, toolCallId) =>
32
+ runTool(questLog, toolCallId, {
32
33
  type: QUEST_ACTIONS.add,
33
34
  descriptions: params.descriptions,
34
- });
35
- },
36
- [QUEST_ACTIONS.split](questLog, params, toolCallId) {
37
- return runTool(questLog, toolCallId, {
35
+ }),
36
+ [QUEST_ACTIONS.split]: (questLog, params, toolCallId) =>
37
+ runTool(questLog, toolCallId, {
38
38
  type: QUEST_ACTIONS.split,
39
39
  id: params.id,
40
40
  descriptions: params.descriptions,
41
- });
42
- },
43
- [QUEST_ACTIONS.add_step](questLog, params, toolCallId) {
44
- return runTool(questLog, toolCallId, {
41
+ }),
42
+ [QUEST_ACTIONS.add_step]: (questLog, params, toolCallId) =>
43
+ runTool(questLog, toolCallId, {
45
44
  type: QUEST_ACTIONS.add_step,
46
45
  id: params.id,
47
46
  descriptions: params.descriptions,
48
- });
49
- },
50
- [QUEST_ACTIONS.list](questLog, _params, toolCallId) {
51
- return runTool(questLog, toolCallId, { type: QUEST_ACTIONS.list });
52
- },
53
- [QUEST_ACTIONS.toggle](questLog, params, toolCallId) {
54
- return runTool(questLog, toolCallId, { type: QUEST_ACTIONS.toggle, id: params.id });
55
- },
56
- [QUEST_ACTIONS.update](questLog, params, toolCallId) {
57
- return runTool(questLog, toolCallId, {
47
+ }),
48
+ [QUEST_ACTIONS.list]: (questLog, _params, toolCallId) =>
49
+ runTool(questLog, toolCallId, { type: QUEST_ACTIONS.list }),
50
+ [QUEST_ACTIONS.toggle]: (questLog, params, toolCallId) =>
51
+ runTool(questLog, toolCallId, {
52
+ type: QUEST_ACTIONS.toggle,
53
+ id: params.id,
54
+ ids: params.ids,
55
+ }),
56
+ [QUEST_ACTIONS.update]: (questLog, params, toolCallId) =>
57
+ runTool(questLog, toolCallId, {
58
58
  type: QUEST_ACTIONS.update,
59
59
  id: params.id,
60
60
  description: params.description,
61
- });
62
- },
63
- [QUEST_ACTIONS.delete](questLog, params, toolCallId) {
64
- return runTool(questLog, toolCallId, { type: QUEST_ACTIONS.delete, id: params.id });
65
- },
66
- [QUEST_ACTIONS.clear](questLog, params, toolCallId) {
67
- return runTool(questLog, toolCallId, { type: QUEST_ACTIONS.clear, all: params.all });
68
- },
69
- [QUEST_ACTIONS.reorder](questLog, params, toolCallId) {
70
- return runTool(questLog, toolCallId, {
61
+ }),
62
+ [QUEST_ACTIONS.delete]: (questLog, params, toolCallId) =>
63
+ runTool(questLog, toolCallId, {
64
+ type: QUEST_ACTIONS.delete,
65
+ id: params.id,
66
+ ids: params.ids,
67
+ }),
68
+ [QUEST_ACTIONS.clear]: (questLog, params, toolCallId) =>
69
+ runTool(questLog, toolCallId, { type: QUEST_ACTIONS.clear, all: params.all }),
70
+ [QUEST_ACTIONS.reorder]: (questLog, params, toolCallId) =>
71
+ runTool(questLog, toolCallId, {
71
72
  type: QUEST_ACTIONS.reorder,
72
73
  id: params.id,
73
74
  targetId: params.targetId,
74
- });
75
- },
76
- [QUEST_ACTIONS.reparent](questLog, params, toolCallId) {
77
- return runTool(questLog, toolCallId, {
75
+ }),
76
+ [QUEST_ACTIONS.reparent]: (questLog, params, toolCallId) =>
77
+ runTool(questLog, toolCallId, {
78
78
  type: QUEST_ACTIONS.reparent,
79
79
  id: params.id,
80
80
  parentId: params.parentId,
81
- });
82
- },
83
- [QUEST_ACTIONS.rules](questLog, _params, toolCallId) {
84
- return runTool(questLog, toolCallId, { type: QUEST_ACTIONS.rules });
85
- },
86
- [QUEST_ACTIONS.skill](questLog, _params, toolCallId) {
87
- return runTool(questLog, toolCallId, { type: QUEST_ACTIONS.skill });
88
- },
89
- [QUEST_ACTIONS.revert](questLog, _params, toolCallId) {
90
- return runTool(questLog, toolCallId, { type: QUEST_ACTIONS.revert });
91
- },
81
+ }),
82
+ [QUEST_ACTIONS.rules]: (questLog, _params, toolCallId) =>
83
+ runTool(questLog, toolCallId, { type: QUEST_ACTIONS.rules }),
84
+ [QUEST_ACTIONS.skill]: (questLog, _params, toolCallId) =>
85
+ runTool(questLog, toolCallId, { type: QUEST_ACTIONS.skill }),
86
+ [QUEST_ACTIONS.undo]: (questLog, _params, toolCallId) =>
87
+ runTool(questLog, toolCallId, { type: QUEST_ACTIONS.undo }),
88
+ [QUEST_ACTIONS.redo]: (questLog, _params, toolCallId) =>
89
+ runTool(questLog, toolCallId, { type: QUEST_ACTIONS.redo }),
92
90
  };
93
91
 
94
92
  function runTool(
@@ -104,11 +102,10 @@ function runTool(
104
102
  action.type as (typeof SPLIT_DISPLAY_ACTIONS)[number],
105
103
  )
106
104
  ? questLog.getAll()
107
- : result.quest
108
- ? [result.quest]
109
- : [];
105
+ : (result.quests ?? (result.quest ? [result.quest] : []));
106
+ const snapshotQuests = action.type === QUEST_ACTIONS.delete ? displayQuests : undefined;
110
107
 
111
- return makeToolResult(result.message, questLog, displayQuests);
108
+ return makeToolResult(result.message, questLog, displayQuests, snapshotQuests);
112
109
  }
113
110
 
114
111
  export async function questToolExecute(
@@ -116,8 +113,13 @@ export async function questToolExecute(
116
113
  toolCallId: string,
117
114
  params: QuestToolParams,
118
115
  ): Promise<AgentToolResult<unknown>> {
119
- logger.debug("quests:tool", "execute", { toolCallId, action: params.action, id: params.id });
120
- const handler = toolHandlers[params.action];
116
+ logger.debug("quests:tool", "execute", {
117
+ toolCallId,
118
+ action: params.action,
119
+ id: params.id,
120
+ ids: params.ids,
121
+ });
122
+ const handler = toolHandlers[params.action as QuestActionType];
121
123
  return handler(questLog, params, toolCallId);
122
124
  }
123
125
 
@@ -140,7 +142,11 @@ export function registerQuestTool(
140
142
  parameters: createQuestParams(config.ids.length),
141
143
  execute: (toolCallId, params, _signal, _onUpdate, _ctx) =>
142
144
  questToolExecute(questLog, toolCallId, params),
143
- renderCall: renderQuestCall,
145
+ renderCall: renderQuestCall as unknown as (
146
+ args: object,
147
+ theme: Theme,
148
+ context: unknown,
149
+ ) => ReturnType<typeof renderQuestCall>,
144
150
  renderResult: renderQuestResult(config),
145
151
  });
146
152
 
@@ -159,8 +165,12 @@ export function registerQuestTool(
159
165
  ),
160
166
  execute: async (_toolCallId, _params, _signal, _onUpdate, _ctx) =>
161
167
  questToolExecute(questLog, "learn_quests", { action: QUEST_ACTIONS.skill }),
162
- renderCall: (_args, theme, context) =>
163
- renderQuestCall({ action: QUEST_ACTIONS.skill }, theme, context),
168
+ renderCall: ((_args: object, theme: Theme, context: unknown) =>
169
+ renderQuestCall({ action: QUEST_ACTIONS.skill }, theme, context)) as unknown as (
170
+ args: object,
171
+ theme: Theme,
172
+ context: unknown,
173
+ ) => ReturnType<typeof renderQuestCall>,
164
174
  renderResult: renderQuestResult(config),
165
175
  });
166
176
  }
@@ -1,5 +1,5 @@
1
1
  import { StringEnum } from "@mariozechner/pi-ai";
2
- import { Type } from "@sinclair/typebox";
2
+ import { Type } from "typebox";
3
3
  import { QUEST_ACTION_VALUES } from "../quest/types.js";
4
4
 
5
5
  export function createQuestParams(idLength: number) {
@@ -22,7 +22,13 @@ export function createQuestParams(idLength: number) {
22
22
  id: Type.Optional(
23
23
  Type.String({
24
24
  pattern,
25
- description: `Quest ID (required for toggle, update, delete, reorder actions). ALWAYS use the ${idLength}-digit hex ID shown in brackets, never the positional number.`,
25
+ description: `Quest ID (required for update, reorder, and reparent actions. For toggle and delete, provide either id or ids. ALWAYS use the ${idLength}-digit hex ID shown in brackets, never the positional number.`,
26
+ }),
27
+ ),
28
+ ids: Type.Optional(
29
+ Type.Array(Type.String({ pattern }), {
30
+ minItems: 1,
31
+ description: `Quest IDs for batch toggle or delete actions. Use this when operating on more than one ${idLength}-digit hex quest ID at once.`,
26
32
  }),
27
33
  ),
28
34
  targetId: Type.Optional(