@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
@@ -27,6 +27,7 @@ import {
27
27
  } from "@oh-my-pi/pi-agent-core";
28
28
  import type {
29
29
  AssistantMessage,
30
+ Context,
30
31
  Effort,
31
32
  ImageContent,
32
33
  Message,
@@ -48,6 +49,7 @@ import {
48
49
  isUsageLimitError,
49
50
  modelsAreEqual,
50
51
  parseRateLimitReason,
52
+ streamSimple,
51
53
  } from "@oh-my-pi/pi-ai";
52
54
  import { killTree, MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
53
55
  import {
@@ -121,6 +123,7 @@ import type { PlanModeState } from "../plan-mode/state";
121
123
  import autoHandoffThresholdFocusPrompt from "../prompts/system/auto-handoff-threshold-focus.md" with { type: "text" };
122
124
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
123
125
  import handoffDocumentPrompt from "../prompts/system/handoff-document.md" with { type: "text" };
126
+ import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
124
127
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
125
128
  import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
126
129
  import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool-decision-reminder.md" with {
@@ -469,6 +472,11 @@ export class AgentSession {
469
472
  #pendingPythonMessages: PythonExecutionMessage[] = [];
470
473
  #activePythonExecutions = new Set<Promise<unknown>>();
471
474
  #pythonExecutionDisposing = false;
475
+
476
+ // Background-channel IRC exchanges queued while the recipient was streaming.
477
+ // Drained into history (via emitExternalEvent) once the recipient becomes idle.
478
+ #pendingBackgroundExchanges: CustomMessage[][] = [];
479
+ #scheduledBackgroundExchangeFlush = false;
472
480
  // Extension system
473
481
  #extensionRunner: ExtensionRunner | undefined = undefined;
474
482
  #turnIndex = 0;
@@ -2653,6 +2661,7 @@ export class AgentSession {
2653
2661
  // Flush any pending bash messages before the new prompt
2654
2662
  this.#flushPendingBashMessages();
2655
2663
  this.#flushPendingPythonMessages();
2664
+ this.#flushPendingBackgroundExchanges();
2656
2665
 
2657
2666
  // Reset todo reminder count on new user prompt
2658
2667
  this.#todoReminderCount = 0;
@@ -3237,11 +3246,11 @@ export class AgentSession {
3237
3246
  return phases.map(phase => ({
3238
3247
  id: phase.id,
3239
3248
  name: phase.name,
3240
- tasks: phase.tasks.map(task => ({
3241
- id: task.id,
3242
- content: task.content,
3243
- status: task.status,
3244
- })),
3249
+ tasks: phase.tasks.map(task => {
3250
+ const out: TodoItem = { id: task.id, content: task.content, status: task.status };
3251
+ if (task.notes && task.notes.length > 0) out.notes = [...task.notes];
3252
+ return out;
3253
+ }),
3245
3254
  }));
3246
3255
  }
3247
3256
 
@@ -5924,6 +5933,209 @@ export class AgentSession {
5924
5933
  this.#pendingPythonMessages = [];
5925
5934
  }
5926
5935
 
5936
+ // =========================================================================
5937
+ // Background-Channel IRC Exchanges
5938
+ // =========================================================================
5939
+
5940
+ /**
5941
+ * Generate an ephemeral reply to a background message (e.g. an IRC ping from
5942
+ * another agent) using this session's current model + system prompt + history.
5943
+ *
5944
+ * The reply is computed via a side-channel `streamSimple` call (analogous to
5945
+ * `/btw`) so it never blocks on the recipient's in-flight tool calls. After
5946
+ * the reply is generated, both the incoming question and the auto-reply are
5947
+ * queued for injection into the recipient's persisted history so the model
5948
+ * sees the exchange on its next turn. Injection happens immediately when the
5949
+ * session is idle, otherwise it is deferred until streaming ends.
5950
+ */
5951
+ async respondAsBackground(args: {
5952
+ from: string;
5953
+ message: string;
5954
+ awaitReply?: boolean;
5955
+ signal?: AbortSignal;
5956
+ }): Promise<{ replyText: string | null }> {
5957
+ const awaitReply = args.awaitReply !== false;
5958
+ const incomingTimestamp = Date.now();
5959
+ const incomingRecord: CustomMessage = {
5960
+ role: "custom",
5961
+ customType: "irc:incoming",
5962
+ content: `[IRC \`${args.from}\` \u2192 you]\n\n${args.message}`,
5963
+ display: true,
5964
+ details: { from: args.from, message: args.message },
5965
+ attribution: "agent",
5966
+ timestamp: incomingTimestamp,
5967
+ };
5968
+
5969
+ if (!awaitReply) {
5970
+ this.#queueBackgroundExchangeInjection([incomingRecord]);
5971
+ return { replyText: null };
5972
+ }
5973
+
5974
+ const incomingPrompt = prompt.render(ircIncomingTemplate, {
5975
+ from: args.from,
5976
+ message: args.message,
5977
+ });
5978
+ const { replyText } = await this.runEphemeralTurn({
5979
+ promptText: incomingPrompt,
5980
+ signal: args.signal,
5981
+ });
5982
+
5983
+ const replyRecord: CustomMessage = {
5984
+ role: "custom",
5985
+ customType: "irc:autoreply",
5986
+ content: `[IRC you \u2192 \`${args.from}\` (auto)]\n\n${replyText}`,
5987
+ display: true,
5988
+ details: { to: args.from, reply: replyText },
5989
+ attribution: "agent",
5990
+ timestamp: Date.now(),
5991
+ };
5992
+ this.#queueBackgroundExchangeInjection([incomingRecord, replyRecord]);
5993
+
5994
+ return { replyText };
5995
+ }
5996
+
5997
+ /**
5998
+ * Run a single ephemeral side-channel turn against this session's current
5999
+ * model + system prompt + history. No tools are used; the side request
6000
+ * does not block on, or interfere with, any in-flight main turn. The
6001
+ * session's history and persisted state are NOT modified by this call.
6002
+ *
6003
+ * Used by `respondAsBackground` (IRC) and `BtwController` (`/btw`) to share
6004
+ * the snapshot + stream pipeline. The snapshot includes any in-flight
6005
+ * streaming assistant text so the model sees the half-finished response
6006
+ * rather than missing context.
6007
+ */
6008
+ async runEphemeralTurn(args: {
6009
+ promptText: string;
6010
+ onTextDelta?: (delta: string) => void;
6011
+ signal?: AbortSignal;
6012
+ }): Promise<{ replyText: string; assistantMessage: AssistantMessage }> {
6013
+ const model = this.model;
6014
+ if (!model) {
6015
+ throw new Error("No active model on session");
6016
+ }
6017
+ const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
6018
+ if (!apiKey) {
6019
+ throw new Error(`No API key for ${model.provider}/${model.id}`);
6020
+ }
6021
+
6022
+ const snapshot = this.#buildEphemeralSnapshot(args.promptText);
6023
+ const llmMessages = await this.convertMessagesToLlm(snapshot, args.signal);
6024
+ const context: Context = {
6025
+ systemPrompt: this.systemPrompt,
6026
+ messages: llmMessages,
6027
+ };
6028
+ const options = this.prepareSimpleStreamOptions({
6029
+ apiKey,
6030
+ sessionId: this.sessionId,
6031
+ reasoning: toReasoningEffort(this.thinkingLevel),
6032
+ serviceTier: this.serviceTier,
6033
+ signal: args.signal,
6034
+ toolChoice: "none",
6035
+ });
6036
+
6037
+ let replyText = "";
6038
+ let assistantMessage: AssistantMessage | undefined;
6039
+ const stream = streamSimple(model, context, options);
6040
+ for await (const event of stream) {
6041
+ if (event.type === "text_delta") {
6042
+ replyText += event.delta;
6043
+ if (args.onTextDelta) args.onTextDelta(event.delta);
6044
+ continue;
6045
+ }
6046
+ if (event.type === "done") {
6047
+ assistantMessage = event.message;
6048
+ break;
6049
+ }
6050
+ if (event.type === "error") {
6051
+ throw new Error(event.error.errorMessage || "Ephemeral turn failed");
6052
+ }
6053
+ }
6054
+
6055
+ if (!assistantMessage) {
6056
+ throw new Error("Ephemeral turn ended without a final message");
6057
+ }
6058
+ return { replyText: replyText.trim(), assistantMessage };
6059
+ }
6060
+
6061
+ /**
6062
+ * Build a message snapshot for an ephemeral side-channel turn. Includes
6063
+ * the in-flight streaming assistant message (if any) so the model sees
6064
+ * the partial response in context, then appends the prompt as a virtual
6065
+ * user message.
6066
+ */
6067
+ #buildEphemeralSnapshot(promptText: string): AgentMessage[] {
6068
+ const messages = [...this.messages];
6069
+ const streaming = this.agent.state.streamMessage;
6070
+ if (streaming && streaming.role === "assistant") {
6071
+ const streamingText = streaming.content
6072
+ .filter((c): c is TextContent => c.type === "text")
6073
+ .map(c => c.text)
6074
+ .join("");
6075
+ if (streamingText) {
6076
+ const normalized: AssistantMessage = {
6077
+ ...streaming,
6078
+ content: [{ type: "text", text: streamingText }],
6079
+ };
6080
+ const lastMessage = messages.at(-1);
6081
+ if (lastMessage?.role === "assistant") {
6082
+ messages[messages.length - 1] = normalized;
6083
+ } else {
6084
+ messages.push(normalized);
6085
+ }
6086
+ }
6087
+ }
6088
+ messages.push({
6089
+ role: "user",
6090
+ content: [{ type: "text", text: promptText }],
6091
+ attribution: "agent",
6092
+ timestamp: Date.now(),
6093
+ });
6094
+ return messages;
6095
+ }
6096
+
6097
+ #queueBackgroundExchangeInjection(messages: CustomMessage[]): void {
6098
+ this.#pendingBackgroundExchanges.push(messages);
6099
+ if (!this.isStreaming) {
6100
+ this.#flushPendingBackgroundExchanges();
6101
+ return;
6102
+ }
6103
+ this.#scheduleBackgroundExchangeFlush();
6104
+ }
6105
+
6106
+ #scheduleBackgroundExchangeFlush(): void {
6107
+ if (this.#scheduledBackgroundExchangeFlush) return;
6108
+ this.#scheduledBackgroundExchangeFlush = true;
6109
+ const attempt = (): void => {
6110
+ if (this.#pendingBackgroundExchanges.length === 0) {
6111
+ this.#scheduledBackgroundExchangeFlush = false;
6112
+ return;
6113
+ }
6114
+ if (this.isStreaming) {
6115
+ setTimeout(attempt, 50);
6116
+ return;
6117
+ }
6118
+ this.#scheduledBackgroundExchangeFlush = false;
6119
+ this.#flushPendingBackgroundExchanges();
6120
+ };
6121
+ setTimeout(attempt, 0);
6122
+ }
6123
+
6124
+ #flushPendingBackgroundExchanges(): void {
6125
+ if (this.#pendingBackgroundExchanges.length === 0) return;
6126
+ const batches = this.#pendingBackgroundExchanges;
6127
+ this.#pendingBackgroundExchanges = [];
6128
+ for (const batch of batches) {
6129
+ for (const msg of batch) {
6130
+ // emitExternalEvent on message_end appends to agent state and dispatches
6131
+ // to all session listeners, which in turn handle TUI rendering and
6132
+ // sessionManager persistence via #handleAgentEvent.
6133
+ this.agent.emitExternalEvent({ type: "message_start", message: msg });
6134
+ this.agent.emitExternalEvent({ type: "message_end", message: msg });
6135
+ }
6136
+ }
6137
+ }
6138
+
5927
6139
  // =========================================================================
5928
6140
  // Session Management
5929
6141
  // =========================================================================
@@ -259,6 +259,8 @@ export interface SessionInfo {
259
259
  created: Date;
260
260
  modified: Date;
261
261
  messageCount: number;
262
+ /** File size in bytes on disk; used for compact list rendering. */
263
+ size: number;
262
264
  firstMessage: string;
263
265
  allMessagesText: string;
264
266
  }
@@ -1264,7 +1266,7 @@ function extractTextFromContent(content: Message["content"]): string {
1264
1266
  .join(" ");
1265
1267
  }
1266
1268
 
1267
- const SESSION_LIST_PREFIX_BYTES = 1024;
1269
+ const SESSION_LIST_PREFIX_BYTES = 4096;
1268
1270
  const SESSION_LIST_PARALLEL_THRESHOLD = 64;
1269
1271
  const SESSION_LIST_MAX_WORKERS = 16;
1270
1272
  const sessionListPrefixDecoder = new TextDecoder("utf-8", { fatal: false });
@@ -1466,6 +1468,7 @@ async function collectSessionFromFile(
1466
1468
  created: new Date(header.timestamp ?? ""),
1467
1469
  modified: stats.mtime,
1468
1470
  messageCount,
1471
+ size: stats.size,
1469
1472
  firstMessage: firstMessage || "(no messages)",
1470
1473
  allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
1471
1474
  };
@@ -759,7 +759,7 @@ export function formatHeadTruncationNotice(
759
759
  const totalFileLines = options.totalFileLines ?? truncation.totalLines;
760
760
  const endLineDisplay = startLineDisplay + (truncation.outputLines ?? truncation.totalLines) - 1;
761
761
  const nextOffset = endLineDisplay + 1;
762
- const notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use sel=L${nextOffset} to continue]`;
762
+ const notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use sel=${nextOffset} to continue]`;
763
763
  return `\n\n${notice}`;
764
764
  }
765
765
 
@@ -259,6 +259,30 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
259
259
  runtime.ctx.editor.setText("");
260
260
  },
261
261
  },
262
+ {
263
+ name: "todo",
264
+ description: "View or modify the agent's todo list",
265
+ subcommands: [
266
+ { name: "edit", description: "Open todos in $EDITOR (Markdown round-trip)" },
267
+ { name: "copy", description: "Copy todos as Markdown to clipboard" },
268
+ { name: "export", description: "Write todos as Markdown to a file (default: TODO.md)", usage: "[<path>]" },
269
+ { name: "import", description: "Replace todos from a Markdown file (default: TODO.md)", usage: "[<path>]" },
270
+ {
271
+ name: "append",
272
+ description: "Append a task; phase fuzzy-matched or auto-created",
273
+ usage: "[<phase>] <task...>",
274
+ },
275
+ { name: "start", description: "Mark task in_progress (fuzzy-matched)", usage: "<task>" },
276
+ { name: "done", description: "Mark task/phase/all completed (fuzzy-matched)", usage: "[<task|phase>]" },
277
+ { name: "drop", description: "Mark task/phase/all abandoned (fuzzy-matched)", usage: "[<task|phase>]" },
278
+ { name: "rm", description: "Remove task/phase/all (fuzzy-matched)", usage: "[<task|phase>]" },
279
+ ],
280
+ allowArgs: true,
281
+ handle: async (command, runtime) => {
282
+ await runtime.ctx.handleTodoCommand(command.args);
283
+ runtime.ctx.editor.setText("");
284
+ },
285
+ },
262
286
  {
263
287
  name: "session",
264
288
  description: "Session management commands",
@@ -20,6 +20,7 @@ import { callTool } from "../mcp/client";
20
20
  import type { MCPManager } from "../mcp/manager";
21
21
  import subagentSystemPromptTemplate from "../prompts/system/subagent-system-prompt.md" with { type: "text" };
22
22
  import submitReminderTemplate from "../prompts/system/subagent-yield-reminder.md" with { type: "text" };
23
+ import { AgentRegistry } from "../registry/agent-registry";
23
24
  import { createAgentSession, discoverAuthStorage } from "../sdk";
24
25
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
25
26
  import type { AuthStorage } from "../session/auth-storage";
@@ -73,6 +74,14 @@ function normalizeModelPatterns(value: string | string[] | undefined): string[]
73
74
  .filter(Boolean);
74
75
  }
75
76
 
77
+ function renderIrcPeerRoster(selfId: string): string {
78
+ const peers = AgentRegistry.global()
79
+ .list()
80
+ .filter(ref => ref.id !== selfId && (ref.status === "running" || ref.status === "idle"));
81
+ if (peers.length === 0) return "- (no other live agents)";
82
+ return peers.map(peer => `- \`${peer.id}\` — ${peer.displayName} (${peer.kind}, ${peer.status})`).join("\n");
83
+ }
84
+
76
85
  function withAbortTimeout<T>(promise: Promise<T>, timeoutMs: number, signal?: AbortSignal): Promise<T> {
77
86
  if (signal?.aborted) {
78
87
  return Promise.reject(new ToolAbortError());
@@ -543,6 +552,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
543
552
  : agent.spawns.join(",");
544
553
 
545
554
  const lspEnabled = enableLsp ?? true;
555
+ const ircEnabled = subagentSettings.get("irc.enabled") === true;
546
556
  const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("python");
547
557
 
548
558
  const outputChunks: string[] = [];
@@ -962,12 +972,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
962
972
  worktree: worktree ?? "",
963
973
  outputSchema: normalizedOutputSchema,
964
974
  contextFile: options.contextFile,
975
+ ircPeers: ircEnabled ? renderIrcPeerRoster(id) : "",
976
+ ircSelfId: ircEnabled ? id : "",
965
977
  }),
966
978
  sessionManager,
967
979
  hasUI: false,
968
980
  spawns: spawnsEnv,
969
981
  taskDepth: childDepth,
970
982
  parentTaskPrefix: id,
983
+ agentId: id,
984
+ agentDisplayName: agent.name,
971
985
  enableLsp: lspEnabled,
972
986
  skipPythonPreflight,
973
987
  enableMCP,
package/src/tools/bash.ts CHANGED
@@ -346,7 +346,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
346
346
  }
347
347
  lines.push(`Background job ${jobId} started: ${label}`);
348
348
  lines.push("Result will be delivered automatically when complete.");
349
- lines.push(`Use \`poll\`, \`read jobs://${jobId}\`, or \`cancel_job\` if needed.`);
349
+ lines.push(`Use \`job\` (with \`poll\` or \`cancel\`) or \`read jobs://${jobId}\` if needed.`);
350
350
  return {
351
351
  content: [{ type: "text", text: lines.join("\n") }],
352
352
  details,
@@ -137,7 +137,9 @@ export function isReadableUrlPath(value: string): boolean {
137
137
  return /^https?:\/\//i.test(value) || /^www\./i.test(value);
138
138
  }
139
139
 
140
- const URL_LINE_RANGE_RE = /^L(\d+)(?:-L?(\d+))?$/i;
140
+ const URL_LINE_RANGE_RE = /^L?(\d+)(?:([-+])L?(\d+))?$/i;
141
+ // Embedded URL selectors (after a `:` in the path) keep the explicit `L` prefix to avoid colliding with ports such as `https://example.com:50`.
142
+ const URL_EMBEDDED_LINE_RANGE_RE = /^L\d+(?:[-+]L?\d+)?$/i;
141
143
 
142
144
  export interface ParsedReadUrlTarget {
143
145
  path: string;
@@ -159,11 +161,21 @@ export function parseReadUrlTarget(readPath: string, sel?: string): ParsedReadUr
159
161
  if (lineMatch) {
160
162
  const startLine = Number.parseInt(lineMatch[1]!, 10);
161
163
  if (startLine < 1) {
162
- throw new ToolError("L0 is invalid; lines are 1-indexed. Use sel=L1.");
164
+ throw new ToolError("sel=0 is invalid; lines are 1-indexed. Use sel=1.");
163
165
  }
164
- const endLine = lineMatch[2] ? Number.parseInt(lineMatch[2], 10) : undefined;
165
- if (endLine !== undefined && endLine < startLine) {
166
- throw new ToolError(`Invalid range L${startLine}-L${endLine}: end must be >= start.`);
166
+ const sep = lineMatch[2];
167
+ const rhs = lineMatch[3] ? Number.parseInt(lineMatch[3], 10) : undefined;
168
+ let endLine: number | undefined;
169
+ if (sep === "+") {
170
+ if (rhs === undefined || rhs < 1) {
171
+ throw new ToolError(`Invalid range ${startLine}+${rhs ?? 0}: count must be >= 1.`);
172
+ }
173
+ endLine = startLine + rhs - 1;
174
+ } else if (sep === "-") {
175
+ if (rhs === undefined || rhs < startLine) {
176
+ throw new ToolError(`Invalid range ${startLine}-${rhs ?? 0}: end must be >= start.`);
177
+ }
178
+ endLine = rhs;
167
179
  }
168
180
  return {
169
181
  path: urlPath,
@@ -183,7 +195,7 @@ function tryExtractEmbeddedUrlSelector(readPath: string): { path: string; sel?:
183
195
  }
184
196
 
185
197
  const candidateSelector = readPath.slice(lastColonIndex + 1);
186
- const isEmbeddedSelector = candidateSelector === "raw" || URL_LINE_RANGE_RE.test(candidateSelector);
198
+ const isEmbeddedSelector = candidateSelector === "raw" || URL_EMBEDDED_LINE_RANGE_RE.test(candidateSelector);
187
199
  if (!isEmbeddedSelector) {
188
200
  return null;
189
201
  }
@@ -1,12 +1,10 @@
1
1
  import { invalidateFsScanCache } from "@oh-my-pi/pi-natives";
2
- import { invalidateChunkCache } from "../edit/modes/chunk";
3
2
 
4
3
  /**
5
4
  * Invalidate shared filesystem scan caches after a content write/update.
6
5
  */
7
6
  export function invalidateFsScanAfterWrite(path: string): void {
8
7
  invalidateFsScanCache(path);
9
- invalidateChunkCache(path);
10
8
  }
11
9
 
12
10
  /**
@@ -14,7 +12,6 @@ export function invalidateFsScanAfterWrite(path: string): void {
14
12
  */
15
13
  export function invalidateFsScanAfterDelete(path: string): void {
16
14
  invalidateFsScanCache(path);
17
- invalidateChunkCache(path);
18
15
  }
19
16
 
20
17
  /**
@@ -25,9 +22,7 @@ export function invalidateFsScanAfterDelete(path: string): void {
25
22
  */
26
23
  export function invalidateFsScanAfterRename(oldPath: string, newPath: string): void {
27
24
  invalidateFsScanCache(oldPath);
28
- invalidateChunkCache(oldPath);
29
25
  if (newPath !== oldPath) {
30
26
  invalidateFsScanCache(newPath);
31
- invalidateChunkCache(newPath);
32
27
  }
33
28
  }
package/src/tools/grep.ts CHANGED
@@ -6,13 +6,11 @@ import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
7
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import { type Static, Type } from "@sinclair/typebox";
9
- import { type ChunkedGrepMatch, describeChunkedGrepMatch } from "../edit/modes/chunk";
10
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
- import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
10
+ import type { Theme } from "../modes/theme/theme";
12
11
  import grepDescription from "../prompts/tools/grep.md" with { type: "text" };
13
12
  import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output";
14
13
  import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
15
- import { resolveEditMode } from "../utils/edit-mode";
16
14
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
17
15
  import type { ToolSession } from ".";
18
16
  import { createFileRecorder } from "./file-recorder";
@@ -64,7 +62,7 @@ export interface GrepToolDetails {
64
62
  truncated?: boolean;
65
63
  error?: string;
66
64
  /** Pre-formatted text for the user-visible TUI render. Mirrors the model-facing
67
- * `result.text` lines but uses a `│` gutter and `*` to mark match lines (vs `-` for
65
+ * `result.text` lines but uses a `│` gutter and `*` to mark match lines (vs space for
68
66
  * context). The TUI uses this directly so it never parses model-facing hashline anchors. */
69
67
  displayContent?: string;
70
68
  }
@@ -83,7 +81,6 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
83
81
  this.description = prompt.render(grepDescription, {
84
82
  IS_HASHLINE_MODE: displayMode.hashLines,
85
83
  IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
86
- IS_CHUNK_MODE: displayMode.chunked,
87
84
  });
88
85
  }
89
86
 
@@ -98,7 +95,6 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
98
95
 
99
96
  return untilAborted(signal, async () => {
100
97
  const normalizedPattern = pattern.trim();
101
- const chunkMode = resolveEditMode(this.session) === "chunk";
102
98
  if (!normalizedPattern) {
103
99
  throw new ToolError("Pattern must not be empty");
104
100
  }
@@ -297,124 +293,6 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
297
293
  }
298
294
  matchesByFile.get(relativePath)!.push(match);
299
295
  }
300
- if (chunkMode) {
301
- const annotatedMatches = await Promise.all(
302
- selectedMatches.map(match => {
303
- const relativePath = match.path.startsWith("/") ? match.path.slice(1) : match.path;
304
- const absoluteFilePath = isDirectory ? path.join(searchPath, relativePath) : searchPath;
305
- return describeChunkedGrepMatch({
306
- filePath: absoluteFilePath,
307
- lineNumber: match.lineNumber,
308
- line: match.line,
309
- cwd: this.session.cwd,
310
- language: getLanguageFromPath(absoluteFilePath),
311
- });
312
- }),
313
- );
314
- const chunkMatchesByFile = new Map<string, ChunkedGrepMatch[]>();
315
- for (const match of annotatedMatches) {
316
- recordFile(match.displayPath);
317
- if (!chunkMatchesByFile.has(match.displayPath)) {
318
- chunkMatchesByFile.set(match.displayPath, []);
319
- }
320
- chunkMatchesByFile.get(match.displayPath)!.push(match);
321
- }
322
- const renderChunkedMatchesForFile = (relativePath: string): string[] => {
323
- const renderedLines: string[] = [];
324
- const fileMatches = chunkMatchesByFile.get(relativePath) ?? [];
325
- if (fileMatches.length === 0) {
326
- return renderedLines;
327
- }
328
- const matchesByChunk = new Map<string, ChunkedGrepMatch[]>();
329
- for (const match of fileMatches) {
330
- const chunkKey = match.chunkPath ?? "";
331
- if (!matchesByChunk.has(chunkKey)) {
332
- matchesByChunk.set(chunkKey, []);
333
- }
334
- matchesByChunk.get(chunkKey)!.push(match);
335
- }
336
- for (const [chunkPath, chunkMatches] of matchesByChunk) {
337
- if (chunkPath) {
338
- const chunkChecksum = chunkMatches[0]?.chunkChecksum;
339
- const dashes = "-".repeat(chunkPath.split(".").length - 1);
340
- const anchor = chunkChecksum
341
- ? `${dashes}@${chunkPath}#${chunkChecksum}`
342
- : `${dashes}@${chunkPath}`;
343
- renderedLines.push(anchor);
344
- }
345
- for (const match of chunkMatches) {
346
- renderedLines.push(` ${match.lineNumber}|${match.line}`);
347
- fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
348
- }
349
- }
350
- return renderedLines;
351
- };
352
- if (isDirectory) {
353
- const filesByDirectory = new Map<string, string[]>();
354
- for (const relativePath of fileList) {
355
- const directory = path.dirname(relativePath).replace(/\\/g, "/");
356
- if (!filesByDirectory.has(directory)) {
357
- filesByDirectory.set(directory, []);
358
- }
359
- filesByDirectory.get(directory)!.push(relativePath);
360
- }
361
- for (const [directory, directoryFiles] of filesByDirectory) {
362
- if (directory === ".") {
363
- for (const relativePath of directoryFiles) {
364
- const renderedLines = renderChunkedMatchesForFile(relativePath);
365
- if (renderedLines.length === 0) continue;
366
- if (outputLines.length > 0) {
367
- outputLines.push("");
368
- }
369
- outputLines.push(`# ${path.basename(relativePath)}`);
370
- outputLines.push(...renderedLines);
371
- }
372
- continue;
373
- }
374
- const renderedFiles = directoryFiles
375
- .map(relativePath => ({ relativePath, lines: renderChunkedMatchesForFile(relativePath) }))
376
- .filter(file => file.lines.length > 0);
377
- if (renderedFiles.length === 0) continue;
378
- if (outputLines.length > 0) {
379
- outputLines.push("");
380
- }
381
- outputLines.push(`# ${directory}`);
382
- for (const { relativePath, lines } of renderedFiles) {
383
- outputLines.push(`## └─ ${path.basename(relativePath)}`);
384
- outputLines.push(...lines);
385
- }
386
- }
387
- } else {
388
- for (const relativePath of fileList) {
389
- outputLines.push(...renderChunkedMatchesForFile(relativePath));
390
- }
391
- }
392
- if (matchLimitReached || result.limitReached) {
393
- outputLines.push("", limitMessage);
394
- }
395
- const rawOutput = outputLines.join("\n");
396
- const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
397
- const truncated = Boolean(matchLimitReached || result.limitReached || truncation.truncated);
398
- const details: GrepToolDetails = {
399
- scopePath,
400
- matchCount: selectedMatches.length,
401
- fileCount: fileList.length,
402
- files: fileList,
403
- fileMatches: fileList.map(path => ({
404
- path,
405
- count: fileMatchCounts.get(path) ?? 0,
406
- })),
407
- truncated,
408
- matchLimitReached: matchLimitReached ? effectiveLimit : undefined,
409
- resultLimitReached: result.limitReached ? internalLimit : undefined,
410
- };
411
- if (truncation.truncated) details.truncation = truncation;
412
- const resultBuilder = toolResult(details).text(truncation.content);
413
- if (truncation.truncated) {
414
- resultBuilder.truncation(truncation, { direction: "head" });
415
- }
416
- return resultBuilder.done();
417
- }
418
296
  const displayLines: string[] = [];
419
297
  const renderMatchesForFile = (relativePath: string): { model: string[]; display: string[] } => {
420
298
  const modelOut: string[] = [];
@@ -502,7 +380,9 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
502
380
  }
503
381
  }
504
382
  if (hasContextLines && outputLines.length > 0) {
505
- outputLines.unshift("[grep] match lines use ':'; context lines use '-'.");
383
+ outputLines.unshift(
384
+ "[grep] '*' marks match lines; leading space marks context. Anchor and content are separated by '|'.",
385
+ );
506
386
  }
507
387
  if (matchLimitReached || result.limitReached) {
508
388
  outputLines.push("", limitMessage);