@oh-my-pi/pi-coding-agent 13.10.1 → 13.11.1

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 (76) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/package.json +7 -7
  3. package/src/commit/agentic/agent.ts +3 -1
  4. package/src/commit/agentic/index.ts +7 -1
  5. package/src/commit/analysis/conventional.ts +5 -1
  6. package/src/commit/analysis/summary.ts +5 -1
  7. package/src/commit/changelog/generate.ts +5 -1
  8. package/src/commit/changelog/index.ts +4 -0
  9. package/src/commit/map-reduce/index.ts +5 -0
  10. package/src/commit/map-reduce/map-phase.ts +17 -2
  11. package/src/commit/map-reduce/reduce-phase.ts +5 -1
  12. package/src/commit/model-selection.ts +38 -26
  13. package/src/commit/pipeline.ts +22 -11
  14. package/src/config/model-registry.ts +98 -17
  15. package/src/config/settings-schema.ts +31 -12
  16. package/src/config.ts +10 -3
  17. package/src/discovery/helpers.ts +10 -3
  18. package/src/exa/index.ts +1 -11
  19. package/src/exa/search.ts +1 -122
  20. package/src/internal-urls/docs-index.generated.ts +2 -2
  21. package/src/lsp/config.ts +1 -0
  22. package/src/lsp/defaults.json +3 -3
  23. package/src/lsp/index.ts +4 -4
  24. package/src/lsp/utils.ts +81 -0
  25. package/src/modes/components/settings-defs.ts +5 -0
  26. package/src/modes/components/todo-reminder.ts +8 -1
  27. package/src/modes/controllers/command-controller.ts +77 -3
  28. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  29. package/src/modes/controllers/input-controller.ts +2 -3
  30. package/src/modes/controllers/selector-controller.ts +18 -17
  31. package/src/modes/interactive-mode.ts +11 -7
  32. package/src/modes/theme/theme.ts +30 -27
  33. package/src/modes/types.ts +2 -1
  34. package/src/patch/hashline.ts +123 -22
  35. package/src/prompts/system/eager-todo.md +13 -0
  36. package/src/prompts/tools/ast-edit.md +1 -1
  37. package/src/prompts/tools/ast-grep.md +1 -1
  38. package/src/prompts/tools/code-search.md +45 -0
  39. package/src/prompts/tools/find.md +1 -0
  40. package/src/prompts/tools/grep.md +1 -0
  41. package/src/prompts/tools/hashline.md +26 -111
  42. package/src/prompts/tools/read.md +2 -2
  43. package/src/prompts/tools/todo-write.md +11 -1
  44. package/src/sdk.ts +20 -16
  45. package/src/session/agent-session.ts +85 -7
  46. package/src/session/streaming-output.ts +17 -54
  47. package/src/slash-commands/builtin-registry.ts +10 -2
  48. package/src/task/executor.ts +10 -19
  49. package/src/task/index.ts +8 -4
  50. package/src/task/render.ts +5 -10
  51. package/src/task/template.ts +4 -1
  52. package/src/task/types.ts +2 -0
  53. package/src/tools/ast-edit.ts +26 -7
  54. package/src/tools/ast-grep.ts +26 -9
  55. package/src/tools/exit-plan-mode.ts +6 -0
  56. package/src/tools/fetch.ts +37 -6
  57. package/src/tools/find.ts +13 -64
  58. package/src/tools/grep.ts +27 -10
  59. package/src/tools/output-meta.ts +10 -7
  60. package/src/tools/path-utils.ts +348 -0
  61. package/src/tools/read.ts +13 -26
  62. package/src/tools/todo-write.ts +27 -4
  63. package/src/utils/commit-message-generator.ts +27 -22
  64. package/src/utils/image-input.ts +1 -1
  65. package/src/utils/image-resize.ts +4 -4
  66. package/src/utils/title-generator.ts +36 -23
  67. package/src/utils/tool-choice.ts +28 -0
  68. package/src/web/parallel.ts +346 -0
  69. package/src/web/scrapers/youtube.ts +29 -0
  70. package/src/web/search/code-search.ts +385 -0
  71. package/src/web/search/index.ts +25 -280
  72. package/src/web/search/provider.ts +4 -1
  73. package/src/web/search/providers/parallel.ts +63 -0
  74. package/src/web/search/types.ts +29 -0
  75. package/src/exa/company.ts +0 -26
  76. package/src/exa/linkedin.ts +0 -26
@@ -1,19 +1,6 @@
1
1
  Applies precise, surgical file edits by referencing `LINE#ID` tags from `read` output. Each tag uniquely identifies a line, so edits remain stable even when lines shift.
2
2
 
3
- <workflow>
4
- Follow these steps in order for every edit:
5
- 1. You **SHOULD** issue a `read` call before editing to get fresh `LINE#ID` tags. Editing without current tags causes mismatches because other edits or external changes may have shifted line numbers since your last read.
6
- 2. You **MUST** submit one `edit` call per file with all operations. Multiple calls to the same file require re-reading between each one (tags shift after each edit), so batching avoids wasted round-trips. Think your changes through before submitting.
7
- 3. You **MUST** pick the operation that matches the owning structure, not merely the smallest textual diff. Use the smallest operation only when it still cleanly owns the changed syntax. If a tiny edit would patch around a block tail, delimiter, or neighboring structural line, expand it to the semantically correct `replace` span instead.
8
- </workflow>
9
-
10
- <checklist>
11
- Before choosing the payload, answer these questions in order:
12
- 1. **Am I replacing existing lines or inserting new ones?** If any existing line changes, use `replace` for the full changed span.
13
- 2. **What declaration or block owns this anchor line?** Prefer declaration/header lines over blank lines or delimiters.
14
- 3. **Am I inserting self-contained new content, or changing an existing block?** Use `append`/`prepend` only for self-contained additions. If surrounding code, indentation, or closers also change, use `replace`.
15
- 4. **Am I editing near a block tail or closing delimiter?** If yes, use shape (a) or (b) from the block-boundaries rule: either stay entirely inside the body, or own the full block including header and closer. Never set `end` at a closer without re-emitting it, and never re-emit a closer without including it in `end`.
16
- </checklist>
3
+ Read the file first to get fresh tags. Submit one `edit` call per file with all operations batched — tags shift after each edit, so multiple calls require re-reading between them.
17
4
 
18
5
  <operations>
19
6
  **`path`** — the path to the file to edit.
@@ -21,25 +8,20 @@ Before choosing the payload, answer these questions in order:
21
8
  **`delete`** — if true, delete the file.
22
9
 
23
10
  **`edits[n].pos`** — the anchor line. Meaning depends on `op`:
24
- - if `replace`: line to rewrite
11
+ - if `replace`: first line to rewrite
25
12
  - if `prepend`: line to insert new lines **before**; omit for beginning of file
26
13
  - if `append`: line to insert new lines **after**; omit for end of file
27
14
  **`edits[n].end`** — range replace only. The last line of the range (inclusive). Omit for single-line replace.
28
15
  **`edits[n].lines`** — the replacement content:
29
- - `["line1", "line2"]` insert `line1` and `line2`
16
+ - for `replace`: the exact lines that will replace `[pos, end??pos]` inclusively (or the single `pos` line when `end` is omitted)
17
+ - for `prepend`/`append`: the new lines to insert
30
18
  - `[""]` — blank line
31
- - `null` or `[]` — delete if replace, no-op if append or prepend
32
-
33
- Ops are applied bottom-up. Tags **MUST** be referenced from the most recent `read` output.
19
+ - `null` or `[]` — delete if replace
20
+ - If `lines` contains content that already exists after `end`, those lines **will be duplicated** in the output.
21
+ - Keep `lines` to exactly what belongs inside the consumed range.
22
+ - Ops are applied bottom-up. Tags **MUST** be referenced from the most recent `read` output.
34
23
  </operations>
35
24
 
36
- <rules>
37
- 1. **Use `prepend`/`append` only for self-contained additions whose surrounding structure stays unchanged.** If you are adding a sibling declaration, prefer `prepend` on the next sibling declaration instead of `append` on the previous block closer.
38
- 2. **If the change touches existing code near a block tail, use range `replace` over the owned span.** Do not patch just the final line(s) before a closing delimiter when the surrounding structure, indentation, or control flow is also changing.
39
- 3. **Match surrounding indentation for new lines.** When inserting via `prepend`/`append`, look at the anchor line and its neighbors in the `read` output. New `lines` entries **MUST** carry the same leading whitespace. If the context uses tabs at depth 1 (`\t`), your inserted declarations need `\t` and bodies need `\t\t`. Inserting at indent level 0 inside an indented block is always wrong.
40
- 4. **Block boundaries travel together — never split them.** See the block-boundaries rule in `<critical>`. The two valid shapes are: replace only the body (leave header and closer untouched), or replace the whole block (header through closer, re-emit all in `lines`). Do not set `end` to a closer and omit it from `lines` (deletes it). Do not emit a closer in `lines` without including it in `end` (duplicates it).
41
- </rules>
42
-
43
25
  <examples>
44
26
  All examples below reference the same file, `util.ts`:
45
27
  ```ts
@@ -103,55 +85,10 @@ Range — remove the legacy block (lines 10–11):
103
85
  ```
104
86
  </example>
105
87
 
106
- <example name="clear text but keep the line break">
107
- Blank out a line without removing it:
108
- ```
109
- {
110
- path: "util.ts",
111
- edits: [{
112
- op: "replace",
113
- pos: {{hlineref 3 "const tag = \"DO NOT SHIP\";"}},
114
- lines: [""]
115
- }]
116
- }
117
- ```
118
- </example>
119
-
120
88
  <example name="rewrite a block body — shape (a)">
121
89
  Replace the catch body with smarter error handling. Shape (a): `pos` is the first body line, `end` is the last body line. The catch header (line 14) and its closer (line 17) are outside the range and stay untouched.
122
- ```
123
- {
124
- path: "util.ts",
125
- edits: [{
126
- op: "replace",
127
- pos: {{hlineref 15 "\t\tconsole.error(err);"}},
128
- end: {{hlineref 16 "\t\treturn null;"}},
129
- lines: [
130
- "\t\tif (isEnoent(err)) return null;",
131
- "\t\tthrow err;"
132
- ]
133
- }]
134
- }
135
- ```
136
- </example>
137
90
 
138
- <example name="span the full body, not a single line">
139
- When changing body content, replace the entire body span — not just one line inside it. Patching one line leaves the rest of the body stale.
140
-
141
- Bad — appends after one body line, leaving the original `return null` in place:
142
- ```
143
- {
144
- path: "util.ts",
145
- edits: [{
146
- op: "append",
147
- pos: {{hlineref 15 "\t\tconsole.error(err);"}},
148
- lines: [
149
- "\t\treturn fallback;"
150
- ]
151
- }]
152
- }
153
- ```
154
- Good — shape (a): replace the full body span. Header and closer stay untouched:
91
+ When changing body content, replace the **entire** body span — not just one line inside it. Patching one line leaves the rest of the body stale.
155
92
  ```
156
93
  {
157
94
  path: "util.ts",
@@ -161,7 +98,7 @@ Good — shape (a): replace the full body span. Header and closer stay untouched
161
98
  end: {{hlineref 16 "\t\treturn null;"}},
162
99
  lines: [
163
100
  "\t\tif (isEnoent(err)) return null;",
164
- "\t\treturn fallback;"
101
+ "\t\tthrow err;"
165
102
  ]
166
103
  }]
167
104
  }
@@ -205,46 +142,18 @@ Good — `end` includes the function's own `}` on line 18, so the old closer is
205
142
  ```
206
143
  </example>
207
144
 
208
- <example name="insert between sibling declarations">
209
- Add a `gamma()` function between `alpha()` and `beta()`:
210
- ```
211
- {
212
- path: "util.ts",
213
- edits: [{
214
- op: "prepend",
215
- pos: {{hlineref 9 "function beta() {"}},
216
- lines: [
217
- "function gamma() {",
218
- "\tvalidate();",
219
- "}",
220
- ""
221
- ]
222
- }]
223
- }
224
- ```
225
- Use a trailing `""` to preserve the blank line between sibling declarations.
226
- </example>
145
+ <example name="avoid shared boundary lines">
146
+ Do not anchor `replace` on a mixed boundary line such as `} catch (err) {`, `} else {`, `}),`, or `},{`. Those lines belong to two adjacent structures at once.
227
147
 
228
- <example name="avoid closer anchors">
229
- When inserting a sibling declaration, do not anchor on the previous block's lone closing brace. Anchor on the next declaration instead.
148
+ Bad if you need to change code on both sides of that line, replacing just the boundary span will usually leave one side's syntax behind.
230
149
 
231
- Badappending after line 7 (`}`) happens to land in the gap today, but the anchor is still the previous function's closer rather than a stable declaration boundary:
232
- ```
233
- {
234
- path: "util.ts",
235
- edits: [{
236
- op: "append",
237
- pos: {{hlineref 7 "}"}},
238
- lines: [
239
- "",
240
- "function gamma() {",
241
- "\tvalidate();",
242
- "}"
243
- ]
244
- }]
245
- }
246
- ```
247
- Good — prepend before the next declaration so the new sibling is anchored on a declaration header, not a block tail:
150
+ Goodchoose one of two safe shapes instead:
151
+ - move inward and replace only body-owned lines
152
+ - expand outward and replace one whole owned block, consuming its real closer/separator too
153
+ </example>
154
+
155
+ <example name="insert between sibling declarations">
156
+ Add a `gamma()` function between `alpha()` and `beta()`. Use `prepend` on the next declaration — not `append` on the previous block's closing brace — so the anchor is a stable declaration boundary.
248
157
  ```
249
158
  {
250
159
  path: "util.ts",
@@ -260,6 +169,7 @@ Good — prepend before the next declaration so the new sibling is anchored on a
260
169
  }]
261
170
  }
262
171
  ```
172
+ Use a trailing `""` to preserve the blank line between sibling declarations.
263
173
  </example>
264
174
  </examples>
265
175
 
@@ -271,5 +181,10 @@ Good — prepend before the next declaration so the new sibling is anchored on a
271
181
  - When changing existing code near a block tail or closing delimiter, default to `replace` over the owned span instead of inserting around the boundary.
272
182
  - When adding a sibling declaration, default to `prepend` on the next sibling declaration instead of `append` on the previous block's closing brace.
273
183
  - **Block boundaries travel together.** For a block `{ header / body / closer }`, there are exactly two valid replace shapes: (a) replace only the body — `pos`=first body line, `end`=last body line, leave the header and closer untouched; or (b) replace the whole block — `pos`=header, `end`=closer, re-emit all three in `lines`. Never split them: do not set `end` to the closer while omitting it from `lines` (deletes it), and do not emit the closer in `lines` without including it in `end` (duplicates it). This applies to every block terminator: `}`, `continue`, `break`, `return`, `throw`.
184
+ - **Never target shared boundary lines.** Do not use `replace` spans that start, end, or pivot on a line that closes one construct and opens/separates another, such as `},{`, `}),`, `} else {`, or `} catch (err) {`. Those lines are not owned by a single block. Move the range inward to body-only lines, or widen it to consume one whole owned construct including its true trailing delimiter.
185
+ - **`lines` must not extend past `end`.** `lines` replaces exactly `pos..end`. Content after `end` survives. If you include lines in `lines` that exist after `end`, they will appear twice. Either extend `end` to cover all lines you are re-emitting, or remove the extra lines from `lines`.
274
186
  - `lines` entries **MUST** be literal file content with indentation copied exactly from the `read` output. If the file uses tabs, use a real tab character.
187
+ - After any successful `edit` call on a file, the next change to that same file **MUST** start with a fresh `read`. Do not chain a second `edit` call off stale mental state, even if the intended range is nearby.
188
+ - If you need a second change in the same local region, default to one wider `replace` over the whole owned block instead of a sequence of micro-edits on adjacent lines. Repeated small patches in a moving region are unstable.
189
+ - If a local region is already malformed or a prior patch partially landed, stop nibbling at it. Re-read the file and replace the full owned block from a stable boundary; for a small file, prefer rewriting the file over stacking more tiny repairs.
275
190
  </critical>
@@ -4,10 +4,10 @@ Reads files from local filesystem or internal URLs.
4
4
  - Reads up to {{DEFAULT_LIMIT}} lines default
5
5
  - Use `offset` and `limit` for large files; max {{DEFAULT_MAX_LINES}} lines per call
6
6
  {{#if IS_HASHLINE_MODE}}
7
- - Text output is CID prefixed: `LINE#ID:content`
7
+ - Filesystem output is CID prefixed: `LINE#ID:content`
8
8
  {{else}}
9
9
  {{#if IS_LINE_NUMBER_MODE}}
10
- - Text output is line-number-prefixed
10
+ - Filesystem output is line-number-prefixed
11
11
  {{/if}}
12
12
  {{/if}}
13
13
  - Supports images (PNG, JPG) and PDFs
@@ -46,6 +46,11 @@ Create a todo list when:
46
46
  - Multiple ops can be batched in one call (e.g., complete current + start next)
47
47
  </protocol>
48
48
 
49
+ ## Task Anatomy
50
+ - `content`: Short label (5-10 words). What is being done, not how.
51
+ - `details`: File paths, implementation steps, edge cases. Shown only when task is active.
52
+ - `notes`: Runtime observations added during execution.
53
+
49
54
  <avoid>
50
55
  - Single-step tasks — act directly
51
56
  - Conversational or informational requests
@@ -65,11 +70,16 @@ ops: [
65
70
  ]
66
71
  </example>
67
72
 
73
+ <example name="add_task">
74
+ Add a follow-up task with implementation specifics in `details`:
75
+ ops: [{op: "add_task", phase: "Implementation", after: "task-2", task: {content: "Handle retries", details: "Update retry.ts to cap exponential backoff and preserve AbortSignal handling", status: "pending"}}]
76
+ </example>
77
+
68
78
  <example name="initial-setup">
69
79
  Replace is for setup only. Prefer add_phase / add_task for incremental additions.
70
80
  ops: [{op: "replace", phases: [
71
81
  {name: "Investigation", tasks: [{content: "Read source"}, {content: "Map callsites"}]},
72
- {name: "Implementation", tasks: [{content: "Apply fix"}, {content: "Run tests"}]}
82
+ {name: "Implementation", tasks: [{content: "Apply fix", details: "Update parser.ts to handle edge case in line 42"}, {content: "Run tests"}]}
73
83
  ]}]
74
84
  </example>
75
85
 
package/src/sdk.ts CHANGED
@@ -89,10 +89,13 @@ import {
89
89
  GrepTool,
90
90
  getSearchTools,
91
91
  HIDDEN_TOOLS,
92
+ isCodeSearchProviderId,
93
+ isSearchProviderPreference,
92
94
  loadSshTool,
93
95
  PythonTool,
94
96
  ReadTool,
95
97
  ResolveTool,
98
+ setPreferredCodeSearchProvider,
96
99
  setPreferredImageProvider,
97
100
  setPreferredSearchProvider,
98
101
  type Tool,
@@ -628,8 +631,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
628
631
  options.skills === undefined ? discoverSkills(cwd, agentDir, skillsSettings) : undefined;
629
632
 
630
633
  // Initialize provider preferences from settings
631
- setPreferredSearchProvider(settings.get("providers.webSearch") ?? "auto");
632
- setPreferredImageProvider(settings.get("providers.image") ?? "auto");
634
+ const webSearchProvider = settings.get("providers.webSearch");
635
+ if (typeof webSearchProvider === "string" && isSearchProviderPreference(webSearchProvider)) {
636
+ setPreferredSearchProvider(webSearchProvider);
637
+ }
638
+
639
+ const codeSearchProvider = settings.get("providers.codeSearch");
640
+ if (typeof codeSearchProvider === "string" && isCodeSearchProviderId(codeSearchProvider)) {
641
+ setPreferredCodeSearchProvider(codeSearchProvider);
642
+ }
643
+
644
+ const imageProvider = settings.get("providers.image");
645
+ if (imageProvider === "auto" || imageProvider === "gemini" || imageProvider === "openrouter") {
646
+ setPreferredImageProvider(imageProvider);
647
+ }
633
648
 
634
649
  const sessionManager = options.sessionManager ?? logger.time("sessionManager", SessionManager.create, cwd);
635
650
  const sessionId = sessionManager.getSessionId();
@@ -962,19 +977,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
962
977
  customTools.push(...(geminiImageTools as unknown as CustomTool[]));
963
978
  }
964
979
 
965
- // Add specialized Exa web search tools if EXA_API_KEY is available
966
- const exaSettings = settings.getGroup("exa");
967
- if (exaSettings.enabled && exaSettings.enableSearch) {
968
- const exaSearchTools = await logger.timeAsync("getSearchTools", getSearchTools, {
969
- enableLinkedin: exaSettings.enableLinkedin as boolean,
970
- enableCompany: exaSettings.enableCompany as boolean,
971
- });
972
- // Filter out the base web_search (already in built-in tools), add specialized Exa tools
973
- const specializedTools = exaSearchTools.filter(t => t.name !== "web_search");
974
- if (specializedTools.length > 0) {
975
- customTools.push(...specializedTools);
976
- }
977
- }
980
+ // Add web search tools
981
+ customTools.push(...getSearchTools());
978
982
 
979
983
  // Discover and load custom tools from .omp/tools/, .claude/tools/, etc.
980
984
  const builtInToolNames = builtinTools.map(t => t.name);
@@ -1409,7 +1413,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1409
1413
  if (pendingActionStore.hasPending) {
1410
1414
  return { type: "function", name: "resolve" };
1411
1415
  }
1412
- return undefined;
1416
+ return session?.consumeNextToolChoiceOverride();
1413
1417
  },
1414
1418
  });
1415
1419
  cursorEventEmitter = event => agent.emitExternalEvent(event);
@@ -89,6 +89,7 @@ import { getCurrentThemeName, theme } from "../modes/theme/theme";
89
89
  import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../patch";
90
90
  import type { PlanModeState } from "../plan-mode/state";
91
91
  import autoHandoffThresholdFocusPrompt from "../prompts/system/auto-handoff-threshold-focus.md" with { type: "text" };
92
+ import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
92
93
  import handoffDocumentPrompt from "../prompts/system/handoff-document.md" with { type: "text" };
93
94
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
94
95
  import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
@@ -106,6 +107,7 @@ import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from ".
106
107
  import { parseCommandArgs } from "../utils/command-args";
107
108
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
108
109
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
110
+ import { buildNamedToolChoice } from "../utils/tool-choice";
109
111
  import {
110
112
  type CompactionResult,
111
113
  calculateContextTokens,
@@ -350,6 +352,7 @@ export class AgentSession {
350
352
  // Todo completion reminder state
351
353
  #todoReminderCount = 0;
352
354
  #todoPhases: TodoPhase[] = [];
355
+ #nextToolChoiceOverride: ToolChoice | undefined = undefined;
353
356
 
354
357
  // Bash execution state
355
358
  #bashAbortController: AbortController | undefined = undefined;
@@ -457,6 +460,12 @@ export class AgentSession {
457
460
  return this.#modelRegistry;
458
461
  }
459
462
 
463
+ consumeNextToolChoiceOverride(): ToolChoice | undefined {
464
+ const toolChoice = this.#nextToolChoiceOverride;
465
+ this.#nextToolChoiceOverride = undefined;
466
+ return toolChoice;
467
+ }
468
+
460
469
  /** Provider-scoped mutable state store for transport/session caches. */
461
470
  get providerSessionState(): Map<string, ProviderSessionState> {
462
471
  return this.#providerSessionState;
@@ -791,7 +800,11 @@ export class AgentSession {
791
800
  const compactionTask = this.#checkCompaction(msg);
792
801
  this.#trackPostPromptTask(compactionTask);
793
802
  await compactionTask;
794
- // Check for incomplete todos (unless there was an error or abort)
803
+ // Check for incomplete todos only after a final assistant stop, not intermediate tool-use turns.
804
+ const hasToolCalls = msg.content.some(content => content.type === "toolCall");
805
+ if (hasToolCalls) {
806
+ return;
807
+ }
795
808
  if (msg.stopReason !== "error" && msg.stopReason !== "aborted") {
796
809
  if (this.#enforceRewindBeforeYield()) {
797
810
  return;
@@ -1349,6 +1362,7 @@ export class AgentSession {
1349
1362
  if (!this.#extensionRunner) return;
1350
1363
  if (event.type === "agent_start") {
1351
1364
  this.#turnIndex = 0;
1365
+ this.#nextToolChoiceOverride = undefined;
1352
1366
  await this.#extensionRunner.emit({ type: "agent_start" });
1353
1367
  } else if (event.type === "agent_end") {
1354
1368
  await this.#extensionRunner.emit({ type: "agent_end", messages: event.messages });
@@ -1945,6 +1959,8 @@ export class AgentSession {
1945
1959
  return;
1946
1960
  }
1947
1961
 
1962
+ const eagerTodoPrelude = !options?.synthetic ? this.#createEagerTodoPrelude() : undefined;
1963
+
1948
1964
  const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
1949
1965
  if (options?.images) {
1950
1966
  userContent.push(...options.images);
@@ -1954,7 +1970,20 @@ export class AgentSession {
1954
1970
  ? { role: "developer" as const, content: userContent, attribution: "agent" as const, timestamp: Date.now() }
1955
1971
  : { role: "user" as const, content: userContent, attribution: "user" as const, timestamp: Date.now() };
1956
1972
 
1957
- await this.#promptWithMessage(message, expandedText, options);
1973
+ if (eagerTodoPrelude) {
1974
+ this.#nextToolChoiceOverride = eagerTodoPrelude.toolChoice;
1975
+ }
1976
+
1977
+ try {
1978
+ await this.#promptWithMessage(message, expandedText, {
1979
+ ...options,
1980
+ prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
1981
+ });
1982
+ } finally {
1983
+ if (eagerTodoPrelude) {
1984
+ this.#nextToolChoiceOverride = undefined;
1985
+ }
1986
+ }
1958
1987
  if (!options?.synthetic) {
1959
1988
  await this.#enforcePlanModeToolDecision();
1960
1989
  }
@@ -1997,6 +2026,7 @@ export class AgentSession {
1997
2026
  message: AgentMessage,
1998
2027
  expandedText: string,
1999
2028
  options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck"> & {
2029
+ prependMessages?: AgentMessage[];
2000
2030
  skipPostPromptRecoveryWait?: boolean;
2001
2031
  },
2002
2032
  ): Promise<void> {
@@ -2034,7 +2064,7 @@ export class AgentSession {
2034
2064
  await this.#checkCompaction(lastAssistant, false);
2035
2065
  }
2036
2066
 
2037
- // Build messages array (custom messages if any, then user message)
2067
+ // Build messages array (session context, eager todo prelude, then active prompt message)
2038
2068
  const messages: AgentMessage[] = [];
2039
2069
  const planReferenceMessage = await this.#buildPlanReferenceMessage?.();
2040
2070
  if (planReferenceMessage) {
@@ -2044,6 +2074,9 @@ export class AgentSession {
2044
2074
  if (planModeMessage) {
2045
2075
  messages.push(planModeMessage);
2046
2076
  }
2077
+ if (options?.prependMessages) {
2078
+ messages.push(...options.prependMessages);
2079
+ }
2047
2080
 
2048
2081
  messages.push(message);
2049
2082
 
@@ -3481,6 +3514,51 @@ export class AgentSession {
3481
3514
  this.agent.setTools(previousTools);
3482
3515
  }
3483
3516
  }
3517
+
3518
+ #createEagerTodoPrelude(): { message: AgentMessage; toolChoice: ToolChoice } | undefined {
3519
+ const eagerTodosEnabled = this.settings.get("todo.eager");
3520
+ const todosEnabled = this.settings.get("todo.enabled");
3521
+ if (!eagerTodosEnabled || !todosEnabled) {
3522
+ return undefined;
3523
+ }
3524
+
3525
+ if (this.#planModeState?.enabled) {
3526
+ return undefined;
3527
+ }
3528
+ if (this.getTodoPhases().length > 0) {
3529
+ return undefined;
3530
+ }
3531
+
3532
+ if (!this.#toolRegistry.has("todo_write")) {
3533
+ logger.warn("Eager todo enforcement skipped because todo_write is unavailable", {
3534
+ activeToolNames: this.agent.state.tools.map(tool => tool.name),
3535
+ });
3536
+ return undefined;
3537
+ }
3538
+
3539
+ const todoWriteToolChoice = buildNamedToolChoice("todo_write", this.model);
3540
+ if (!todoWriteToolChoice) {
3541
+ logger.warn("Eager todo enforcement skipped because the current model does not support forcing todo_write", {
3542
+ modelApi: this.model?.api,
3543
+ modelId: this.model?.id,
3544
+ });
3545
+ return undefined;
3546
+ }
3547
+
3548
+ const eagerTodoReminder = renderPromptTemplate(eagerTodoPrompt);
3549
+
3550
+ return {
3551
+ message: {
3552
+ role: "custom",
3553
+ customType: "eager-todo-prelude",
3554
+ content: eagerTodoReminder,
3555
+ display: false,
3556
+ attribution: "agent",
3557
+ timestamp: Date.now(),
3558
+ },
3559
+ toolChoice: todoWriteToolChoice,
3560
+ };
3561
+ }
3484
3562
  /**
3485
3563
  * Check if agent stopped with incomplete todos and prompt to continue.
3486
3564
  */
@@ -5191,8 +5269,8 @@ export class AgentSession {
5191
5269
  }
5192
5270
 
5193
5271
  for (const msg of this.messages) {
5194
- if (msg.role === "user") {
5195
- lines.push("## User\n");
5272
+ if (msg.role === "user" || msg.role === "developer") {
5273
+ lines.push(msg.role === "developer" ? "## Developer\n" : "## User\n");
5196
5274
  if (typeof msg.content === "string") {
5197
5275
  lines.push(msg.content);
5198
5276
  } else {
@@ -5316,8 +5394,8 @@ export class AgentSession {
5316
5394
  lines.push("");
5317
5395
 
5318
5396
  for (const msg of this.messages) {
5319
- if (msg.role === "user") {
5320
- lines.push("## User");
5397
+ if (msg.role === "user" || msg.role === "developer") {
5398
+ lines.push(msg.role === "developer" ? "## Developer" : "## User");
5321
5399
  lines.push("");
5322
5400
  if (typeof msg.content === "string") {
5323
5401
  lines.push(msg.content);
@@ -36,16 +36,14 @@ export interface OutputSinkOptions {
36
36
 
37
37
  export interface TruncationResult {
38
38
  content: string;
39
- truncated: boolean;
40
- truncatedBy: "lines" | "bytes" | null;
39
+ truncated?: boolean;
40
+ truncatedBy?: "lines" | "bytes";
41
41
  totalLines: number;
42
42
  totalBytes: number;
43
- outputLines: number;
44
- outputBytes: number;
45
- lastLinePartial: boolean;
46
- firstLineExceedsLimit: boolean;
47
- maxLines: number;
48
- maxBytes: number;
43
+ outputLines?: number;
44
+ outputBytes?: number;
45
+ lastLinePartial?: boolean;
46
+ firstLineExceedsLimit?: boolean;
49
47
  }
50
48
 
51
49
  export interface TruncationOptions {
@@ -206,26 +204,10 @@ export function truncateLine(
206
204
  // =============================================================================
207
205
 
208
206
  /** Shared helper to build a no-truncation result. */
209
- function noTruncResult(
210
- content: string,
211
- totalLines: number,
212
- totalBytes: number,
213
- maxLines: number,
214
- maxBytes: number,
215
- ): TruncationResult {
216
- return {
217
- content,
218
- truncated: false,
219
- truncatedBy: null,
220
- totalLines,
221
- totalBytes,
222
- outputLines: totalLines,
223
- outputBytes: totalBytes,
224
- lastLinePartial: false,
225
- firstLineExceedsLimit: false,
226
- maxLines,
227
- maxBytes,
228
- };
207
+ export function noTruncResult(content: string, totalLines?: number, totalBytes?: number): TruncationResult {
208
+ if (totalLines == null) totalLines = countNewlines(content) + 1;
209
+ if (totalBytes == null) totalBytes = Buffer.byteLength(content, "utf-8");
210
+ return { content, totalLines, totalBytes };
229
211
  }
230
212
 
231
213
  /**
@@ -244,7 +226,7 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
244
226
  const totalLines = countNewlines(content) + 1;
245
227
 
246
228
  if (totalLines <= maxLines && totalBytes <= maxBytes) {
247
- return noTruncResult(content, totalLines, totalBytes, maxLines, maxBytes);
229
+ return noTruncResult(content, totalLines, totalBytes);
248
230
  }
249
231
 
250
232
  let includedLines = 0;
@@ -283,8 +265,6 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
283
265
  outputBytes: 0,
284
266
  lastLinePartial: false,
285
267
  firstLineExceedsLimit: true,
286
- maxLines,
287
- maxBytes,
288
268
  };
289
269
  }
290
270
  break;
@@ -307,8 +287,6 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
307
287
  outputBytes: 0,
308
288
  lastLinePartial: false,
309
289
  firstLineExceedsLimit: true,
310
- maxLines,
311
- maxBytes,
312
290
  };
313
291
  }
314
292
  break;
@@ -335,8 +313,6 @@ export function truncateHead(content: string, options: TruncationOptions = {}):
335
313
  outputBytes: bytesUsed,
336
314
  lastLinePartial: false,
337
315
  firstLineExceedsLimit: false,
338
- maxLines,
339
- maxBytes,
340
316
  };
341
317
  }
342
318
 
@@ -354,7 +330,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
354
330
  const totalLines = countNewlines(content) + 1;
355
331
 
356
332
  if (totalLines <= maxLines && totalBytes <= maxBytes) {
357
- return noTruncResult(content, totalLines, totalBytes, maxLines, maxBytes);
333
+ return noTruncResult(content, totalLines, totalBytes);
358
334
  }
359
335
 
360
336
  let includedLines = 0;
@@ -396,8 +372,6 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
396
372
  outputBytes: tail.bytes,
397
373
  lastLinePartial: true,
398
374
  firstLineExceedsLimit: false,
399
- maxLines,
400
- maxBytes,
401
375
  };
402
376
  }
403
377
  break;
@@ -420,8 +394,6 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
420
394
  outputBytes: tail.bytes,
421
395
  lastLinePartial: true,
422
396
  firstLineExceedsLimit: false,
423
- maxLines,
424
- maxBytes,
425
397
  };
426
398
  }
427
399
  break;
@@ -447,8 +419,6 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
447
419
  outputBytes: bytesUsed,
448
420
  lastLinePartial: false,
449
421
  firstLineExceedsLimit: false,
450
- maxLines,
451
- maxBytes,
452
422
  };
453
423
  }
454
424
 
@@ -693,7 +663,7 @@ export function formatTailTruncationNotice(
693
663
  if (!truncation.truncated) return "";
694
664
 
695
665
  const { fullOutputPath, originalContent, suffix = "" } = options;
696
- const startLine = truncation.totalLines - truncation.outputLines + 1;
666
+ const startLine = truncation.totalLines - (truncation.outputLines ?? truncation.totalLines) + 1;
697
667
  const endLine = truncation.totalLines;
698
668
  const fullOutputPart = fullOutputPath ? `. Full output: ${fullOutputPath}` : "";
699
669
 
@@ -705,11 +675,9 @@ export function formatTailTruncationNotice(
705
675
  const lastLine = lastNl === -1 ? originalContent : originalContent.substring(lastNl + 1);
706
676
  lastLineSizePart = ` (line is ${formatBytes(Buffer.byteLength(lastLine, "utf-8"))})`;
707
677
  }
708
- notice = `[Showing last ${formatBytes(truncation.outputBytes)} of line ${endLine}${lastLineSizePart}${fullOutputPart}${suffix}]`;
709
- } else if (truncation.truncatedBy === "lines") {
710
- notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}${fullOutputPart}${suffix}]`;
678
+ notice = `[Showing last ${formatBytes(truncation.outputBytes ?? truncation.totalBytes)} of line ${endLine}${lastLineSizePart}${fullOutputPart}${suffix}]`;
711
679
  } else {
712
- notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatBytes(truncation.maxBytes)} limit)${fullOutputPart}${suffix}]`;
680
+ notice = `[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}${fullOutputPart}${suffix}]`;
713
681
  }
714
682
 
715
683
  return `\n\n${notice}`;
@@ -727,13 +695,8 @@ export function formatHeadTruncationNotice(
727
695
 
728
696
  const startLineDisplay = options.startLine ?? 1;
729
697
  const totalFileLines = options.totalFileLines ?? truncation.totalLines;
730
- const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
698
+ const endLineDisplay = startLineDisplay + (truncation.outputLines ?? truncation.totalLines) - 1;
731
699
  const nextOffset = endLineDisplay + 1;
732
-
733
- const notice =
734
- truncation.truncatedBy === "lines"
735
- ? `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`
736
- : `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatBytes(truncation.maxBytes)} limit). Use offset=${nextOffset} to continue]`;
737
-
700
+ const notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
738
701
  return `\n\n${notice}`;
739
702
  }