@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
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";
@@ -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);
@@ -12,6 +12,7 @@ import { checkPythonKernelAvailability } from "../ipy/kernel";
12
12
  import { LspTool } from "../lsp";
13
13
  import type { DiscoverableMCPSearchIndex, DiscoverableMCPTool } from "../mcp/discoverable-tool-metadata";
14
14
  import type { PlanModeState } from "../plan-mode/state";
15
+ import type { AgentRegistry } from "../registry/agent-registry";
15
16
  import type { CustomMessage } from "../session/messages";
16
17
  import type { ToolChoiceQueue } from "../session/tool-choice-queue";
17
18
  import { TaskTool } from "../task";
@@ -24,7 +25,6 @@ import { AstGrepTool } from "./ast-grep";
24
25
  import { BashTool } from "./bash";
25
26
  import { BrowserTool } from "./browser";
26
27
  import { CalculatorTool } from "./calculator";
27
- import { CancelJobTool } from "./cancel-job";
28
28
  import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
29
29
  import { DebugTool } from "./debug";
30
30
  import { ExitPlanModeTool } from "./exit-plan-mode";
@@ -32,9 +32,10 @@ import { FindTool } from "./find";
32
32
  import { GithubTool } from "./gh";
33
33
  import { GrepTool } from "./grep";
34
34
  import { InspectImageTool } from "./inspect-image";
35
+ import { IrcTool } from "./irc";
36
+ import { JobTool } from "./job";
35
37
  import { NotebookTool } from "./notebook";
36
38
  import { wrapToolWithMetaNotice } from "./output-meta";
37
- import { PollTool } from "./poll-tool";
38
39
  import { PythonTool } from "./python";
39
40
  import { ReadTool } from "./read";
40
41
  import { RenderMermaidTool } from "./render-mermaid";
@@ -62,7 +63,6 @@ export * from "./ast-grep";
62
63
  export * from "./bash";
63
64
  export * from "./browser";
64
65
  export * from "./calculator";
65
- export * from "./cancel-job";
66
66
  export * from "./checkpoint";
67
67
  export * from "./debug";
68
68
  export * from "./exit-plan-mode";
@@ -71,8 +71,9 @@ export * from "./gh";
71
71
  export * from "./grep";
72
72
  export * from "./image-gen";
73
73
  export * from "./inspect-image";
74
+ export * from "./irc";
75
+ export * from "./job";
74
76
  export * from "./notebook";
75
- export * from "./poll-tool";
76
77
  export * from "./python";
77
78
  export * from "./read";
78
79
  export * from "./render-mermaid";
@@ -135,6 +136,10 @@ export interface ToolSession {
135
136
  trackPythonExecution?<T>(execution: Promise<T>, abortController: AbortController): Promise<T>;
136
137
  /** Get session ID */
137
138
  getSessionId?: () => string | null;
139
+ /** Agent identity used for IRC routing. Returns the registry id (e.g. "0-Main", "0-AuthLoader"). */
140
+ getAgentId?: () => string | null;
141
+ /** Agent registry for IRC routing across live sessions. */
142
+ agentRegistry?: AgentRegistry;
138
143
  /** Get artifacts directory for artifact:// URLs */
139
144
  getArtifactsDir?: () => string | null;
140
145
  /** Allocate a new artifact path and ID for session-scoped truncated output. */
@@ -218,8 +223,8 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
218
223
  checkpoint: CheckpointTool.createIf,
219
224
  rewind: RewindTool.createIf,
220
225
  task: TaskTool.create,
221
- cancel_job: CancelJobTool.createIf,
222
- poll: PollTool.createIf,
226
+ job: JobTool.createIf,
227
+ irc: IrcTool.createIf,
223
228
  todo_write: s => new TodoWriteTool(s),
224
229
  web_search: s => new SearchTool(s),
225
230
  search_tool_bm25: SearchToolBm25Tool.createIf,
@@ -386,6 +391,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
386
391
  if (name === "calc") return session.settings.get("calc.enabled");
387
392
  if (name === "browser") return session.settings.get("browser.enabled");
388
393
  if (name === "checkpoint" || name === "rewind") return session.settings.get("checkpoint.enabled");
394
+ if (name === "irc") return session.settings.get("irc.enabled");
389
395
  if (name === "task") {
390
396
  const maxDepth = session.settings.get("task.maxRecursionDepth") ?? 2;
391
397
  const currentDepth = session.taskDepth ?? 0;
@@ -0,0 +1,258 @@
1
+ /**
2
+ * IRC tool — agent-to-agent messaging.
3
+ *
4
+ * Lets any live agent send a short prose message to any other live agent in
5
+ * this process and (optionally) get a prose reply.
6
+ *
7
+ * Routing happens via the global AgentRegistry. Replies are produced by an
8
+ * ephemeral side-channel call (`AgentSession.respondAsBackground`) that
9
+ * mirrors `/btw`: the recipient's current model, system prompt, and message
10
+ * history are used to compute a reply without persisting it through the
11
+ * normal stream path. After the reply is generated, both the incoming
12
+ * message and the auto-reply are queued for injection into the recipient's
13
+ * persisted history (deferred until the recipient is idle), so the model
14
+ * sees the exchange on its next turn.
15
+ *
16
+ * This avoids the deadlock that arises when the recipient is blocked on a
17
+ * long-running tool call: the side-channel call does not depend on the
18
+ * recipient's main agent loop being free.
19
+ */
20
+
21
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
22
+ import { prompt } from "@oh-my-pi/pi-utils";
23
+ import { type Static, Type } from "@sinclair/typebox";
24
+ import ircDescription from "../prompts/tools/irc.md" with { type: "text" };
25
+ import type { AgentRef, AgentRegistry } from "../registry/agent-registry";
26
+ import type { ToolSession } from ".";
27
+
28
+ const ircSchema = Type.Object({
29
+ op: Type.Union(
30
+ [
31
+ Type.Literal("send", { description: "Send a message to one peer or to all peers" }),
32
+ Type.Literal("list", { description: "List currently visible peers" }),
33
+ ],
34
+ { description: "IRC operation" },
35
+ ),
36
+ to: Type.Optional(
37
+ Type.String({
38
+ description: 'Recipient agent id (e.g. "0-Main", "0-AuthLoader") or "all" to broadcast',
39
+ examples: ["0-Main", "all"],
40
+ }),
41
+ ),
42
+ message: Type.Optional(
43
+ Type.String({
44
+ description: "Message body to deliver",
45
+ examples: ["Should we use JWT or session cookies?"],
46
+ }),
47
+ ),
48
+ awaitReply: Type.Optional(
49
+ Type.Boolean({
50
+ description: "Wait for the recipient's prose reply (default: true for DM, false for broadcast)",
51
+ }),
52
+ ),
53
+ });
54
+
55
+ type IrcParams = Static<typeof ircSchema>;
56
+
57
+ interface IrcReply {
58
+ from: string;
59
+ text: string;
60
+ }
61
+
62
+ export interface IrcDetails {
63
+ op: "send" | "list";
64
+ from?: string;
65
+ to?: string;
66
+ delivered?: string[];
67
+ replies?: IrcReply[];
68
+ failed?: Array<{ id: string; error: string }>;
69
+ notFound?: string[];
70
+ peers?: Array<{ id: string; displayName: string; kind: string; status: string; parentId?: string }>;
71
+ channels?: string[];
72
+ }
73
+
74
+ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
75
+ readonly name = "irc";
76
+ readonly label = "IRC";
77
+ readonly description: string;
78
+ readonly parameters = ircSchema;
79
+ readonly strict = true;
80
+
81
+ constructor(private readonly session: ToolSession) {
82
+ this.description = prompt.render(ircDescription);
83
+ }
84
+
85
+ static createIf(session: ToolSession): IrcTool | null {
86
+ if (!session.settings.get("irc.enabled")) return null;
87
+ if (!session.agentRegistry || !session.getAgentId) return null;
88
+ return new IrcTool(session);
89
+ }
90
+
91
+ async execute(
92
+ _toolCallId: string,
93
+ params: IrcParams,
94
+ signal?: AbortSignal,
95
+ _onUpdate?: AgentToolUpdateCallback<IrcDetails>,
96
+ _context?: AgentToolContext,
97
+ ): Promise<AgentToolResult<IrcDetails>> {
98
+ const registry = this.session.agentRegistry;
99
+ const senderId = this.session.getAgentId?.() ?? null;
100
+ if (!registry) {
101
+ return errorResult("IRC is unavailable in this session.", { op: params.op });
102
+ }
103
+ if (!senderId) {
104
+ return errorResult("IRC is unavailable: caller has no agent id.", { op: params.op });
105
+ }
106
+
107
+ if (params.op === "list") {
108
+ return this.#executeList(registry, senderId);
109
+ }
110
+ if (params.op === "send") {
111
+ return this.#executeSend(registry, senderId, params, signal);
112
+ }
113
+ return errorResult("Unknown irc op.", { op: params.op as "send" | "list" });
114
+ }
115
+
116
+ #executeList(registry: AgentRegistry, senderId: string): AgentToolResult<IrcDetails> {
117
+ const peers = registry.listVisibleTo(senderId);
118
+ const lines: string[] = [];
119
+ if (peers.length === 0) {
120
+ lines.push("No other live agents.");
121
+ } else {
122
+ lines.push(`${peers.length} peer(s):`);
123
+ for (const peer of peers) {
124
+ lines.push(`- ${peer.id} [${peer.displayName} · ${peer.kind} · ${peer.status}]`);
125
+ }
126
+ }
127
+ const channels = ["all", ...peers.map(p => p.id)];
128
+ return {
129
+ content: [{ type: "text", text: lines.join("\n") }],
130
+ details: {
131
+ op: "list",
132
+ from: senderId,
133
+ peers: peers.map(p => ({
134
+ id: p.id,
135
+ displayName: p.displayName,
136
+ kind: p.kind,
137
+ status: p.status,
138
+ parentId: p.parentId,
139
+ })),
140
+ channels,
141
+ },
142
+ };
143
+ }
144
+
145
+ async #executeSend(
146
+ registry: AgentRegistry,
147
+ senderId: string,
148
+ params: IrcParams,
149
+ signal?: AbortSignal,
150
+ ): Promise<AgentToolResult<IrcDetails>> {
151
+ const to = params.to?.trim();
152
+ const message = params.message?.trim();
153
+ if (!to) {
154
+ return errorResult('`to` is required for op="send".', { op: "send", from: senderId });
155
+ }
156
+ if (!message) {
157
+ return errorResult('`message` is required for op="send".', { op: "send", from: senderId });
158
+ }
159
+
160
+ // Resolve target peers.
161
+ let targets: AgentRef[];
162
+ const notFound: string[] = [];
163
+ const isBroadcast = to === "all";
164
+ if (isBroadcast) {
165
+ targets = registry.listVisibleTo(senderId);
166
+ } else {
167
+ const ref = registry.get(to);
168
+ if (!ref || ref.id === senderId) {
169
+ notFound.push(to);
170
+ targets = [];
171
+ } else if (ref.status !== "running" && ref.status !== "idle") {
172
+ notFound.push(to);
173
+ targets = [];
174
+ } else {
175
+ targets = [ref];
176
+ }
177
+ }
178
+
179
+ const awaitReply = params.awaitReply ?? !isBroadcast;
180
+
181
+ const delivered: string[] = [];
182
+ const replies: IrcReply[] = [];
183
+ const failed: Array<{ id: string; error: string }> = [];
184
+
185
+ // Dispatch to each target in parallel via the recipient's ephemeral
186
+ // side-channel. Independent calls so a slow recipient cannot stall the
187
+ // others. The recipient's main loop never has to be unblocked: the
188
+ // side-channel runs alongside any in-flight tool call.
189
+ const dispatches = targets.map(async target => {
190
+ const targetSession = target.session;
191
+ if (!targetSession) {
192
+ notFound.push(target.id);
193
+ return;
194
+ }
195
+ try {
196
+ const result = await targetSession.respondAsBackground({
197
+ from: senderId,
198
+ message,
199
+ awaitReply,
200
+ signal,
201
+ });
202
+ delivered.push(target.id);
203
+ if (awaitReply && result.replyText) {
204
+ replies.push({ from: target.id, text: result.replyText });
205
+ }
206
+ } catch (err) {
207
+ failed.push({ id: target.id, error: err instanceof Error ? err.message : String(err) });
208
+ }
209
+ });
210
+ await Promise.all(dispatches);
211
+
212
+ const lines: string[] = [];
213
+ if (delivered.length === 0) {
214
+ lines.push("No recipients received the message.");
215
+ } else {
216
+ lines.push(`Delivered to ${delivered.length} peer(s): ${delivered.join(", ")}`);
217
+ }
218
+ if (replies.length > 0) {
219
+ lines.push("");
220
+ lines.push("## Replies");
221
+ for (const reply of replies) {
222
+ lines.push(`### ${reply.from}`);
223
+ lines.push(reply.text);
224
+ }
225
+ }
226
+ if (failed.length > 0) {
227
+ lines.push("");
228
+ lines.push("## Failed");
229
+ for (const f of failed) {
230
+ lines.push(`- ${f.id}: ${f.error}`);
231
+ }
232
+ }
233
+ if (notFound.length > 0) {
234
+ lines.push("");
235
+ lines.push(`Unknown / unavailable peers: ${notFound.join(", ")}`);
236
+ }
237
+
238
+ return {
239
+ content: [{ type: "text", text: lines.join("\n") }],
240
+ details: {
241
+ op: "send",
242
+ from: senderId,
243
+ to,
244
+ delivered,
245
+ ...(replies.length > 0 ? { replies } : {}),
246
+ ...(failed.length > 0 ? { failed } : {}),
247
+ ...(notFound.length > 0 ? { notFound } : {}),
248
+ },
249
+ };
250
+ }
251
+ }
252
+
253
+ function errorResult(text: string, details: IrcDetails): AgentToolResult<IrcDetails> {
254
+ return {
255
+ content: [{ type: "text", text }],
256
+ details,
257
+ };
258
+ }