@oh-my-pi/pi-coding-agent 14.4.1 → 14.4.4

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 (71) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/package.json +7 -7
  3. package/src/cli.ts +0 -1
  4. package/src/config/prompt-templates.ts +0 -30
  5. package/src/config/settings-schema.ts +68 -36
  6. package/src/config/settings.ts +1 -1
  7. package/src/edit/index.ts +1 -53
  8. package/src/edit/line-hash.ts +0 -53
  9. package/src/edit/modes/atom.ts +82 -47
  10. package/src/edit/modes/hashline.ts +6 -8
  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/modes/components/session-observer-overlay.ts +635 -295
  17. package/src/modes/components/settings-defs.ts +1 -5
  18. package/src/modes/components/tool-execution.ts +2 -5
  19. package/src/modes/controllers/btw-controller.ts +17 -105
  20. package/src/modes/controllers/command-controller.ts +16 -5
  21. package/src/modes/controllers/selector-controller.ts +32 -19
  22. package/src/modes/controllers/todo-command-controller.ts +537 -0
  23. package/src/modes/interactive-mode.ts +45 -10
  24. package/src/modes/types.ts +3 -0
  25. package/src/modes/utils/ui-helpers.ts +17 -0
  26. package/src/prompts/system/irc-incoming.md +8 -0
  27. package/src/prompts/system/subagent-system-prompt.md +8 -0
  28. package/src/prompts/tools/ast-grep.md +1 -1
  29. package/src/prompts/tools/atom.md +37 -26
  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 +226 -6
  40. package/src/session/session-manager.ts +13 -0
  41. package/src/session/session-storage.ts +4 -0
  42. package/src/session/streaming-output.ts +1 -1
  43. package/src/slash-commands/builtin-registry.ts +32 -0
  44. package/src/task/executor.ts +14 -0
  45. package/src/tools/bash.ts +1 -1
  46. package/src/tools/fetch.ts +18 -6
  47. package/src/tools/fs-cache-invalidation.ts +0 -5
  48. package/src/tools/grep.ts +4 -124
  49. package/src/tools/index.ts +12 -6
  50. package/src/tools/irc.ts +258 -0
  51. package/src/tools/job.ts +489 -0
  52. package/src/tools/match-line-format.ts +7 -6
  53. package/src/tools/output-meta.ts +1 -1
  54. package/src/tools/read.ts +36 -126
  55. package/src/tools/renderers.ts +2 -0
  56. package/src/tools/todo-write.ts +243 -12
  57. package/src/utils/edit-mode.ts +1 -2
  58. package/src/utils/file-display-mode.ts +0 -3
  59. package/src/web/search/index.ts +2 -2
  60. package/src/web/search/provider.ts +3 -0
  61. package/src/web/search/providers/searxng.ts +238 -0
  62. package/src/web/search/types.ts +3 -1
  63. package/src/cli/read-cli.ts +0 -67
  64. package/src/commands/read.ts +0 -33
  65. package/src/edit/modes/chunk.ts +0 -832
  66. package/src/prompts/tools/cancel-job.md +0 -5
  67. package/src/prompts/tools/chunk-edit.md +0 -158
  68. package/src/prompts/tools/poll.md +0 -5
  69. package/src/prompts/tools/read-chunk.md +0 -73
  70. package/src/tools/cancel-job.ts +0 -95
  71. 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
 
@@ -3364,7 +3373,15 @@ export class AgentSession {
3364
3373
  this.#asyncJobManager?.cancelAll();
3365
3374
  this.#closeAllProviderSessions("new session");
3366
3375
  this.agent.reset();
3367
- await this.sessionManager.flush();
3376
+ if (options?.drop && previousSessionFile) {
3377
+ try {
3378
+ await this.sessionManager.dropSession(previousSessionFile);
3379
+ } catch (err) {
3380
+ logger.error("Failed to delete session during /drop", { err });
3381
+ }
3382
+ } else {
3383
+ await this.sessionManager.flush();
3384
+ }
3368
3385
  await this.sessionManager.newSession(options);
3369
3386
  this.setTodoPhases([]);
3370
3387
  this.agent.sessionId = this.sessionManager.getSessionId();
@@ -5924,6 +5941,209 @@ export class AgentSession {
5924
5941
  this.#pendingPythonMessages = [];
5925
5942
  }
5926
5943
 
5944
+ // =========================================================================
5945
+ // Background-Channel IRC Exchanges
5946
+ // =========================================================================
5947
+
5948
+ /**
5949
+ * Generate an ephemeral reply to a background message (e.g. an IRC ping from
5950
+ * another agent) using this session's current model + system prompt + history.
5951
+ *
5952
+ * The reply is computed via a side-channel `streamSimple` call (analogous to
5953
+ * `/btw`) so it never blocks on the recipient's in-flight tool calls. After
5954
+ * the reply is generated, both the incoming question and the auto-reply are
5955
+ * queued for injection into the recipient's persisted history so the model
5956
+ * sees the exchange on its next turn. Injection happens immediately when the
5957
+ * session is idle, otherwise it is deferred until streaming ends.
5958
+ */
5959
+ async respondAsBackground(args: {
5960
+ from: string;
5961
+ message: string;
5962
+ awaitReply?: boolean;
5963
+ signal?: AbortSignal;
5964
+ }): Promise<{ replyText: string | null }> {
5965
+ const awaitReply = args.awaitReply !== false;
5966
+ const incomingTimestamp = Date.now();
5967
+ const incomingRecord: CustomMessage = {
5968
+ role: "custom",
5969
+ customType: "irc:incoming",
5970
+ content: `[IRC \`${args.from}\` \u2192 you]\n\n${args.message}`,
5971
+ display: true,
5972
+ details: { from: args.from, message: args.message },
5973
+ attribution: "agent",
5974
+ timestamp: incomingTimestamp,
5975
+ };
5976
+
5977
+ if (!awaitReply) {
5978
+ this.#queueBackgroundExchangeInjection([incomingRecord]);
5979
+ return { replyText: null };
5980
+ }
5981
+
5982
+ const incomingPrompt = prompt.render(ircIncomingTemplate, {
5983
+ from: args.from,
5984
+ message: args.message,
5985
+ });
5986
+ const { replyText } = await this.runEphemeralTurn({
5987
+ promptText: incomingPrompt,
5988
+ signal: args.signal,
5989
+ });
5990
+
5991
+ const replyRecord: CustomMessage = {
5992
+ role: "custom",
5993
+ customType: "irc:autoreply",
5994
+ content: `[IRC you \u2192 \`${args.from}\` (auto)]\n\n${replyText}`,
5995
+ display: true,
5996
+ details: { to: args.from, reply: replyText },
5997
+ attribution: "agent",
5998
+ timestamp: Date.now(),
5999
+ };
6000
+ this.#queueBackgroundExchangeInjection([incomingRecord, replyRecord]);
6001
+
6002
+ return { replyText };
6003
+ }
6004
+
6005
+ /**
6006
+ * Run a single ephemeral side-channel turn against this session's current
6007
+ * model + system prompt + history. No tools are used; the side request
6008
+ * does not block on, or interfere with, any in-flight main turn. The
6009
+ * session's history and persisted state are NOT modified by this call.
6010
+ *
6011
+ * Used by `respondAsBackground` (IRC) and `BtwController` (`/btw`) to share
6012
+ * the snapshot + stream pipeline. The snapshot includes any in-flight
6013
+ * streaming assistant text so the model sees the half-finished response
6014
+ * rather than missing context.
6015
+ */
6016
+ async runEphemeralTurn(args: {
6017
+ promptText: string;
6018
+ onTextDelta?: (delta: string) => void;
6019
+ signal?: AbortSignal;
6020
+ }): Promise<{ replyText: string; assistantMessage: AssistantMessage }> {
6021
+ const model = this.model;
6022
+ if (!model) {
6023
+ throw new Error("No active model on session");
6024
+ }
6025
+ const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
6026
+ if (!apiKey) {
6027
+ throw new Error(`No API key for ${model.provider}/${model.id}`);
6028
+ }
6029
+
6030
+ const snapshot = this.#buildEphemeralSnapshot(args.promptText);
6031
+ const llmMessages = await this.convertMessagesToLlm(snapshot, args.signal);
6032
+ const context: Context = {
6033
+ systemPrompt: this.systemPrompt,
6034
+ messages: llmMessages,
6035
+ };
6036
+ const options = this.prepareSimpleStreamOptions({
6037
+ apiKey,
6038
+ sessionId: this.sessionId,
6039
+ reasoning: toReasoningEffort(this.thinkingLevel),
6040
+ serviceTier: this.serviceTier,
6041
+ signal: args.signal,
6042
+ toolChoice: "none",
6043
+ });
6044
+
6045
+ let replyText = "";
6046
+ let assistantMessage: AssistantMessage | undefined;
6047
+ const stream = streamSimple(model, context, options);
6048
+ for await (const event of stream) {
6049
+ if (event.type === "text_delta") {
6050
+ replyText += event.delta;
6051
+ if (args.onTextDelta) args.onTextDelta(event.delta);
6052
+ continue;
6053
+ }
6054
+ if (event.type === "done") {
6055
+ assistantMessage = event.message;
6056
+ break;
6057
+ }
6058
+ if (event.type === "error") {
6059
+ throw new Error(event.error.errorMessage || "Ephemeral turn failed");
6060
+ }
6061
+ }
6062
+
6063
+ if (!assistantMessage) {
6064
+ throw new Error("Ephemeral turn ended without a final message");
6065
+ }
6066
+ return { replyText: replyText.trim(), assistantMessage };
6067
+ }
6068
+
6069
+ /**
6070
+ * Build a message snapshot for an ephemeral side-channel turn. Includes
6071
+ * the in-flight streaming assistant message (if any) so the model sees
6072
+ * the partial response in context, then appends the prompt as a virtual
6073
+ * user message.
6074
+ */
6075
+ #buildEphemeralSnapshot(promptText: string): AgentMessage[] {
6076
+ const messages = [...this.messages];
6077
+ const streaming = this.agent.state.streamMessage;
6078
+ if (streaming && streaming.role === "assistant") {
6079
+ const streamingText = streaming.content
6080
+ .filter((c): c is TextContent => c.type === "text")
6081
+ .map(c => c.text)
6082
+ .join("");
6083
+ if (streamingText) {
6084
+ const normalized: AssistantMessage = {
6085
+ ...streaming,
6086
+ content: [{ type: "text", text: streamingText }],
6087
+ };
6088
+ const lastMessage = messages.at(-1);
6089
+ if (lastMessage?.role === "assistant") {
6090
+ messages[messages.length - 1] = normalized;
6091
+ } else {
6092
+ messages.push(normalized);
6093
+ }
6094
+ }
6095
+ }
6096
+ messages.push({
6097
+ role: "user",
6098
+ content: [{ type: "text", text: promptText }],
6099
+ attribution: "agent",
6100
+ timestamp: Date.now(),
6101
+ });
6102
+ return messages;
6103
+ }
6104
+
6105
+ #queueBackgroundExchangeInjection(messages: CustomMessage[]): void {
6106
+ this.#pendingBackgroundExchanges.push(messages);
6107
+ if (!this.isStreaming) {
6108
+ this.#flushPendingBackgroundExchanges();
6109
+ return;
6110
+ }
6111
+ this.#scheduleBackgroundExchangeFlush();
6112
+ }
6113
+
6114
+ #scheduleBackgroundExchangeFlush(): void {
6115
+ if (this.#scheduledBackgroundExchangeFlush) return;
6116
+ this.#scheduledBackgroundExchangeFlush = true;
6117
+ const attempt = (): void => {
6118
+ if (this.#pendingBackgroundExchanges.length === 0) {
6119
+ this.#scheduledBackgroundExchangeFlush = false;
6120
+ return;
6121
+ }
6122
+ if (this.isStreaming) {
6123
+ setTimeout(attempt, 50);
6124
+ return;
6125
+ }
6126
+ this.#scheduledBackgroundExchangeFlush = false;
6127
+ this.#flushPendingBackgroundExchanges();
6128
+ };
6129
+ setTimeout(attempt, 0);
6130
+ }
6131
+
6132
+ #flushPendingBackgroundExchanges(): void {
6133
+ if (this.#pendingBackgroundExchanges.length === 0) return;
6134
+ const batches = this.#pendingBackgroundExchanges;
6135
+ this.#pendingBackgroundExchanges = [];
6136
+ for (const batch of batches) {
6137
+ for (const msg of batch) {
6138
+ // emitExternalEvent on message_end appends to agent state and dispatches
6139
+ // to all session listeners, which in turn handle TUI rendering and
6140
+ // sessionManager persistence via #handleAgentEvent.
6141
+ this.agent.emitExternalEvent({ type: "message_start", message: msg });
6142
+ this.agent.emitExternalEvent({ type: "message_end", message: msg });
6143
+ }
6144
+ }
6145
+ }
6146
+
5927
6147
  // =========================================================================
5928
6148
  // Session Management
5929
6149
  // =========================================================================
@@ -66,6 +66,8 @@ export interface SessionHeader {
66
66
 
67
67
  export interface NewSessionOptions {
68
68
  parentSession?: string;
69
+ /** Skip flushing the current session and delete it instead of saving. */
70
+ drop?: boolean;
69
71
  }
70
72
 
71
73
  export interface SessionEntryBase {
@@ -1707,6 +1709,17 @@ export class SessionManager {
1707
1709
  return this.#newSessionSync(options);
1708
1710
  }
1709
1711
 
1712
+ /** Delete a session file and its artifacts. Drains the persist writer first to avoid EPERM on Windows. ENOENT is treated as success. */
1713
+ async dropSession(sessionPath: string): Promise<void> {
1714
+ await this.#closePersistWriter();
1715
+ try {
1716
+ await this.storage.deleteSessionWithArtifacts(sessionPath);
1717
+ } catch (err) {
1718
+ if (isEnoent(err)) return;
1719
+ throw err;
1720
+ }
1721
+ }
1722
+
1710
1723
  /**
1711
1724
  * Fork the current session, creating a new session file with the same entries.
1712
1725
  * Returns both the old and new session file paths for artifact copying.
@@ -32,6 +32,7 @@ export interface SessionStorage {
32
32
  writeText(path: string, content: string): Promise<void>;
33
33
  rename(path: string, nextPath: string): Promise<void>;
34
34
  unlink(path: string): Promise<void>;
35
+ deleteSessionWithArtifacts(sessionPath: string): Promise<void>;
35
36
  openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter;
36
37
  }
37
38
 
@@ -360,6 +361,9 @@ export class MemorySessionStorage implements SessionStorage {
360
361
  this.#files.delete(path);
361
362
  return Promise.resolve();
362
363
  }
364
+ deleteSessionWithArtifacts(_sessionPath: string): Promise<void> {
365
+ return Promise.resolve();
366
+ }
363
367
 
364
368
  openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
365
369
  return new MemorySessionStorageWriter(this, path, options);
@@ -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",
@@ -488,6 +512,14 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
488
512
  await runtime.ctx.handleClearCommand();
489
513
  },
490
514
  },
515
+ {
516
+ name: "drop",
517
+ description: "Delete the current session and start a new one",
518
+ handle: async (_command, runtime) => {
519
+ runtime.ctx.editor.setText("");
520
+ await runtime.ctx.handleDropCommand();
521
+ },
522
+ },
491
523
  {
492
524
  name: "compact",
493
525
  description: "Manually compact the session context",
@@ -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
  }