@oh-my-pi/pi-coding-agent 14.4.0 → 14.4.3

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/package.json +7 -7
  3. package/src/cli.ts +0 -1
  4. package/src/config/prompt-templates.ts +1 -31
  5. package/src/config/settings-schema.ts +27 -37
  6. package/src/config/settings.ts +1 -1
  7. package/src/edit/index.ts +1 -53
  8. package/src/edit/line-hash.ts +13 -63
  9. package/src/edit/modes/atom.ts +334 -64
  10. package/src/edit/modes/hashline.ts +19 -26
  11. package/src/edit/renderer.ts +6 -8
  12. package/src/edit/streaming.ts +90 -114
  13. package/src/export/html/template.generated.ts +1 -1
  14. package/src/export/html/template.js +10 -15
  15. package/src/internal-urls/docs-index.generated.ts +1 -2
  16. package/src/lsp/defaults.json +142 -652
  17. package/src/modes/components/session-selector.ts +3 -3
  18. package/src/modes/components/settings-defs.ts +0 -5
  19. package/src/modes/components/tool-execution.ts +2 -5
  20. package/src/modes/controllers/btw-controller.ts +17 -105
  21. package/src/modes/controllers/todo-command-controller.ts +537 -0
  22. package/src/modes/interactive-mode.ts +35 -9
  23. package/src/modes/types.ts +2 -0
  24. package/src/modes/utils/ui-helpers.ts +17 -0
  25. package/src/prompts/system/irc-incoming.md +8 -0
  26. package/src/prompts/system/subagent-system-prompt.md +8 -0
  27. package/src/prompts/tools/ast-edit.md +1 -1
  28. package/src/prompts/tools/ast-grep.md +1 -0
  29. package/src/prompts/tools/atom.md +55 -53
  30. package/src/prompts/tools/bash.md +2 -2
  31. package/src/prompts/tools/grep.md +2 -5
  32. package/src/prompts/tools/irc.md +49 -0
  33. package/src/prompts/tools/job.md +11 -0
  34. package/src/prompts/tools/read.md +12 -13
  35. package/src/prompts/tools/task.md +1 -1
  36. package/src/prompts/tools/todo-write.md +14 -5
  37. package/src/registry/agent-registry.ts +139 -0
  38. package/src/sdk.ts +35 -0
  39. package/src/session/agent-session.ts +217 -5
  40. package/src/session/session-manager.ts +4 -1
  41. package/src/session/streaming-output.ts +1 -1
  42. package/src/slash-commands/builtin-registry.ts +24 -0
  43. package/src/task/executor.ts +14 -0
  44. package/src/tools/bash.ts +1 -1
  45. package/src/tools/fetch.ts +18 -6
  46. package/src/tools/fs-cache-invalidation.ts +0 -5
  47. package/src/tools/grep.ts +5 -125
  48. package/src/tools/index.ts +12 -6
  49. package/src/tools/irc.ts +258 -0
  50. package/src/tools/job.ts +489 -0
  51. package/src/tools/match-line-format.ts +8 -7
  52. package/src/tools/output-meta.ts +1 -1
  53. package/src/tools/read.ts +37 -131
  54. package/src/tools/renderers.ts +2 -0
  55. package/src/tools/todo-write.ts +243 -12
  56. package/src/tools/write.ts +2 -2
  57. package/src/utils/edit-mode.ts +1 -2
  58. package/src/utils/file-display-mode.ts +0 -3
  59. package/src/cli/read-cli.ts +0 -67
  60. package/src/commands/read.ts +0 -33
  61. package/src/edit/modes/chunk.ts +0 -832
  62. package/src/prompts/tools/cancel-job.md +0 -5
  63. package/src/prompts/tools/chunk-edit.md +0 -158
  64. package/src/prompts/tools/poll.md +0 -5
  65. package/src/prompts/tools/read-chunk.md +0 -73
  66. package/src/tools/cancel-job.ts +0 -95
  67. package/src/tools/poll-tool.ts +0 -173
package/src/tools/read.ts CHANGED
@@ -9,20 +9,11 @@ import { Text } from "@oh-my-pi/pi-tui";
9
9
  import { getRemoteDir, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import { type Static, Type } from "@sinclair/typebox";
11
11
  import { formatHashLines } from "../edit/line-hash";
12
- import {
13
- type ChunkReadTarget,
14
- formatChunkedRead,
15
- parseChunkReadPath,
16
- parseChunkSelector,
17
- resolveAnchorStyle,
18
- resolveChunkAutoIndent,
19
- } from "../edit/modes/chunk";
20
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
21
13
  import { parseInternalUrl } from "../internal-urls/parse";
22
14
  import type { InternalUrl } from "../internal-urls/types";
23
15
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
24
16
  import readDescription from "../prompts/tools/read.md" with { type: "text" };
25
- import readChunkDescription from "../prompts/tools/read-chunk.md" with { type: "text" };
26
17
  import type { ToolSession } from "../sdk";
27
18
  import {
28
19
  DEFAULT_MAX_BYTES,
@@ -34,7 +25,6 @@ import {
34
25
  } from "../session/streaming-output";
35
26
  import { renderCodeCell, renderStatusLine } from "../tui";
36
27
  import { CachedOutputBlock } from "../tui/output-block";
37
- import { resolveEditMode } from "../utils/edit-mode";
38
28
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
39
29
  import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading";
40
30
  import { convertFileWithMarkit } from "../utils/markit";
@@ -71,12 +61,6 @@ import {
71
61
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
72
62
  import { toolResult } from "./tool-result";
73
63
 
74
- const PROSE_LANGUAGES = new Set(["markdown", "text", "log", "asciidoc", "restructuredtext"]);
75
-
76
- function isProseLanguage(language: string | undefined): boolean {
77
- return language !== undefined && PROSE_LANGUAGES.has(language);
78
- }
79
-
80
64
  // Document types converted to markdown via markit.
81
65
  const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
82
66
 
@@ -92,17 +76,13 @@ function prependLineNumbers(text: string, startNum: number): string {
92
76
  return textLines.map((line, i) => `${startNum + i}|${line}`).join("\n");
93
77
  }
94
78
 
95
- function prependHashLines(text: string, startNum: number): string {
96
- return formatHashLines(text, startNum);
97
- }
98
-
99
79
  function formatTextWithMode(
100
80
  text: string,
101
81
  startNum: number,
102
82
  shouldAddHashLines: boolean,
103
83
  shouldAddLineNumbers: boolean,
104
84
  ): string {
105
- if (shouldAddHashLines) return prependHashLines(text, startNum);
85
+ if (shouldAddHashLines) return formatHashLines(text, startNum);
106
86
  if (shouldAddLineNumbers) return prependLineNumbers(text, startNum);
107
87
  return text;
108
88
  }
@@ -362,7 +342,7 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
362
342
 
363
343
  const readSchema = Type.Object({
364
344
  path: Type.String({ description: "path or url", examples: ["src/foo.ts", "https://example.com"] }),
365
- sel: Type.Optional(Type.String({ description: "line range or mode", examples: ["L50", "L50-L120", "raw"] })),
345
+ sel: Type.Optional(Type.String({ description: "line range or mode", examples: ["50", "50-200", "50+150", "raw"] })),
366
346
  timeout: Type.Optional(Type.Number({ description: "timeout in seconds", default: 20 })),
367
347
  });
368
348
 
@@ -374,7 +354,6 @@ export interface ReadToolDetails {
374
354
  isDirectory?: boolean;
375
355
  resolvedPath?: string;
376
356
  suffixResolution?: { from: string; to: string };
377
- chunk?: ChunkReadTarget;
378
357
  url?: string;
379
358
  finalUrl?: string;
380
359
  contentType?: string;
@@ -393,28 +372,37 @@ type ReadParams = ReadToolInput;
393
372
  type ParsedSelector =
394
373
  | { kind: "none" }
395
374
  | { kind: "raw" }
396
- | { kind: "lines"; startLine: number; endLine: number | undefined }
397
- | { kind: "chunk"; selector: string };
375
+ | { kind: "lines"; startLine: number; endLine: number | undefined };
398
376
 
399
- const LINE_RANGE_RE = /^L(\d+)(?:-L?(\d+))?$/i;
377
+ const LINE_RANGE_RE = /^L?(\d+)(?:([-+])L?(\d+))?$/i;
400
378
 
401
379
  function parseSel(sel: string | undefined): ParsedSelector {
402
380
  if (!sel || sel.length === 0) return { kind: "none" };
403
- const normalizedSelector = parseChunkSelector(sel).selector ?? sel;
404
- if (normalizedSelector === "raw") return { kind: "raw" };
405
- const lineMatch = LINE_RANGE_RE.exec(normalizedSelector);
381
+ if (sel === "raw") return { kind: "raw" };
382
+ const lineMatch = LINE_RANGE_RE.exec(sel);
406
383
  if (lineMatch) {
407
384
  const rawStart = Number.parseInt(lineMatch[1]!, 10);
408
385
  if (rawStart < 1) {
409
- throw new ToolError("L0 is invalid; lines are 1-indexed. Use sel=L1.");
386
+ throw new ToolError("sel=0 is invalid; lines are 1-indexed. Use sel=1.");
410
387
  }
411
- const rawEnd = lineMatch[2] ? Number.parseInt(lineMatch[2], 10) : undefined;
412
- if (rawEnd !== undefined && rawEnd < rawStart) {
413
- throw new ToolError(`Invalid range L${rawStart}-L${rawEnd}: end must be >= start.`);
388
+ const sep = lineMatch[2];
389
+ const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
390
+ let rawEnd: number | undefined;
391
+ if (sep === "+") {
392
+ if (rhs === undefined || rhs < 1) {
393
+ throw new ToolError(`Invalid range ${rawStart}+${rhs ?? 0}: count must be >= 1.`);
394
+ }
395
+ rawEnd = rawStart + rhs - 1;
396
+ } else if (sep === "-") {
397
+ if (rhs === undefined || rhs < rawStart) {
398
+ throw new ToolError(`Invalid range ${rawStart}-${rhs ?? 0}: end must be >= start.`);
399
+ }
400
+ rawEnd = rhs;
414
401
  }
415
402
  return { kind: "lines", startLine: rawStart, endLine: rawEnd };
416
403
  }
417
- return { kind: "chunk", selector: normalizedSelector };
404
+ // Unrecognized selectors fall through; sqlite/archive/url readers consume `sel` themselves.
405
+ return { kind: "none" };
418
406
  }
419
407
 
420
408
  /** Convert a line-range selector to the offset/limit pair used by internal pagination. */
@@ -481,18 +469,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
481
469
  Math.min(session.settings.get("read.defaultLimit") ?? DEFAULT_MAX_LINES, DEFAULT_MAX_LINES),
482
470
  );
483
471
  this.#inspectImageEnabled = session.settings.get("inspect_image.enabled");
484
- this.description =
485
- resolveEditMode(session) === "chunk"
486
- ? prompt.render(readChunkDescription, {
487
- anchorStyle: resolveAnchorStyle(session.settings),
488
- chunkAutoIndent: resolveChunkAutoIndent(),
489
- })
490
- : prompt.render(readDescription, {
491
- DEFAULT_LIMIT: String(this.#defaultLimit),
492
- DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
493
- IS_HASHLINE_MODE: displayMode.hashLines,
494
- IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
495
- });
472
+ this.description = prompt.render(readDescription, {
473
+ DEFAULT_LIMIT: String(this.#defaultLimit),
474
+ DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
475
+ IS_HASHLINE_MODE: displayMode.hashLines,
476
+ IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
477
+ });
496
478
  }
497
479
 
498
480
  async #resolveArchiveReadPath(readPath: string, signal?: AbortSignal): Promise<ResolvedArchiveReadPath | null> {
@@ -621,7 +603,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
621
603
  const suggestion =
622
604
  allLines.length === 0
623
605
  ? `The ${options.entityLabel} is empty.`
624
- : `Use sel=L1 to read from the start, or sel=L${allLines.length} to read the last line.`;
606
+ : `Use sel=1 to read from the start, or sel=${allLines.length} to read the last line.`;
625
607
  return resultBuilder
626
608
  .text(
627
609
  `Line ${startLineDisplay} is beyond end of ${options.entityLabel} (${allLines.length} lines total). ${suggestion}`,
@@ -683,7 +665,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
683
665
  const nextOffset = startLine + userLimitedLines + 1;
684
666
 
685
667
  outputText = formatText(selectedContent, startLineDisplay);
686
- outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use sel=L${nextOffset} to continue]`;
668
+ outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use sel=${nextOffset} to continue]`;
687
669
  } else {
688
670
  outputText = formatText(truncation.content, startLineDisplay);
689
671
  }
@@ -934,7 +916,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
934
916
  readPath = expandPath(readPath);
935
917
  }
936
918
  const displayMode = resolveFileDisplayMode(this.session);
937
- const chunkMode = resolveEditMode(this.session) === "chunk";
938
919
 
939
920
  // Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://)
940
921
  const internalRouter = this.session.internalRouter;
@@ -968,13 +949,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
968
949
  return executeReadUrl(this.session, { path: parsedUrlTarget.path, timeout, raw: parsedUrlTarget.raw }, signal);
969
950
  }
970
951
 
971
- const parsedReadPath = chunkMode ? parseChunkReadPath(readPath) : { filePath: readPath };
972
- const localReadPath = parsedReadPath.filePath;
973
- const pathSelectorParsed = chunkMode ? parseSel(parsedReadPath.selector) : { kind: "none" as const };
974
- const pathChunkSelector = pathSelectorParsed.kind === "chunk" ? pathSelectorParsed.selector : undefined;
975
- const selectorInput = sel ?? parsedReadPath.selector;
976
- const rawSelectorInput = sel ?? parsedReadPath.selector;
977
- const parsed = parseSel(selectorInput);
952
+ const localReadPath = readPath;
953
+ const parsed = parseSel(sel);
978
954
 
979
955
  const archivePath = await this.#resolveArchiveReadPath(localReadPath, signal);
980
956
  if (archivePath) {
@@ -1034,54 +1010,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1034
1010
  const imageMetadata = await readImageMetadata(absolutePath);
1035
1011
  const mimeType = imageMetadata?.mimeType;
1036
1012
  const ext = path.extname(absolutePath).toLowerCase();
1037
- const hasEditTool = this.session.hasEditTool ?? true;
1038
- const language = getLanguageFromPath(absolutePath);
1039
- const skipChunksForExplore = !hasEditTool && !this.session.settings.get("read.explorechunks");
1040
- const skipChunksForProse = isProseLanguage(language) && !this.session.settings.get("read.prosechunks");
1041
- const shouldConvertWithMarkit =
1042
- CONVERTIBLE_EXTENSIONS.has(ext) || (ext === ".ipynb" && (parsed.kind === "raw" || !chunkMode));
1043
-
1044
- if (chunkMode && parsed.kind !== "raw" && !skipChunksForExplore && !skipChunksForProse) {
1045
- const absoluteLineRange =
1046
- pathChunkSelector && parsed.kind === "lines"
1047
- ? { startLine: parsed.startLine, endLine: parsed.endLine }
1048
- : undefined;
1049
- // sel= wins over path:chunk when both are provided (explicit param > embedded path).
1050
- const effectiveSelector = sel ? selectorInput : (pathChunkSelector ?? selectorInput);
1051
- const rawEffectiveSelector = sel ? selectorInput : (rawSelectorInput ?? effectiveSelector);
1052
- const chunkReadPath =
1053
- parsed.kind === "chunk" || (pathChunkSelector && !sel)
1054
- ? rawEffectiveSelector
1055
- ? `${localReadPath}:${rawEffectiveSelector}`
1056
- : localReadPath
1057
- : parsed.kind === "lines"
1058
- ? parsed.endLine !== undefined
1059
- ? `${localReadPath}:L${parsed.startLine}-L${parsed.endLine}`
1060
- : `${localReadPath}:L${parsed.startLine}`
1061
- : localReadPath;
1062
- const chunkResult = await formatChunkedRead({
1063
- filePath: absolutePath,
1064
- readPath: chunkReadPath,
1065
- cwd: this.session.cwd,
1066
- language,
1067
- omitChecksum: !hasEditTool,
1068
- anchorStyle: resolveAnchorStyle(this.session.settings),
1069
- absoluteLineRange,
1070
- });
1071
- let text = chunkResult.text;
1072
- if (suffixResolution) {
1073
- text = prependSuffixResolutionNotice(text, suffixResolution);
1074
- }
1075
- return toolResult<ReadToolDetails>({
1076
- resolvedPath: absolutePath,
1077
- suffixResolution,
1078
- chunk: chunkResult.chunk,
1079
- })
1080
- .text(text)
1081
- .sourcePath(absolutePath)
1082
- .done();
1083
- }
1084
-
1013
+ const _hasEditTool = this.session.hasEditTool ?? true;
1014
+ const _language = getLanguageFromPath(absolutePath);
1015
+ const shouldConvertWithMarkit = CONVERTIBLE_EXTENSIONS.has(ext) || (ext === ".ipynb" && parsed.kind === "raw");
1085
1016
  // Read the file based on type
1086
1017
  let content: Array<TextContent | ImageContent>;
1087
1018
  let details: ReadToolDetails = {};
@@ -1164,31 +1095,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1164
1095
  content = [{ type: "text", text: `[Cannot read ${ext} file: conversion failed]` }];
1165
1096
  }
1166
1097
  } else {
1167
- // Chunk mode: dispatch to chunk tree unless raw or line range requested
1168
- if (chunkMode && parsed.kind !== "raw" && parsed.kind !== "lines") {
1169
- const chunkSel = parsed.kind === "chunk" ? parsed.selector : undefined;
1170
- const chunkResult = await formatChunkedRead({
1171
- filePath: absolutePath,
1172
- readPath: chunkSel ? `${localReadPath}:${chunkSel}` : localReadPath,
1173
- cwd: this.session.cwd,
1174
- language: getLanguageFromPath(absolutePath),
1175
- omitChecksum: !(this.session.hasEditTool ?? true),
1176
- anchorStyle: resolveAnchorStyle(this.session.settings),
1177
- });
1178
- let text = chunkResult.text;
1179
- if (suffixResolution) {
1180
- text = prependSuffixResolutionNotice(text, suffixResolution);
1181
- }
1182
- return toolResult<ReadToolDetails>({
1183
- resolvedPath: absolutePath,
1184
- suffixResolution,
1185
- chunk: chunkResult.chunk,
1186
- })
1187
- .text(text)
1188
- .sourcePath(absolutePath)
1189
- .done();
1190
- }
1191
-
1192
1098
  // Raw text or line-range mode
1193
1099
  const { offset, limit } = selToOffsetLimit(parsed);
1194
1100
  const startLine = offset ? Math.max(0, offset - 1) : 0;
@@ -1222,7 +1128,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1222
1128
  const suggestion =
1223
1129
  totalFileLines === 0
1224
1130
  ? "The file is empty."
1225
- : `Use sel=L1 to read from the start, or sel=L${totalFileLines} to read the last line.`;
1131
+ : `Use sel=1 to read from the start, or sel=${totalFileLines} to read the last line.`;
1226
1132
  return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
1227
1133
  .text(`Line ${startLineDisplay} is beyond end of file (${totalFileLines} lines total). ${suggestion}`)
1228
1134
  .done();
@@ -1294,7 +1200,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1294
1200
  const nextOffset = startLine + userLimitedLines + 1;
1295
1201
 
1296
1202
  outputText = formatText(truncation.content, startLineDisplay);
1297
- outputText += `\n\n[${remaining} more lines in file. Use sel=L${nextOffset} to continue]`;
1203
+ outputText += `\n\n[${remaining} more lines in file. Use sel=${nextOffset} to continue]`;
1298
1204
  details = {};
1299
1205
  sourcePath = absolutePath;
1300
1206
  } else {
@@ -20,6 +20,7 @@ import { findToolRenderer } from "./find";
20
20
  import { githubToolRenderer } from "./gh-renderer";
21
21
  import { grepToolRenderer } from "./grep";
22
22
  import { inspectImageToolRenderer } from "./inspect-image-renderer";
23
+ import { jobToolRenderer } from "./job";
23
24
  import { notebookToolRenderer } from "./notebook";
24
25
  import { pythonToolRenderer } from "./python";
25
26
  import { readToolRenderer } from "./read";
@@ -58,6 +59,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
58
59
  notebook: notebookToolRenderer as ToolRenderer,
59
60
  inspect_image: inspectImageToolRenderer as ToolRenderer,
60
61
  read: readToolRenderer as ToolRenderer,
62
+ job: jobToolRenderer as ToolRenderer,
61
63
  resolve: resolveToolRenderer as ToolRenderer,
62
64
  search_tool_bm25: searchToolBm25Renderer as ToolRenderer,
63
65
  ssh: sshToolRenderer as ToolRenderer,
@@ -23,6 +23,13 @@ export interface TodoItem {
23
23
  id: string;
24
24
  content: string;
25
25
  status: TodoStatus;
26
+ /**
27
+ * Append-only list of freeform notes attached by `op: "note"`.
28
+ * Each element is one note and may itself be multi-line.
29
+ * Rendered as text only when the task is in_progress; otherwise shown as a
30
+ * dim marker indicating the task has notes.
31
+ */
32
+ notes?: string[];
26
33
  }
27
34
 
28
35
  export interface TodoPhase {
@@ -40,7 +47,7 @@ export interface TodoWriteToolDetails {
40
47
  // Schema
41
48
  // =============================================================================
42
49
 
43
- const TodoOp = StringEnum(["replace", "start", "done", "rm", "drop", "append"] as const, {
50
+ const TodoOp = StringEnum(["replace", "start", "done", "rm", "drop", "append", "note"] as const, {
44
51
  description: "operation to apply",
45
52
  });
46
53
 
@@ -54,7 +61,7 @@ const InputTask = Type.Object({
54
61
  });
55
62
 
56
63
  const InputPhase = Type.Object({
57
- name: Type.String({ description: "phase name", examples: ["Investigation", "Implementation"] }),
64
+ name: Type.String({ description: "phase name", examples: ["I. Foundation", "II. Auth", "III. Verification"] }),
58
65
  tasks: Type.Optional(Type.Array(InputTask)),
59
66
  });
60
67
 
@@ -71,6 +78,7 @@ const TodoOpEntry = Type.Object({
71
78
  Type.String({ description: "phase id for done/rm/drop/append", examples: ["Implementation", "phase-1"] }),
72
79
  ),
73
80
  items: Type.Optional(Type.Array(AppendItem, { minItems: 1, description: "items to append for op=append" })),
81
+ text: Type.Optional(Type.String({ description: "note text for op=note (appended with newline)" })),
74
82
  });
75
83
 
76
84
  const todoWriteSchema = Type.Object(
@@ -160,8 +168,14 @@ function fileFromPhases(phases: TodoPhase[]): TodoFile {
160
168
  return { phases, nextTaskId, nextPhaseId };
161
169
  }
162
170
 
171
+ function cloneTask(task: TodoItem): TodoItem {
172
+ const out: TodoItem = { id: task.id, content: task.content, status: task.status };
173
+ if (task.notes && task.notes.length > 0) out.notes = [...task.notes];
174
+ return out;
175
+ }
176
+
163
177
  function clonePhases(phases: TodoPhase[]): TodoPhase[] {
164
- return phases.map(phase => ({ ...phase, tasks: phase.tasks.map(task => ({ ...task })) }));
178
+ return phases.map(phase => ({ ...phase, tasks: phase.tasks.map(cloneTask) }));
165
179
  }
166
180
 
167
181
  function normalizeInProgressTask(phases: TodoPhase[]): void {
@@ -181,11 +195,19 @@ function normalizeInProgressTask(phases: TodoPhase[]): void {
181
195
  if (firstPendingTask) firstPendingTask.status = "in_progress";
182
196
  }
183
197
 
198
+ export const USER_TODO_EDIT_CUSTOM_TYPE = "user_todo_edit";
199
+
184
200
  export function getLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPhase[] {
185
201
  for (let i = entries.length - 1; i >= 0; i--) {
186
202
  const entry = entries[i];
203
+ if (entry.type === "custom" && entry.customType === USER_TODO_EDIT_CUSTOM_TYPE) {
204
+ const data = entry.data as { phases?: unknown } | undefined;
205
+ if (data && Array.isArray(data.phases)) {
206
+ return clonePhases(data.phases as TodoPhase[]);
207
+ }
208
+ continue;
209
+ }
187
210
  if (entry.type !== "message") continue;
188
-
189
211
  const message = entry.message as { role?: string; toolName?: string; details?: unknown; isError?: boolean };
190
212
  if (message.role !== "toolResult" || message.toolName !== "todo_write" || message.isError) continue;
191
213
 
@@ -328,6 +350,17 @@ function applyEntry(file: TodoFile, entry: TodoOpEntryValue, errors: string[]):
328
350
  removeTasks(file, entry, errors);
329
351
  return file;
330
352
  }
353
+ case "note": {
354
+ const task = resolveTaskOrError(file.phases, entry.task, errors);
355
+ if (!task) return file;
356
+ const text = (entry.text ?? "").replace(/\s+$/u, "");
357
+ if (!text) {
358
+ errors.push("Missing text for note operation");
359
+ return file;
360
+ }
361
+ task.notes = task.notes ? [...task.notes, text] : [text];
362
+ return file;
363
+ }
331
364
  case "append": {
332
365
  appendItems(file, entry, errors);
333
366
  return file;
@@ -343,6 +376,142 @@ function applyParams(file: TodoFile, params: TodoWriteParams): { file: TodoFile;
343
376
  normalizeInProgressTask(file.phases);
344
377
  return { file, errors };
345
378
  }
379
+ /** Apply an array of `todo_write`-style ops to existing phases. Used by /todo slash command. */
380
+ export function applyOpsToPhases(
381
+ currentPhases: TodoPhase[],
382
+ ops: TodoWriteParams["ops"],
383
+ ): { phases: TodoPhase[]; errors: string[] } {
384
+ const startFile = fileFromPhases(currentPhases);
385
+ const { file, errors } = applyParams(startFile, { ops });
386
+ return { phases: file.phases, errors };
387
+ }
388
+
389
+ // =============================================================================
390
+ // Markdown round-trip
391
+ // =============================================================================
392
+
393
+ const STATUS_TO_MARKER: Record<TodoStatus, string> = {
394
+ pending: " ",
395
+ in_progress: "/",
396
+ completed: "x",
397
+ abandoned: "-",
398
+ };
399
+
400
+ /** Render todo phases as a Markdown checklist suitable for editing/copying. */
401
+ export function phasesToMarkdown(phases: TodoPhase[]): string {
402
+ if (phases.length === 0) return "# I. Todos\n";
403
+ const out: string[] = [];
404
+ for (let i = 0; i < phases.length; i++) {
405
+ if (i > 0) out.push("");
406
+ out.push(`# ${phases[i].name}`);
407
+ for (const task of phases[i].tasks) {
408
+ out.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}`);
409
+ if (task.notes && task.notes.length > 0) {
410
+ for (let j = 0; j < task.notes.length; j++) {
411
+ if (j > 0) out.push(" >");
412
+ for (const noteLine of task.notes[j].split("\n")) {
413
+ out.push(noteLine === "" ? " >" : ` > ${noteLine}`);
414
+ }
415
+ }
416
+ }
417
+ }
418
+ }
419
+ return `${out.join("\n")}\n`;
420
+ }
421
+
422
+ const MARKER_TO_STATUS: Record<string, TodoStatus> = {
423
+ " ": "pending",
424
+ "": "pending",
425
+ x: "completed",
426
+ X: "completed",
427
+ "/": "in_progress",
428
+ ">": "in_progress",
429
+ "-": "abandoned",
430
+ "~": "abandoned",
431
+ };
432
+
433
+ /**
434
+ * Parse a Markdown checklist back into todo phases. Task and phase ids are
435
+ * regenerated; the agent observes the new ids in the system reminder.
436
+ */
437
+ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: string[] } {
438
+ const errors: string[] = [];
439
+ const phases: TodoPhase[] = [];
440
+ let currentPhase: TodoPhase | undefined;
441
+ let currentTask: TodoItem | undefined;
442
+ let noteBuf: string[] = [];
443
+ let nextPhaseId = 1;
444
+ let nextTaskId = 1;
445
+
446
+ const flushNote = () => {
447
+ if (!currentTask || noteBuf.length === 0) {
448
+ noteBuf = [];
449
+ return;
450
+ }
451
+ while (noteBuf.length > 0 && noteBuf[noteBuf.length - 1] === "") noteBuf.pop();
452
+ if (noteBuf.length === 0) return;
453
+ const joined = noteBuf.join("\n");
454
+ currentTask.notes = currentTask.notes ? [...currentTask.notes, joined] : [joined];
455
+ noteBuf = [];
456
+ };
457
+
458
+ const lines = md.split(/\r?\n/);
459
+ for (let lineNum = 0; lineNum < lines.length; lineNum++) {
460
+ const raw = lines[lineNum];
461
+
462
+ // Blockquote line attached to the current task: ` > text` or ` >`
463
+ const noteMatch = /^\s*>\s?(.*)$/.exec(raw);
464
+ if (noteMatch && currentTask) {
465
+ const noteLine = noteMatch[1];
466
+ if (noteLine === "") {
467
+ // Blank `>` separates two distinct notes
468
+ flushNote();
469
+ } else {
470
+ noteBuf.push(noteLine);
471
+ }
472
+ continue;
473
+ }
474
+
475
+ const trimmed = raw.trim();
476
+ if (!trimmed) continue;
477
+
478
+ const headingMatch = /^#{1,6}\s+(.+?)\s*$/.exec(trimmed);
479
+ if (headingMatch) {
480
+ flushNote();
481
+ currentTask = undefined;
482
+ currentPhase = { id: `phase-${nextPhaseId++}`, name: headingMatch[1].trim(), tasks: [] };
483
+ phases.push(currentPhase);
484
+ continue;
485
+ }
486
+
487
+ const taskMatch = /^[-*+]\s*\[(.?)\]\s+(.+?)\s*$/.exec(trimmed);
488
+ if (taskMatch) {
489
+ flushNote();
490
+ if (!currentPhase) {
491
+ currentPhase = { id: `phase-${nextPhaseId++}`, name: "I. Todos", tasks: [] };
492
+ phases.push(currentPhase);
493
+ }
494
+ const marker = taskMatch[1];
495
+ const status = MARKER_TO_STATUS[marker];
496
+ if (!status) {
497
+ errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-])`);
498
+ currentTask = undefined;
499
+ continue;
500
+ }
501
+ currentTask = { id: `task-${nextTaskId++}`, content: taskMatch[2].trim(), status };
502
+ currentPhase.tasks.push(currentTask);
503
+ continue;
504
+ }
505
+
506
+ flushNote();
507
+ currentTask = undefined;
508
+ errors.push(`Line ${lineNum + 1}: unrecognized syntax "${trimmed}"`);
509
+ }
510
+ flushNote();
511
+
512
+ normalizeInProgressTask(phases);
513
+ return { phases, errors };
514
+ }
346
515
 
347
516
  function formatSummary(phases: TodoPhase[], errors: string[]): string {
348
517
  const tasks = phases.flatMap(phase => phase.tasks);
@@ -387,7 +556,17 @@ function formatSummary(phases: TodoPhase[], errors: string[]): string {
387
556
  : task.status === "abandoned"
388
557
  ? "✗"
389
558
  : "○";
390
- lines.push(` ${sym} ${task.id} ${task.content}`);
559
+ const noteCount = task.notes?.length ?? 0;
560
+ const noteMarker = noteCount > 0 ? ` (+${noteCount} note${noteCount === 1 ? "" : "s"})` : "";
561
+ lines.push(` ${sym} ${task.id} ${task.content}${noteMarker}`);
562
+ if (task.status === "in_progress" && task.notes && task.notes.length > 0) {
563
+ for (let j = 0; j < task.notes.length; j++) {
564
+ if (j > 0) lines.push(" ---");
565
+ for (const noteLine of task.notes[j].split("\n")) {
566
+ lines.push(` ${noteLine}`);
567
+ }
568
+ }
569
+ }
391
570
  }
392
571
  }
393
572
  return lines.join("\n");
@@ -442,18 +621,65 @@ type TodoWriteRenderArgs = {
442
621
  }>;
443
622
  };
444
623
 
624
+ const SUP_DIGITS: Record<string, string> = {
625
+ "0": "\u2070",
626
+ "1": "\u00b9",
627
+ "2": "\u00b2",
628
+ "3": "\u00b3",
629
+ "4": "\u2074",
630
+ "5": "\u2075",
631
+ "6": "\u2076",
632
+ "7": "\u2077",
633
+ "8": "\u2078",
634
+ "9": "\u2079",
635
+ };
636
+
637
+ function toSuperscript(n: number): string {
638
+ return n
639
+ .toString()
640
+ .split("")
641
+ .map(d => SUP_DIGITS[d] ?? d)
642
+ .join("");
643
+ }
644
+
645
+ function noteMarker(count: number, uiTheme: Theme): string {
646
+ if (count <= 0) return "";
647
+ return uiTheme.fg("dim", chalk.italic(` \u207a${toSuperscript(count)}`));
648
+ }
649
+
445
650
  function formatTodoLine(item: TodoItem, uiTheme: Theme, prefix: string): string {
446
651
  const checkbox = uiTheme.checkbox;
652
+ const marker = noteMarker(item.notes?.length ?? 0, uiTheme);
447
653
  switch (item.status) {
448
654
  case "completed":
449
- return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`);
655
+ return uiTheme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(item.content)}`) + marker;
450
656
  case "in_progress":
451
- return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`);
657
+ return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`) + marker;
452
658
  case "abandoned":
453
- return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(item.content)}`);
659
+ return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(item.content)}`) + marker;
454
660
  default:
455
- return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`);
661
+ return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`) + marker;
662
+ }
663
+ }
664
+
665
+ function renderNoteAttachments(phases: TodoPhase[], uiTheme: Theme): string[] {
666
+ const lines: string[] = [];
667
+ for (const phase of phases) {
668
+ for (const task of phase.tasks) {
669
+ if (task.status !== "in_progress" || !task.notes || task.notes.length === 0) continue;
670
+ const bar = uiTheme.fg("dim", uiTheme.tree.vertical);
671
+ const title = uiTheme.fg("dim", chalk.italic(`\u00a7 notes \u2014 ${task.content}`));
672
+ lines.push("");
673
+ lines.push(` ${title}`);
674
+ for (let j = 0; j < task.notes.length; j++) {
675
+ if (j > 0) lines.push(` ${bar}`);
676
+ for (const noteLine of task.notes[j].split("\n")) {
677
+ lines.push(` ${bar} ${uiTheme.fg("dim", noteLine)}`);
678
+ }
679
+ }
680
+ }
456
681
  }
682
+ return lines;
457
683
  }
458
684
 
459
685
  export const todoWriteToolRenderer = {
@@ -488,9 +714,11 @@ export const todoWriteToolRenderer = {
488
714
 
489
715
  const { expanded } = options;
490
716
  const lines: string[] = [header];
491
- for (const phase of phases) {
717
+ for (let p = 0; p < phases.length; p++) {
718
+ const phase = phases[p];
719
+ if (p > 0) lines.push("");
492
720
  if (phases.length > 1) {
493
- lines.push(uiTheme.fg("accent", ` ${uiTheme.tree.hook} ${phase.name}`));
721
+ lines.push(uiTheme.fg("accent", chalk.bold(` ${phase.name}`)));
494
722
  }
495
723
  const treeLines = renderTreeList(
496
724
  {
@@ -502,8 +730,11 @@ export const todoWriteToolRenderer = {
502
730
  },
503
731
  uiTheme,
504
732
  );
505
- lines.push(...treeLines);
733
+ for (const line of treeLines) {
734
+ lines.push(` ${line}`);
735
+ }
506
736
  }
737
+ lines.push(...renderNoteAttachments(phases, uiTheme));
507
738
  return new Text(lines.join("\n"), 0, 0);
508
739
  },
509
740
  mergeCallAndResult: true,
@@ -59,7 +59,7 @@ export interface WriteToolDetails {
59
59
  /**
60
60
  * Strip hashline display prefixes from write content.
61
61
  *
62
- * Only active when hashline edit mode is enabled — the model sees `LINE+ID:`
62
+ * Only active when hashline edit mode is enabled — the model sees `LINE+ID|`
63
63
  * prefixes in read output and sometimes copies them into write content.
64
64
  */
65
65
  function stripWriteContent(session: ToolSession, content: string): { text: string; stripped: boolean } {
@@ -418,7 +418,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
418
418
  context?: AgentToolContext,
419
419
  ): Promise<AgentToolResult<WriteToolDetails>> {
420
420
  return untilAborted(signal, async () => {
421
- // Strip hashline display prefixes (LINE+ID:) if the model copied them from read output
421
+ // Strip hashline display prefixes (LINE+ID|) if the model copied them from read output
422
422
  const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
423
423
  const resolvedArchivePath = await this.#resolveArchiveWritePath(path);
424
424
  if (resolvedArchivePath) {