@oh-my-pi/pi-coding-agent 14.5.1 → 14.5.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.
package/src/lsp/utils.ts CHANGED
@@ -4,6 +4,7 @@ import * as fs from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { isEnoent } from "@oh-my-pi/pi-utils";
6
6
  import { type Theme, theme } from "../modes/theme/theme";
7
+ import { formatGroupedFiles } from "../tools/grouped-file-output";
7
8
  import { resolveToCwd } from "../tools/path-utils";
8
9
  import type {
9
10
  CodeAction,
@@ -154,7 +155,7 @@ const DIAG_PATH_RE = /^(.+?):(\d+:\d+\s+.*)$/;
154
155
  /**
155
156
  * Reformat pre-formatted diagnostic messages into grep-style directory/file groups.
156
157
  * Input: ["path:line:col [sev] msg", ...]
157
- * Output: "# dir\n## └─ file.ts\n line:col [sev] msg"
158
+ * Output: "# dir/\n## file.ts\n line:col [sev] msg"
158
159
  *
159
160
  * Messages that don't match the expected format are appended ungrouped at the end.
160
161
  */
@@ -183,41 +184,10 @@ export function formatGroupedDiagnosticMessages(messages: string[]): string {
183
184
  return ungrouped.join("\n");
184
185
  }
185
186
 
186
- const filesByDirectory = new Map<string, string[]>();
187
- for (const filePath of fileOrder) {
188
- const directory = path.dirname(filePath).replace(/\\/g, "/");
189
- if (!filesByDirectory.has(directory)) {
190
- filesByDirectory.set(directory, []);
191
- }
192
- filesByDirectory.get(directory)?.push(filePath);
193
- }
194
-
195
- const lines: string[] = [];
196
- for (const [directory, directoryFiles] of filesByDirectory) {
197
- if (directory === ".") {
198
- for (const filePath of directoryFiles) {
199
- if (lines.length > 0) {
200
- lines.push("");
201
- }
202
- lines.push(`# ${path.basename(filePath)}`);
203
- for (const diagnostic of diagnosticsByFile.get(filePath) ?? []) {
204
- lines.push(` ${diagnostic}`);
205
- }
206
- }
207
- continue;
208
- }
209
-
210
- if (lines.length > 0) {
211
- lines.push("");
212
- }
213
- lines.push(`# ${directory}`);
214
- for (const filePath of directoryFiles) {
215
- lines.push(`## └─ ${path.basename(filePath)}`);
216
- for (const diagnostic of diagnosticsByFile.get(filePath) ?? []) {
217
- lines.push(` ${diagnostic}`);
218
- }
219
- }
220
- }
187
+ const grouped = formatGroupedFiles(fileOrder, filePath => ({
188
+ modelLines: (diagnosticsByFile.get(filePath) ?? []).map(diagnostic => ` ${diagnostic}`),
189
+ }));
190
+ const lines: string[] = grouped.model;
221
191
 
222
192
  if (ungrouped.length > 0) {
223
193
  lines.push("");
@@ -388,10 +388,12 @@ export class StatusLineComponent implements Component {
388
388
 
389
389
  // Collect visible segment contents
390
390
  const leftParts: string[] = [];
391
+ const leftSegIds: StatusLineSegmentId[] = [];
391
392
  for (const segId of effectiveSettings.leftSegments) {
392
393
  const rendered = renderSegment(segId, ctx);
393
394
  if (rendered.visible && rendered.content) {
394
395
  leftParts.push(rendered.content);
396
+ leftSegIds.push(segId);
395
397
  }
396
398
  }
397
399
 
@@ -434,8 +436,42 @@ export class StatusLineComponent implements Component {
434
436
  right.pop();
435
437
  rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
436
438
  }
439
+ // Shrink path before dropping left segments — path is the only elastic segment
440
+ const pathIdx = leftSegIds.indexOf("path");
441
+ if (pathIdx >= 0 && totalWidth() > topFillWidth) {
442
+ const overflow = totalWidth() - topFillWidth;
443
+ const currentPathVW = visibleWidth(left[pathIdx]);
444
+ const minPathVW = 8; // icon + ellipsis + a few chars
445
+ const shrinkable = currentPathVW - minPathVW;
446
+ if (shrinkable > 0) {
447
+ const shrinkBy = Math.min(shrinkable, overflow);
448
+ const currentMaxLen = ctx.options.path?.maxLength ?? 40;
449
+ let newMaxLen = Math.max(4, Math.min(currentMaxLen, currentPathVW) - shrinkBy);
450
+ const pathCtx = (maxLen: number): SegmentContext => ({
451
+ ...ctx,
452
+ options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } },
453
+ });
454
+ let reRendered = renderSegment("path", pathCtx(newMaxLen));
455
+ if (reRendered.visible && reRendered.content) {
456
+ // maxLength governs path text, not icon prefix; iterate to compensate
457
+ for (let i = 0; i < 8; i++) {
458
+ const saved = currentPathVW - visibleWidth(reRendered.content);
459
+ if (saved >= shrinkBy) break;
460
+ const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved));
461
+ if (nextMaxLen >= newMaxLen) break; // no progress or hit floor
462
+ newMaxLen = nextMaxLen;
463
+ const adjusted = renderSegment("path", pathCtx(newMaxLen));
464
+ if (!adjusted.visible || !adjusted.content) break;
465
+ reRendered = adjusted;
466
+ }
467
+ left[pathIdx] = reRendered.content;
468
+ leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
469
+ }
470
+ }
471
+ }
437
472
  while (totalWidth() > topFillWidth && left.length > 0) {
438
473
  left.pop();
474
+ leftSegIds.pop();
439
475
  leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
440
476
  }
441
477
  }
@@ -52,6 +52,7 @@ export class EventController {
52
52
  ttsr_triggered: e => this.#handleTtsrTriggered(e),
53
53
  todo_reminder: e => this.#handleTodoReminder(e),
54
54
  todo_auto_clear: e => this.#handleTodoAutoClear(e),
55
+ irc_message: e => this.#handleIrcMessage(e),
55
56
  } satisfies AgentSessionEventHandlers;
56
57
  }
57
58
 
@@ -203,6 +204,17 @@ export class EventController {
203
204
  }
204
205
  }
205
206
 
207
+ async #handleIrcMessage(event: Extract<AgentSessionEvent, { type: "irc_message" }>): Promise<void> {
208
+ const signature = `${event.message.role}:${event.message.customType}:${event.message.timestamp}`;
209
+ if (this.#renderedCustomMessages.has(signature)) {
210
+ return;
211
+ }
212
+ this.#renderedCustomMessages.add(signature);
213
+ this.#resetReadGroup();
214
+ this.ctx.addMessageToChat(event.message);
215
+ this.ctx.ui.requestRender();
216
+ }
217
+
206
218
  async #handleMessageUpdate(event: Extract<AgentSessionEvent, { type: "message_update" }>): Promise<void> {
207
219
  if (this.ctx.streamingComponent && event.message.role === "assistant") {
208
220
  this.ctx.streamingMessage = event.message;
@@ -269,8 +281,21 @@ export class EventController {
269
281
  for (const content of this.ctx.streamingMessage.content) {
270
282
  if (content.type !== "toolCall") continue;
271
283
  const args = content.arguments;
272
- if (!args || typeof args !== "object" || !(INTENT_FIELD in args)) continue;
273
- this.#updateWorkingMessageFromIntent(args[INTENT_FIELD] as string | undefined);
284
+ if (!args || typeof args !== "object") continue;
285
+ if (INTENT_FIELD in args) {
286
+ this.#updateWorkingMessageFromIntent(args[INTENT_FIELD] as string | undefined);
287
+ continue;
288
+ }
289
+ const tool = this.ctx.session.getToolByName(content.name);
290
+ if (typeof tool?.intent !== "function") continue;
291
+ try {
292
+ const derived = tool.intent(args as never)?.trim();
293
+ if (derived) {
294
+ this.#updateWorkingMessageFromIntent(derived);
295
+ }
296
+ } catch {
297
+ // intent function must never break the UI
298
+ }
274
299
  }
275
300
 
276
301
  this.ctx.ui.requestRender();
@@ -168,7 +168,7 @@ Tools:
168
168
 
169
169
  {{#if intentTracing}}
170
170
  <intent-field>
171
- Every tool has a `{{intentField}}` parameter. Fill it with a concise intent in present participle form, 2-6 words, no period.
171
+ Most tools have a `{{intentField}}` parameter. Fill it with a concise intent in present participle form, 2-6 words, no period.
172
172
  </intent-field>
173
173
  {{/if}}
174
174
 
@@ -1,98 +1,76 @@
1
- Applies precise file edits using full anchors from `read` output (for example `160sr`).
2
-
3
- Read the file first. Copy the full anchors exactly as shown by `read`.
4
-
5
- <operations>
6
- **Top level**: `{ path, edits: […] }` — `path` is shared by all entries. You may still override the file inside `loc` with forms like `other.ts:160sr`.
7
-
8
- Each entry has one shared locator plus one or more verbs:
9
- - `loc: "160sr"` single anchored line
10
- - `loc: "$"` whole file: `pre` prepends, `post` appends, `sed` substitutes across every line
11
- - `loc: "a.ts:160sr"` cross-file override inside the locator
12
-
13
- Verbs:
14
- - `splice: […]`: lines are spliced in at the anchor.
15
- - `pre: […]`: prepend before the anchor (or at BOF if `loc=$`)
16
- - `post: […]`: append after the anchor (or at EOF if `loc=$`)
17
- - `sed: "s/foo/bar/"` sed-style substitution applied to the anchor line. **Prefer this over `splice` for token-level changes**
18
- Flags: `g` (all occurrences), `i` (case-insensitive), `F` (literal).
19
- Delimiter is whatever character follows `s`.
20
- You **MUST** keep the pattern as short as possible.
21
-
22
- Combination rules:
23
- - On a single-anchor `loc`, you may combine `pre`, `splice`, and `post` in the same entry.
24
- - `splice: []` on a single-anchor `loc` deletes that line.
25
- - `splice:[""]` is **not** delete it replaces the line with a blank line.
26
- </operations>
1
+ Applies precise file edits using anchors (line+hash).
2
+
3
+ <ops>
4
+ Each call **MUST** have shape `{path:"a.ts",edits:[…]}`. `path` is the default file; you **MAY** override it per edit with `loc:"b.ts:160sr"`.
5
+ Each edit **MUST** have exactly one `loc` and **MUST** include one or more verbs.
6
+
7
+ # Locators
8
+ - `"A"` targets one anchored line. `"$"` targets the whole file: `pre` = BOF, `post` = EOF, `sed` = every line.
9
+ - Bracketed locators are **`splice` only** and select a balanced region around anchor `A`.
10
+ - `"(A)"` = block body. `"[A]"` = whole block/node.
11
+ - `"[A"` / `"(A"` = tail after/including anchor, closer excluded.
12
+ - `"A]"` / `"A)"` = head through/before anchor, opener excluded.
13
+ - Anchor bracketed forms on a body line of the intended block, not the opener line.
14
+ - Do not use bracketed locators on files that do not currently parse.
15
+
16
+ # Verbs
17
+ - `splice:[…]` replaces the anchored line, or the bracketed region. `[]` deletes; `[""]` makes a blank line.
18
+ - `pre:[…]` inserts before the anchor, or BOF with `loc:"$"`.
19
+ - `post:[…]` inserts after the anchor, or EOF with `loc:"$"`.
20
+ </ops>
21
+
22
+ <splice>
23
+ Replaces the anchored line, or the bracketed region.
24
+ - `[]` deletes. `[""]` leaves a blank line.
25
+ - For bracketed `splice`, write body at column 0, it will be re-indented.
26
+ - Do not use bracketed `splice` on broken files, or for single line edits.
27
+ </splice>
28
+
29
+ <sed>
30
+ Use for tiny inline edits: names, operators, literals.
31
+ - Keep `pat` as short as possible, it does not have to be unique.
32
+ - `g:false` by default; set to replace all instead of first.
33
+ </sed>
27
34
 
28
35
  <examples>
29
- All examples below reference the same file:
30
-
31
36
  ```ts title="a.ts"
32
- {{hline 1 "const tag = \"BAD\";"}}
37
+ {{hline 1 "const FALLBACK = \"guest\";"}}
33
38
  {{hline 2 ""}}
34
- {{hline 3 "function beta(x) {"}}
35
- {{hline 4 "\tif (x) {"}}
36
- {{hline 5 "\t\treturn parse(data) || fallback;"}}
37
- {{hline 6 "\t}"}}
38
- {{hline 7 "\treturn null;"}}
39
- {{hline 8 "}"}}
39
+ {{hline 3 "export function label(name) {"}}
40
+ {{hline 4 "\tconst clean = name || FALLBACK;"}}
41
+ {{hline 5 "\treturn clean.trim().toLowerCase();"}}
42
+ {{hline 6 "}"}}
40
43
  ```
41
44
 
42
- # Replace a line with `splice`
43
- `{path:"a.ts",edits:[{loc:{{href 1 "const tag = \"BAD\";"}},splice:["const tag = \"OK\";"]}]}`
44
-
45
- # Combine `pre` + `splice` + `post` in one entry
46
- `{path:"a.ts",edits:[{loc:{{href 4 "\tif (x) {"}},pre:["\tvalidate();"],splice:["\tif (!x) {"],post:["\t\tlog();"]}]}`
47
-
48
- # Delete a line with `splice: []`
49
- `{path:"a.ts",edits:[{loc:{{href 7 "\treturn null;"}},splice:[]}]}`
50
-
51
- # Preserve a blank line with `splice:[""]`
45
+ # Single-line replacement:
46
+ `{path:"a.ts",edits:[{loc:{{href 1 "const FALLBACK = \"guest\";"}},splice:["const FALLBACK = \"anonymous\";"]}]}`
47
+ # Small token edit: prefer `sed`:
48
+ `{path:"a.ts",edits:[{loc:{{href 5 "\treturn clean.trim().toLowerCase();"}},sed:{pat:"toLowerCase",rep:"toUpperCase"}}]}`
49
+ # Insert before / after an anchor:
50
+ `{path:"a.ts",edits:[{loc:{{href 5 "\treturn clean.trim().toLowerCase();"}},pre:["\tif (!clean) return FALLBACK;"],post:["\t// normalized label"]}]}`
51
+ # Delete a line vs make it blank:
52
+ `{path:"a.ts",edits:[{loc:{{href 2 ""}},splice:[]}]}`
52
53
  `{path:"a.ts",edits:[{loc:{{href 2 ""}},splice:[""]}]}`
53
-
54
- # Insert before / after a line
55
- `{path:"a.ts",edits:[{loc:{{href 3 "function beta(x) {"}},pre:["function gamma() {","\tvalidate();","}",""]}]}`
56
-
57
- # Substitute one token with `sed` (regex) — preferred for token-level edits
58
- Use the smallest pattern that uniquely identifies the change.
59
- `{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:"s/\\|\\|/??/"}]}`
60
-
61
- # Substitute every occurrence with `sed` (literal/fixed-string)
62
- Use the `F` flag to disable regex; the delimiter can be any non-alphanumeric char.
63
- `{path:"a.ts",edits:[{loc:{{href 5 "\t\treturn parse(data) || fallback;"}},sed:"s|data|input|gF"}]}`
64
-
65
- # Prepend / append at file edges
54
+ # File edges:
66
55
  `{path:"a.ts",edits:[{loc:"$",pre:["// Copyright (c) 2026",""]}]}`
67
- `{path:"a.ts",edits:[{loc:"$",post:["","export const VERSION = \"1.0.0\";"]}]}`
68
-
69
- # Cross-file override inside `loc`
70
- `{path:"a.ts",edits:[{loc:"b.ts:{{href 1 "const tag = \"BAD\";"}}",splice:["const tag = \"OK\";"]}]}`
71
-
72
- # WRONG: retyping unchanged neighbors inside `splice` duplicates them
73
- `{path:"a.ts",edits:[{loc:{{href 4 "\tif (x) {"}},splice:["\tif (x && ready) {","\t\treturn parse(data) ?? fallback;","\t\t//unreachable"]}]}`
74
- The 2nd array element matches existing line 5, which is **not** overwritten, it shifts, so return statement ends up duplicated.
75
-
76
- # RIGHT: split into separate edits
77
- - `{path:"a.ts",edits:[{loc:{{href 4 "\tif (x) {"}},sed:"s/x/x \\&\\& ready/"},{loc:{{href 5 "\t\treturn parse(data) ?? fallback;"}},post:["\t\t//unreachable"]}]}`
78
- OR
79
- - `{path:"a.ts",edits:[{loc:{{href 4 "\tif (x) {"}},splice:["\tif (x && ready) {"]},{loc:{{href 5 "\t\treturn parse(data) ?? fallback;"}},splice:["\t\treturn parse(data) ?? fallback;","\t\t//unreachable"]}]}`
56
+ `{path:"a.ts",edits:[{loc:"$",post:["","export { FALLBACK };"]}]}`
57
+ # Cross-file override:
58
+ `{path:"a.ts",edits:[{loc:{{href 1 "const FALLBACK = \"guest\";" "config.ts:" ""}},splice:["const FALLBACK = \"anonymous\";"]}]}`
59
+ # Body replacement: use bracketed `splice`, write body at column 0:
60
+ `{path:"a.ts",edits:[{loc:{{href 4 "\tconst clean = name || FALLBACK;" "(" ")"}},splice:["if (name == null) return FALLBACK;","const clean = String(name).trim();","return clean || FALLBACK;"]}]}`
61
+ # Whole function replacement: anchor on a body line:
62
+ `{path:"a.ts",edits:[{loc:{{href 5 "\treturn clean.trim().toLowerCase();" "[" "]"}},splice:["export function label(name) {","\treturn String(name ?? FALLBACK).trim().toLowerCase();","}"]}]}`
63
+ # WRONG: bare-anchor `splice` does not own neighboring lines:
64
+ `{path:"a.ts",edits:[{loc:{{href 4 "\tconst clean = name || FALLBACK;"}},splice:["\tconst clean = String(name ?? FALLBACK).trim();","\treturn clean.toLowerCase();"]}]}`
65
+ This replaces only line 4. Original line 5 still shifts down, so the function now has two returns.
66
+ # RIGHT: use a body edit for that rewrite:
67
+ `{path:"a.ts",edits:[{loc:{{href 4 "\tconst clean = name || FALLBACK;" "(" ")"}},splice:["const clean = String(name ?? FALLBACK).trim();","return clean.toLowerCase();"]}]}`
80
68
  </examples>
81
69
 
82
70
  <critical>
83
- - Make the minimum exact edit.
84
- - Copy the full anchors exactly as shown by `read/grep` (for example `160sr`, not just `sr`).
85
- - `loc` chooses the target. Verbs describe what to do there.
86
- - On a single-anchor `loc`, you may combine `pre`, `splice`, and `post`.
87
- - `loc:"$"` operates on the whole file: `pre` prepends, `post` appends, `sed` runs across every line.
88
- - `splice: []` deletes the anchored line. `splice:[""]` preserves a blank line.
89
- - Within a single request you may submit edits in any order — the runtime applies them bottom-up so they don't shift each other. After any request that mutates a file, anchors below the mutation are stale on disk; re-read before issuing more edits to that file.
90
- - `splice` operations target the current file content only. Do not try to reference old line text after the file has changed.
91
- - For **small** in-line edits (renaming a token, flipping an operator, tweaking a literal), prefer `sed` over `splice`. The `loc` anchor already pins the line — repeating the entire line in a `splice` array invites hallucinated content. Use the smallest `sed` pattern that uniquely identifies the change on that line; do not pad it with surrounding text just to feel safe. For multi-line restructuring (wrapping logic, adding new branches, inserting blocks), use `splice`/`pre`/`post` — do **not** stretch `sed` into a rewrite tool.
92
- - When you do use `splice`, re-read the anchored line first and copy it verbatim, changing only the required token(s). Anchor identity does not verify line content, so a hallucinated replacement will silently corrupt the file.
93
- - Anchors are pin points, not region markers. One anchor pins exactly one line. If your change touches N distinct source lines, that is N edits with N anchors — not one big `splice` array intended to cover the whole region. `splice` cannot "replace lines 4 through 7"; it can only splice content in at one anchor.
94
- - You **MUST NOT** include lines in `splice`/`pre`/`post` that already exist immediately adjacent to the anchor in the current file. `splice` does not overwrite the lines below — they shift down — so any neighbor you re-type in your array becomes a duplicate. If your intended replacement contains content that is already on neighboring source lines, split into multiple edits at each real change site instead of one fat `splice`.
95
- - Before issuing a multi-line `splice`, mentally diff each array element against the current file lines at and just below the anchor. Any element that matches a line within ~5 lines of the anchor will become a duplicate after the splice. If you find a match, drop that element and use a separate edit (or `pre`/`post`) at the real change point.
96
- - Text content must be literal file content with matching indentation. If the file uses tabs, use real tabs.
97
- - You **MUST NOT** use this tool to reformat or clean up unrelated code.
71
+ - You **MUST** copy full anchors exactly from a read op (e.g. `160sr`); you **MUST NOT** send only the 2-letter suffix.
72
+ - You **MUST** make the minimum exact edit; you **MUST NOT** reformat unrelated code.
73
+ - A bare anchor **MUST** target one line only; you **MUST** use bracketed `splice` for balanced block rewrites.
74
+ - You **MUST NOT** include unchanged adjacent lines in `splice`/`pre`/`post`; they shift and duplicate.
75
+ - For bracketed `splice`, replacement braces **MUST** be balanced for the selected region.
98
76
  </critical>
@@ -23,7 +23,7 @@ The `read` tool is multi-purpose and more capable than it looks — inspects fil
23
23
  # Filesystem
24
24
  - Reading a directory path returns a list of dirents.
25
25
  {{#if IS_HASHLINE_MODE}}
26
- - Reading a file returns lines prefixed with anchors (line # .. hash .. | .. line content): `41th|def alpha():`
26
+ - Reading a file returns lines prefixed with anchors (line+hash): `41th|def alpha():`
27
27
  {{else}}
28
28
  {{#if IS_LINE_NUMBER_MODE}}
29
29
  - Reading a file returns lines prefixed with line numbers: `41|def alpha():`
@@ -196,7 +196,8 @@ export type AgentSessionEvent =
196
196
  | { type: "retry_fallback_succeeded"; model: string; role: string }
197
197
  | { type: "ttsr_triggered"; rules: Rule[] }
198
198
  | { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number }
199
- | { type: "todo_auto_clear" };
199
+ | { type: "todo_auto_clear" }
200
+ | { type: "irc_message"; message: CustomMessage };
200
201
 
201
202
  /** Listener function for agent session events */
202
203
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
@@ -2970,6 +2971,30 @@ export class AgentSession {
2970
2971
  attribution: "user",
2971
2972
  timestamp: Date.now(),
2972
2973
  });
2974
+ // When fully idle AND the session is in a resumable assistant-ended state,
2975
+ // schedule an immediate continue so the queued follow-up is delivered
2976
+ // without waiting for the next user turn. We gate on isStreaming (model
2977
+ // actively producing), isRetrying (auto-retry backoff is sleeping between
2978
+ // attempts, #retryPromise set), and the last message being assistant —
2979
+ // agent.continue() only dequeues follow-ups from an assistant-ended state;
2980
+ // resuming from user/toolResult state runs an extra model call on the
2981
+ // stale prompt before draining the queue.
2982
+ if (this.#canAutoContinueForFollowUp()) {
2983
+ this.#scheduleAgentContinue({
2984
+ shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(),
2985
+ });
2986
+ }
2987
+ }
2988
+
2989
+ /**
2990
+ * Gate for idle-path follow-up auto-continue. See `#queueFollowUp` for rationale.
2991
+ */
2992
+ #canAutoContinueForFollowUp(): boolean {
2993
+ if (this.isStreaming) return false;
2994
+ if (this.isRetrying) return false;
2995
+ const messages = this.agent.state.messages;
2996
+ const last = messages[messages.length - 1];
2997
+ return last?.role === "assistant";
2973
2998
  }
2974
2999
 
2975
3000
  queueDeferredMessage(message: CustomMessage): void {
@@ -5973,6 +5998,7 @@ export class AgentSession {
5973
5998
  attribution: "agent",
5974
5999
  timestamp: incomingTimestamp,
5975
6000
  };
6001
+ void this.#emitSessionEvent({ type: "irc_message", message: incomingRecord });
5976
6002
 
5977
6003
  if (!awaitReply) {
5978
6004
  this.#queueBackgroundExchangeInjection([incomingRecord]);
@@ -5997,6 +6023,7 @@ export class AgentSession {
5997
6023
  attribution: "agent",
5998
6024
  timestamp: Date.now(),
5999
6025
  };
6026
+ void this.#emitSessionEvent({ type: "irc_message", message: replyRecord });
6000
6027
  this.#queueBackgroundExchangeInjection([incomingRecord, replyRecord]);
6001
6028
 
6002
6029
  return { replyText };
@@ -13,6 +13,7 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, t
13
13
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
14
14
  import type { ToolSession } from ".";
15
15
  import { createFileRecorder, formatResultPath } from "./file-recorder";
16
+ import { formatGroupedFiles } from "./grouped-file-output";
16
17
  import type { OutputMeta } from "./output-meta";
17
18
  import {
18
19
  hasGlobPathChars,
@@ -204,7 +205,9 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
204
205
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
205
206
  const outputLines: string[] = [];
206
207
  const displayLines: string[] = [];
207
- const renderChangesForFile = (relativePath: string) => {
208
+ const renderChangesForFile = (relativePath: string): { model: string[]; display: string[] } => {
209
+ const modelOut: string[] = [];
210
+ const displayOut: string[] = [];
208
211
  const fileChanges = changesByFile.get(relativePath) ?? [];
209
212
  const lineNumberWidth = fileChanges.reduce(
210
213
  (width, change) => Math.max(width, String(change.startLine).length),
@@ -222,55 +225,31 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
222
225
  ? `${change.startLine}${computeLineHash(change.startLine, afterFirstLine)}`
223
226
  : `${change.startLine}:${change.startColumn}`;
224
227
  const lineSeparator = useHashLines ? HASHLINE_CONTENT_SEPARATOR : " ";
225
- outputLines.push(`-${beforeRef}${lineSeparator}${beforeLine}`);
226
- outputLines.push(`+${afterRef}${lineSeparator}${afterLine}`);
227
- displayLines.push(formatCodeFrameLine("-", change.startLine, beforeLine, lineNumberWidth));
228
- displayLines.push(formatCodeFrameLine("+", change.startLine, afterLine, lineNumberWidth));
228
+ modelOut.push(`-${beforeRef}${lineSeparator}${beforeLine}`);
229
+ modelOut.push(`+${afterRef}${lineSeparator}${afterLine}`);
230
+ displayOut.push(formatCodeFrameLine("-", change.startLine, beforeLine, lineNumberWidth));
231
+ displayOut.push(formatCodeFrameLine("+", change.startLine, afterLine, lineNumberWidth));
229
232
  }
233
+ return { model: modelOut, display: displayOut };
230
234
  };
231
235
 
232
236
  if (isDirectory) {
233
- const filesByDirectory = new Map<string, string[]>();
234
- for (const relativePath of fileList) {
235
- const directory = path.dirname(relativePath).replace(/\\/g, "/");
236
- if (!filesByDirectory.has(directory)) {
237
- filesByDirectory.set(directory, []);
238
- }
239
- filesByDirectory.get(directory)!.push(relativePath);
240
- }
241
- for (const [directory, directoryFiles] of filesByDirectory) {
242
- if (directory === ".") {
243
- for (const relativePath of directoryFiles) {
244
- if (outputLines.length > 0) {
245
- outputLines.push("");
246
- displayLines.push("");
247
- }
248
- const count = fileReplacementCounts.get(relativePath) ?? 0;
249
- const header = `# ${path.basename(relativePath)} (${formatCount("replacement", count)})`;
250
- outputLines.push(header);
251
- displayLines.push(header);
252
- renderChangesForFile(relativePath);
253
- }
254
- continue;
255
- }
256
- if (outputLines.length > 0) {
257
- outputLines.push("");
258
- displayLines.push("");
259
- }
260
- const dirHeader = `# ${directory}`;
261
- outputLines.push(dirHeader);
262
- displayLines.push(dirHeader);
263
- for (const relativePath of directoryFiles) {
264
- const count = fileReplacementCounts.get(relativePath) ?? 0;
265
- const fileHeader = `## └─ ${path.basename(relativePath)} (${formatCount("replacement", count)})`;
266
- outputLines.push(fileHeader);
267
- displayLines.push(fileHeader);
268
- renderChangesForFile(relativePath);
269
- }
270
- }
237
+ const grouped = formatGroupedFiles(fileList, relativePath => {
238
+ const rendered = renderChangesForFile(relativePath);
239
+ const count = fileReplacementCounts.get(relativePath) ?? 0;
240
+ return {
241
+ headerSuffix: ` (${formatCount("replacement", count)})`,
242
+ modelLines: rendered.model,
243
+ displayLines: rendered.display,
244
+ };
245
+ });
246
+ outputLines.push(...grouped.model);
247
+ displayLines.push(...grouped.display);
271
248
  } else {
272
249
  for (const relativePath of fileList) {
273
- renderChangesForFile(relativePath);
250
+ const rendered = renderChangesForFile(relativePath);
251
+ outputLines.push(...rendered.model);
252
+ displayLines.push(...rendered.display);
274
253
  }
275
254
  }
276
255
 
@@ -12,6 +12,7 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, t
12
12
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
13
13
  import type { ToolSession } from ".";
14
14
  import { createFileRecorder, formatResultPath } from "./file-recorder";
15
+ import { formatGroupedFiles } from "./grouped-file-output";
15
16
  import { formatMatchLine } from "./match-line-format";
16
17
  import type { OutputMeta } from "./output-meta";
17
18
  import {
@@ -184,7 +185,9 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
184
185
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
185
186
  const outputLines: string[] = [];
186
187
  const displayLines: string[] = [];
187
- const renderMatchesForFile = (relativePath: string) => {
188
+ const renderMatchesForFile = (relativePath: string): { model: string[]; display: string[] } => {
189
+ const modelOut: string[] = [];
190
+ const displayOut: string[] = [];
188
191
  const fileMatches = matchesByFile.get(relativePath) ?? [];
189
192
  const lineNumberWidth = fileMatches.reduce((width, match) => {
190
193
  const lineCount = match.text.split("\n").length;
@@ -197,61 +200,34 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
197
200
  const lineNumber = match.startLine + index;
198
201
  const isMatch = index === 0;
199
202
  const line = matchLines[index] ?? "";
200
- outputLines.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines }));
201
- displayLines.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
203
+ modelOut.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines }));
204
+ displayOut.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
202
205
  }
203
206
  if (match.metaVariables && Object.keys(match.metaVariables).length > 0) {
204
207
  const serializedMeta = Object.entries(match.metaVariables)
205
208
  .sort(([left], [right]) => left.localeCompare(right))
206
209
  .map(([key, value]) => `${key}=${value}`)
207
210
  .join(", ");
208
- outputLines.push(` meta: ${serializedMeta}`);
209
- displayLines.push(` meta: ${serializedMeta}`);
211
+ modelOut.push(` meta: ${serializedMeta}`);
212
+ displayOut.push(` meta: ${serializedMeta}`);
210
213
  }
211
214
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
212
215
  }
216
+ return { model: modelOut, display: displayOut };
213
217
  };
214
218
 
215
219
  if (isDirectory) {
216
- const filesByDirectory = new Map<string, string[]>();
217
- for (const relativePath of fileList) {
218
- const directory = path.dirname(relativePath).replace(/\\/g, "/");
219
- if (!filesByDirectory.has(directory)) {
220
- filesByDirectory.set(directory, []);
221
- }
222
- filesByDirectory.get(directory)!.push(relativePath);
223
- }
224
- for (const [directory, directoryFiles] of filesByDirectory) {
225
- if (directory === ".") {
226
- for (const relativePath of directoryFiles) {
227
- if (outputLines.length > 0) {
228
- outputLines.push("");
229
- displayLines.push("");
230
- }
231
- const header = `# ${path.basename(relativePath)}`;
232
- outputLines.push(header);
233
- displayLines.push(header);
234
- renderMatchesForFile(relativePath);
235
- }
236
- continue;
237
- }
238
- if (outputLines.length > 0) {
239
- outputLines.push("");
240
- displayLines.push("");
241
- }
242
- const dirHeader = `# ${directory}`;
243
- outputLines.push(dirHeader);
244
- displayLines.push(dirHeader);
245
- for (const relativePath of directoryFiles) {
246
- const fileHeader = `## └─ ${path.basename(relativePath)}`;
247
- outputLines.push(fileHeader);
248
- displayLines.push(fileHeader);
249
- renderMatchesForFile(relativePath);
250
- }
251
- }
220
+ const grouped = formatGroupedFiles(fileList, relativePath => {
221
+ const rendered = renderMatchesForFile(relativePath);
222
+ return { modelLines: rendered.model, displayLines: rendered.display };
223
+ });
224
+ outputLines.push(...grouped.model);
225
+ displayLines.push(...grouped.display);
252
226
  } else {
253
227
  for (const relativePath of fileList) {
254
- renderMatchesForFile(relativePath);
228
+ const rendered = renderMatchesForFile(relativePath);
229
+ outputLines.push(...rendered.model);
230
+ displayLines.push(...rendered.display);
255
231
  }
256
232
  }
257
233
 
@@ -52,6 +52,7 @@ export class CheckpointTool implements AgentTool<typeof checkpointSchema, Checkp
52
52
  readonly description: string;
53
53
  readonly parameters = checkpointSchema;
54
54
  readonly strict = true;
55
+ readonly intent = (args: Partial<CheckpointParams>) => args.goal;
55
56
 
56
57
  constructor(private readonly session: ToolSession) {
57
58
  this.description = prompt.render(checkpointDescription);
@@ -94,6 +95,7 @@ export class RewindTool implements AgentTool<typeof rewindSchema, RewindToolDeta
94
95
  readonly description: string;
95
96
  readonly parameters = rewindSchema;
96
97
  readonly strict = true;
98
+ readonly intent = (): string => "Rewinding to checkpoint";
97
99
 
98
100
  constructor(private readonly session: ToolSession) {
99
101
  this.description = prompt.render(rewindDescription);
@@ -46,6 +46,7 @@ export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, Ex
46
46
  readonly parameters = exitPlanModeSchema;
47
47
  readonly strict = true;
48
48
  readonly concurrency = "exclusive";
49
+ readonly intent = (): string => "Exiting plan mode";
49
50
 
50
51
  constructor(private readonly session: ToolSession) {
51
52
  this.description = prompt.render(exitPlanModeDescription);