@oh-my-pi/pi-coding-agent 15.10.12 → 15.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +90 -4
  2. package/dist/cli.js +869 -825
  3. package/dist/types/async/index.d.ts +0 -1
  4. package/dist/types/capability/mcp.d.ts +1 -0
  5. package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
  6. package/dist/types/config/keybindings.d.ts +6 -1
  7. package/dist/types/config/settings-schema.d.ts +66 -34
  8. package/dist/types/export/html/template.generated.d.ts +1 -1
  9. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  10. package/dist/types/extensibility/shared-events.d.ts +2 -2
  11. package/dist/types/internal-urls/history-protocol.d.ts +14 -0
  12. package/dist/types/internal-urls/index.d.ts +1 -0
  13. package/dist/types/internal-urls/types.d.ts +1 -1
  14. package/dist/types/irc/bus.d.ts +66 -0
  15. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  16. package/dist/types/mcp/oauth-flow.d.ts +6 -1
  17. package/dist/types/mcp/transports/stdio.d.ts +1 -0
  18. package/dist/types/mcp/types.d.ts +2 -0
  19. package/dist/types/modes/components/agent-hub.d.ts +30 -0
  20. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  21. package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
  22. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  23. package/dist/types/modes/components/mcp-add-wizard.d.ts +2 -1
  24. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  25. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  26. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  27. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  28. package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
  29. package/dist/types/modes/components/welcome.d.ts +3 -9
  30. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  31. package/dist/types/modes/controllers/tool-args-reveal.d.ts +43 -0
  32. package/dist/types/modes/interactive-mode.d.ts +3 -2
  33. package/dist/types/modes/theme/theme.d.ts +3 -1
  34. package/dist/types/modes/types.d.ts +3 -2
  35. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  36. package/dist/types/registry/agent-lifecycle.d.ts +51 -0
  37. package/dist/types/registry/agent-registry.d.ts +16 -5
  38. package/dist/types/session/agent-session.d.ts +35 -30
  39. package/dist/types/session/messages.d.ts +2 -4
  40. package/dist/types/session/session-history-format.d.ts +12 -0
  41. package/dist/types/session/session-manager.d.ts +21 -3
  42. package/dist/types/session/streaming-output.d.ts +23 -0
  43. package/dist/types/task/executor.d.ts +11 -2
  44. package/dist/types/task/index.d.ts +11 -4
  45. package/dist/types/task/output-manager.d.ts +0 -7
  46. package/dist/types/task/repair-args.d.ts +8 -7
  47. package/dist/types/task/types.d.ts +55 -51
  48. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  49. package/dist/types/tools/find.d.ts +0 -11
  50. package/dist/types/tools/grouped-file-output.d.ts +0 -49
  51. package/dist/types/tools/index.d.ts +1 -3
  52. package/dist/types/tools/irc.d.ts +76 -38
  53. package/dist/types/tools/job.d.ts +7 -1
  54. package/dist/types/tools/render-utils.d.ts +22 -0
  55. package/examples/extensions/with-deps/package.json +1 -0
  56. package/package.json +11 -10
  57. package/scripts/bundle-dist.ts +28 -19
  58. package/src/async/index.ts +0 -1
  59. package/src/capability/mcp.ts +1 -0
  60. package/src/cli/gallery-cli.ts +6 -5
  61. package/src/cli/gallery-fixtures/agentic.ts +230 -115
  62. package/src/cli/gallery-fixtures/types.ts +5 -0
  63. package/src/cli.ts +20 -6
  64. package/src/commit/agentic/tools/analyze-file.ts +38 -19
  65. package/src/config/keybindings.ts +6 -1
  66. package/src/config/mcp-schema.json +4 -0
  67. package/src/config/settings-schema.ts +68 -41
  68. package/src/config/settings.ts +7 -0
  69. package/src/edit/renderer.ts +96 -46
  70. package/src/eval/__tests__/agent-bridge.test.ts +5 -3
  71. package/src/eval/agent-bridge.ts +3 -16
  72. package/src/eval/js/shared/prelude.txt +1 -1
  73. package/src/eval/py/prelude.py +5 -6
  74. package/src/export/html/template.generated.ts +1 -1
  75. package/src/export/html/template.js +44 -14
  76. package/src/extensibility/custom-tools/types.ts +2 -2
  77. package/src/extensibility/shared-events.ts +2 -2
  78. package/src/internal-urls/docs-index.generated.ts +9 -9
  79. package/src/internal-urls/history-protocol.ts +113 -0
  80. package/src/internal-urls/index.ts +1 -0
  81. package/src/internal-urls/router.ts +3 -1
  82. package/src/internal-urls/types.ts +1 -1
  83. package/src/irc/bus.ts +292 -0
  84. package/src/main.ts +8 -60
  85. package/src/mcp/manager.ts +3 -0
  86. package/src/mcp/oauth-discovery.ts +27 -2
  87. package/src/mcp/oauth-flow.ts +47 -1
  88. package/src/mcp/transports/stdio.ts +3 -0
  89. package/src/mcp/types.ts +2 -0
  90. package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
  91. package/src/modes/components/assistant-message.ts +15 -0
  92. package/src/modes/components/btw-panel.ts +5 -1
  93. package/src/modes/components/compaction-summary-message.ts +68 -32
  94. package/src/modes/components/custom-editor.ts +10 -0
  95. package/src/modes/components/mcp-add-wizard.ts +13 -0
  96. package/src/modes/components/settings-selector.ts +2 -0
  97. package/src/modes/components/status-line/component.ts +22 -12
  98. package/src/modes/components/status-line/types.ts +3 -0
  99. package/src/modes/components/tool-execution.ts +31 -1
  100. package/src/modes/components/transcript-container.ts +99 -18
  101. package/src/modes/components/tree-selector.ts +6 -1
  102. package/src/modes/components/ttsr-notification.ts +72 -30
  103. package/src/modes/components/welcome.ts +9 -33
  104. package/src/modes/controllers/event-controller.ts +93 -4
  105. package/src/modes/controllers/extension-ui-controller.ts +8 -8
  106. package/src/modes/controllers/input-controller.ts +18 -2
  107. package/src/modes/controllers/mcp-command-controller.ts +34 -2
  108. package/src/modes/controllers/selector-controller.ts +25 -17
  109. package/src/modes/controllers/tool-args-reveal.ts +174 -0
  110. package/src/modes/interactive-mode.ts +17 -15
  111. package/src/modes/theme/theme.ts +24 -5
  112. package/src/modes/types.ts +3 -5
  113. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  114. package/src/modes/utils/ui-helpers.ts +51 -49
  115. package/src/prompts/system/irc-incoming.md +3 -4
  116. package/src/prompts/system/orchestrate-notice.md +2 -2
  117. package/src/prompts/system/subagent-system-prompt.md +0 -5
  118. package/src/prompts/system/system-prompt.md +1 -0
  119. package/src/prompts/system/workflow-notice.md +2 -2
  120. package/src/prompts/tools/eval.md +3 -3
  121. package/src/prompts/tools/irc.md +29 -19
  122. package/src/prompts/tools/read.md +2 -2
  123. package/src/prompts/tools/task-summary.md +5 -16
  124. package/src/prompts/tools/task.md +43 -29
  125. package/src/registry/agent-lifecycle.ts +218 -0
  126. package/src/registry/agent-registry.ts +16 -5
  127. package/src/sdk.ts +29 -9
  128. package/src/session/agent-session.ts +268 -241
  129. package/src/session/messages.ts +11 -78
  130. package/src/session/session-history-format.ts +246 -0
  131. package/src/session/session-manager.ts +59 -5
  132. package/src/session/streaming-output.ts +60 -0
  133. package/src/task/executor.ts +855 -466
  134. package/src/task/index.ts +723 -794
  135. package/src/task/output-manager.ts +0 -11
  136. package/src/task/render.ts +142 -66
  137. package/src/task/repair-args.ts +21 -9
  138. package/src/task/types.ts +73 -66
  139. package/src/tools/ask.ts +4 -2
  140. package/src/tools/bash.ts +15 -5
  141. package/src/tools/browser/tab-worker.ts +26 -7
  142. package/src/tools/browser.ts +28 -1
  143. package/src/tools/find.ts +2 -27
  144. package/src/tools/grouped-file-output.ts +1 -118
  145. package/src/tools/index.ts +4 -12
  146. package/src/tools/irc.ts +596 -171
  147. package/src/tools/job.ts +41 -7
  148. package/src/tools/read.ts +57 -1
  149. package/src/tools/render-utils.ts +56 -0
  150. package/src/tools/renderers.ts +2 -0
  151. package/src/tools/resolve.ts +4 -1
  152. package/src/tools/write.ts +65 -47
  153. package/src/web/search/providers/anthropic.ts +29 -4
  154. package/dist/types/async/support.d.ts +0 -2
  155. package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
  156. package/dist/types/task/simple-mode.d.ts +0 -8
  157. package/src/async/support.ts +0 -5
  158. package/src/task/simple-mode.ts +0 -27
package/src/tools/irc.ts CHANGED
@@ -1,62 +1,94 @@
1
1
  /**
2
- * IRC tool — agent-to-agent messaging.
2
+ * IRC tool — agent-to-agent messaging over the process-global IrcBus.
3
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.
4
+ * `send` is fire-and-forget: the bus routes the message to the recipient
5
+ * (waking idle agents with a real turn, reviving parked ones via the
6
+ * lifecycle manager, injecting a non-interrupting aside into busy ones) and
7
+ * returns delivery receipts immediately. Replies are real turns by the
8
+ * recipient, observed with `wait` (or the `await: true` send sugar). `inbox`
9
+ * drains pending messages; `list` shows every addressable peer.
19
10
  */
20
11
 
21
12
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
22
- import { prompt } from "@oh-my-pi/pi-utils";
13
+ import { type Component, Text } from "@oh-my-pi/pi-tui";
14
+ import { formatAge, formatDuration, prompt } from "@oh-my-pi/pi-utils";
23
15
  import * as z from "zod/v4";
16
+ import type { Settings } from "../config/settings";
17
+ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
18
+ import { IrcBus, type IrcDeliveryReceipt, type IrcMessage } from "../irc/bus";
19
+ import type { Theme } from "../modes/theme/theme";
24
20
  import ircDescription from "../prompts/tools/irc.md" with { type: "text" };
25
- import type { AgentRef, AgentRegistry } from "../registry/agent-registry";
21
+ import type { AgentRegistry } from "../registry/agent-registry";
22
+ import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
26
23
  import type { ToolSession } from ".";
24
+ import {
25
+ createCachedComponent,
26
+ formatBadge,
27
+ formatErrorDetail,
28
+ getPreviewLines,
29
+ PREVIEW_LIMITS,
30
+ replaceTabs,
31
+ type ToolUIColor,
32
+ } from "./render-utils";
27
33
 
28
34
  const DEFAULT_IRC_TIMEOUT_MS = 120_000;
35
+
36
+ /**
37
+ * IRC availability: there must be someone to chat with. True for every
38
+ * subagent (it always has a parent, and possibly siblings) and for any
39
+ * session that can still spawn subagents through the task tool. Only a
40
+ * top-level session with task spawning unavailable has no peers — no irc.
41
+ */
42
+ export function isIrcEnabled(settings: Settings, taskDepth: number): boolean {
43
+ if (taskDepth > 0) return true;
44
+ const maxDepth = settings.get("task.maxRecursionDepth") ?? 2;
45
+ return maxDepth < 0 || taskDepth < maxDepth;
46
+ }
47
+
29
48
  const ircSchema = z.object({
30
- op: z.enum(["send", "list"]).describe("irc operation"),
31
- to: z.string().optional().describe('recipient agent id or "all"'),
32
- message: z.string().optional().describe("message body"),
33
- awaitReply: z.boolean().optional().describe("wait for prose reply"),
49
+ op: z.enum(["send", "wait", "inbox", "list"]).describe("irc operation"),
50
+ to: z.string().optional().describe('send: recipient agent id or "all"'),
51
+ message: z.string().optional().describe("send: message body"),
52
+ replyTo: z.string().optional().describe("send: message id being answered"),
53
+ await: z.boolean().optional().describe('send: wait for the recipient\'s reply (invalid with to:"all")'),
54
+ from: z.string().optional().describe("wait: only accept a message from this agent id"),
55
+ timeoutMs: z.number().optional().describe("wait: timeout in milliseconds (0 waits indefinitely)"),
56
+ peek: z.boolean().optional().describe("inbox: list messages without consuming them"),
34
57
  });
35
58
 
36
59
  type IrcParams = z.infer<typeof ircSchema>;
37
60
 
38
- interface IrcReply {
39
- from: string;
40
- text: string;
61
+ interface IrcPeerInfo {
62
+ id: string;
63
+ displayName: string;
64
+ kind: string;
65
+ status: string;
66
+ parentId?: string;
67
+ unread: number;
68
+ lastActivity: number;
41
69
  }
42
70
 
43
71
  export interface IrcDetails {
44
- op: "send" | "list";
72
+ op: "send" | "wait" | "inbox" | "list";
45
73
  from?: string;
46
74
  to?: string;
47
- delivered?: string[];
48
- replies?: IrcReply[];
49
- failed?: Array<{ id: string; error: string }>;
50
- notFound?: string[];
51
- peers?: Array<{ id: string; displayName: string; kind: string; status: string; parentId?: string }>;
52
- channels?: string[];
75
+ receipts?: IrcDeliveryReceipt[];
76
+ /** Message consumed by `wait` / `send await:true`; null when the wait timed out. */
77
+ waited?: IrcMessage | null;
78
+ inbox?: IrcMessage[];
79
+ peers?: IrcPeerInfo[];
80
+ }
81
+
82
+ function formatIncoming(msg: IrcMessage): string {
83
+ const replyTag = msg.replyTo ? ` (reply to ${msg.replyTo})` : "";
84
+ return `[${msg.id}] ${msg.from}${replyTag}: ${msg.body}`;
53
85
  }
54
86
 
55
87
  export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
56
88
  readonly name = "irc";
57
89
  readonly approval = "read" as const;
58
90
  readonly label = "IRC";
59
- readonly summary = "Send and receive messages between agents over IRC-like channels";
91
+ readonly summary = "Send and receive messages between agents";
60
92
  readonly description: string;
61
93
  readonly parameters = ircSchema;
62
94
  readonly strict = true;
@@ -66,7 +98,7 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
66
98
  }
67
99
 
68
100
  static createIf(session: ToolSession): IrcTool | null {
69
- if (!session.settings.get("irc.enabled")) return null;
101
+ if (!isIrcEnabled(session.settings, session.taskDepth ?? 0)) return null;
70
102
  if (!session.agentRegistry || !session.getAgentId) return null;
71
103
  return new IrcTool(session);
72
104
  }
@@ -87,41 +119,55 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
87
119
  return errorResult("IRC is unavailable: caller has no agent id.", { op: params.op });
88
120
  }
89
121
 
90
- if (params.op === "list") {
91
- return this.#executeList(registry, senderId);
92
- }
93
- if (params.op === "send") {
94
- return this.#executeSend(registry, senderId, params, signal);
122
+ switch (params.op) {
123
+ case "list":
124
+ return this.#executeList(registry, senderId);
125
+ case "send":
126
+ return this.#executeSend(registry, senderId, params, signal);
127
+ case "wait":
128
+ return this.#executeWait(senderId, params, signal);
129
+ case "inbox":
130
+ return this.#executeInbox(senderId, params);
131
+ default:
132
+ return errorResult("Unknown irc op.", { op: params.op });
95
133
  }
96
- return errorResult("Unknown irc op.", { op: params.op as "send" | "list" });
97
134
  }
98
135
 
99
136
  #executeList(registry: AgentRegistry, senderId: string): AgentToolResult<IrcDetails> {
100
- const peers = registry.listVisibleTo(senderId);
137
+ const bus = IrcBus.global();
138
+ const peers = registry
139
+ .list()
140
+ .filter(ref => ref.id !== senderId && ref.status !== "aborted")
141
+ .map(ref => ({
142
+ id: ref.id,
143
+ displayName: ref.displayName,
144
+ kind: ref.kind,
145
+ status: ref.status,
146
+ parentId: ref.parentId,
147
+ unread: bus.unreadCount(ref.id),
148
+ lastActivity: ref.lastActivity,
149
+ }));
101
150
  const lines: string[] = [];
102
151
  if (peers.length === 0) {
103
- lines.push("No other live agents.");
152
+ lines.push("No other agents.");
104
153
  } else {
105
154
  lines.push(`${peers.length} peer(s):`);
106
155
  for (const peer of peers) {
107
- lines.push(`- ${peer.id} [${peer.displayName} · ${peer.kind} · ${peer.status}]`);
156
+ const extras = [
157
+ peer.unread > 0 ? `unread ${peer.unread}` : undefined,
158
+ peer.parentId ? `parent ${peer.parentId}` : undefined,
159
+ `active ${formatDuration(Date.now() - peer.lastActivity)} ago`,
160
+ ].filter(Boolean);
161
+ lines.push(`- ${peer.id} [${peer.displayName} · ${peer.kind} · ${peer.status}] — ${extras.join(", ")}`);
162
+ }
163
+ if (peers.some(peer => peer.status === "parked")) {
164
+ lines.push("");
165
+ lines.push("Parked agents are revived automatically when you message them.");
108
166
  }
109
167
  }
110
- const channels = ["all", ...peers.map(p => p.id)];
111
168
  return {
112
169
  content: [{ type: "text", text: lines.join("\n") }],
113
- details: {
114
- op: "list",
115
- from: senderId,
116
- peers: peers.map(p => ({
117
- id: p.id,
118
- displayName: p.displayName,
119
- kind: p.kind,
120
- status: p.status,
121
- parentId: p.parentId,
122
- })),
123
- channels,
124
- },
170
+ details: { op: "list", from: senderId, peers },
125
171
  };
126
172
  }
127
173
 
@@ -139,105 +185,153 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
139
185
  if (!message) {
140
186
  return errorResult('`message` is required for op="send".', { op: "send", from: senderId });
141
187
  }
142
-
143
- // Resolve target peers.
144
- let targets: AgentRef[];
145
- const notFound: string[] = [];
188
+ if (to === senderId) {
189
+ return errorResult("Cannot send an IRC message to yourself.", { op: "send", from: senderId, to });
190
+ }
146
191
  const isBroadcast = to === "all";
147
- if (isBroadcast) {
148
- targets = registry.listVisibleTo(senderId);
149
- } else {
150
- const ref = registry.get(to);
151
- if (!ref || ref.id === senderId) {
152
- notFound.push(to);
153
- targets = [];
154
- } else if (ref.status !== "running" && ref.status !== "idle") {
155
- notFound.push(to);
156
- targets = [];
192
+ if (isBroadcast && params.await) {
193
+ return errorResult('`await` is invalid with to:"all" — broadcasts have no single replier.', {
194
+ op: "send",
195
+ from: senderId,
196
+ to,
197
+ });
198
+ }
199
+
200
+ const bus = IrcBus.global();
201
+ let waited: IrcMessage | null | undefined;
202
+ const timeoutMs = params.await ? this.#resolveTimeoutMs(params) : undefined;
203
+ const awaitAbort = params.await ? new AbortController() : undefined;
204
+ const awaitCancelled = new Error("IRC await cancelled");
205
+ let removeAwaitAbortListener: (() => void) | undefined;
206
+ const waiting = params.await
207
+ ? bus
208
+ .wait(senderId, { from: to }, timeoutMs ?? DEFAULT_IRC_TIMEOUT_MS, awaitAbort?.signal, {
209
+ drainPending: false,
210
+ })
211
+ .then(
212
+ message => ({ message, error: null as Error | null }),
213
+ error => ({
214
+ message: null,
215
+ error: error === awaitCancelled ? null : error instanceof Error ? error : new Error(String(error)),
216
+ }),
217
+ )
218
+ : undefined;
219
+ if (params.await && signal && awaitAbort) {
220
+ if (signal.aborted) {
221
+ awaitAbort.abort(signal.reason instanceof Error ? signal.reason : new Error("IRC wait aborted"));
157
222
  } else {
158
- targets = [ref];
223
+ const onAbort = (): void => {
224
+ awaitAbort.abort(signal.reason instanceof Error ? signal.reason : new Error("IRC wait aborted"));
225
+ };
226
+ signal.addEventListener("abort", onAbort, { once: true });
227
+ removeAwaitAbortListener = () => signal.removeEventListener("abort", onAbort);
159
228
  }
160
229
  }
161
230
 
162
- const awaitReply = params.awaitReply ?? !isBroadcast;
163
-
164
- const timeoutMs = normalizeIrcTimeoutMs(this.session.settings.get("irc.timeoutMs"));
165
- const delivered: string[] = [];
166
- const replies: IrcReply[] = [];
167
- const failed: Array<{ id: string; error: string }> = [];
168
-
169
- // Dispatch to each target in parallel via the recipient's ephemeral
170
- // side-channel. Independent calls so a slow recipient cannot stall the
171
- // others. The recipient's main loop never has to be unblocked: the
172
- // side-channel runs alongside any in-flight tool call.
173
- const dispatches = targets.map(async target => {
174
- const targetSession = target.session;
175
- if (!targetSession) {
176
- notFound.push(target.id);
177
- return;
231
+ try {
232
+ // Broadcasts fan out to live peers only (running | idle); reviving every
233
+ // parked agent on a broadcast would be a stampede. Direct sends go
234
+ // through the bus unfiltered so parked recipients are revived.
235
+ const targets = isBroadcast ? registry.listVisibleTo(senderId).map(ref => ref.id) : [to];
236
+ const receipts = await Promise.all(
237
+ targets.map(target => bus.send({ from: senderId, to: target, body: message, replyTo: params.replyTo })),
238
+ );
239
+
240
+ const lines: string[] = [];
241
+ const delivered = receipts.filter(receipt => receipt.outcome !== "failed");
242
+ if (targets.length === 0) {
243
+ lines.push("No live peers to broadcast to.");
244
+ } else if (delivered.length === 0) {
245
+ lines.push("No recipients received the message.");
246
+ } else {
247
+ lines.push(`Delivered to ${delivered.length} peer(s):`);
178
248
  }
179
- try {
180
- const result = await runIrcDispatchWithTimeout(
181
- timeoutMs,
182
- signal,
183
- timeoutSignal =>
184
- targetSession.respondAsBackground({
185
- from: senderId,
186
- message,
187
- awaitReply,
188
- signal: timeoutSignal,
189
- }),
190
- target.id,
249
+ for (const receipt of receipts) {
250
+ lines.push(
251
+ receipt.outcome === "failed"
252
+ ? `- ${receipt.to}: failed — ${receipt.error ?? "unknown error"}`
253
+ : `- ${receipt.to}: ${receipt.outcome}`,
191
254
  );
192
- delivered.push(target.id);
193
- if (awaitReply && result.replyText) {
194
- replies.push({ from: target.id, text: result.replyText });
195
- }
196
- } catch (err) {
197
- failed.push({ id: target.id, error: err instanceof Error ? err.message : String(err) });
198
255
  }
199
- });
200
- await Promise.all(dispatches);
201
256
 
202
- const lines: string[] = [];
203
- if (delivered.length === 0) {
204
- lines.push("No recipients received the message.");
205
- } else {
206
- lines.push(`Delivered to ${delivered.length} peer(s): ${delivered.join(", ")}`);
207
- }
208
- if (replies.length > 0) {
209
- lines.push("");
210
- lines.push("## Replies");
211
- for (const reply of replies) {
212
- lines.push(`### ${reply.from}`);
213
- lines.push(reply.text);
214
- }
215
- }
216
- if (failed.length > 0) {
217
- lines.push("");
218
- lines.push("## Failed");
219
- for (const f of failed) {
220
- lines.push(`- ${f.id}: ${f.error}`);
257
+ if (params.await && waiting && timeoutMs !== undefined) {
258
+ lines.push("");
259
+ if (delivered.length > 0) {
260
+ const reply = await waiting;
261
+ if (reply.error) throw reply.error;
262
+ waited = reply.message;
263
+ if (waited) {
264
+ lines.push(`Reply from ${waited.from}:`);
265
+ lines.push(waited.body);
266
+ } else {
267
+ lines.push(
268
+ `No reply from ${to} within ${formatDuration(timeoutMs)}. ` +
269
+ "They may answer later — check `inbox` or `wait` again.",
270
+ );
271
+ }
272
+ } else {
273
+ awaitAbort?.abort(awaitCancelled);
274
+ const reply = await waiting;
275
+ if (reply.error) throw reply.error;
276
+ }
221
277
  }
278
+
279
+ return {
280
+ content: [{ type: "text", text: lines.join("\n") }],
281
+ details: {
282
+ op: "send",
283
+ from: senderId,
284
+ to,
285
+ receipts,
286
+ ...(waited !== undefined ? { waited } : {}),
287
+ },
288
+ isError: delivered.length === 0 && targets.length > 0,
289
+ };
290
+ } finally {
291
+ awaitAbort?.abort(awaitCancelled);
292
+ removeAwaitAbortListener?.();
222
293
  }
223
- if (notFound.length > 0) {
224
- lines.push("");
225
- lines.push(`Unknown / unavailable peers: ${notFound.join(", ")}`);
294
+ }
295
+
296
+ async #executeWait(senderId: string, params: IrcParams, signal?: AbortSignal): Promise<AgentToolResult<IrcDetails>> {
297
+ const from = params.from?.trim() || undefined;
298
+ const timeoutMs = this.#resolveTimeoutMs(params);
299
+ const waited = await IrcBus.global().wait(senderId, { from }, timeoutMs, signal);
300
+ if (!waited) {
301
+ const filterNote = from ? ` from ${from}` : "";
302
+ return {
303
+ content: [{ type: "text", text: `No message${filterNote} within ${formatDuration(timeoutMs)}.` }],
304
+ details: { op: "wait", from: senderId, waited: null },
305
+ };
226
306
  }
307
+ return {
308
+ content: [{ type: "text", text: formatIncoming(waited) }],
309
+ details: { op: "wait", from: senderId, waited },
310
+ };
311
+ }
227
312
 
313
+ #executeInbox(senderId: string, params: IrcParams): AgentToolResult<IrcDetails> {
314
+ const messages = IrcBus.global().inbox(senderId, { peek: params.peek });
315
+ if (messages.length === 0) {
316
+ return {
317
+ content: [{ type: "text", text: "Inbox empty." }],
318
+ details: { op: "inbox", from: senderId, inbox: [] },
319
+ };
320
+ }
321
+ const header = params.peek ? `${messages.length} unread message(s):` : `${messages.length} message(s):`;
322
+ const lines = [header, ...messages.map(msg => `- ${formatIncoming(msg)}`)];
228
323
  return {
229
324
  content: [{ type: "text", text: lines.join("\n") }],
230
- details: {
231
- op: "send",
232
- from: senderId,
233
- to,
234
- delivered,
235
- ...(replies.length > 0 ? { replies } : {}),
236
- ...(failed.length > 0 ? { failed } : {}),
237
- ...(notFound.length > 0 ? { notFound } : {}),
238
- },
325
+ details: { op: "inbox", from: senderId, inbox: messages },
239
326
  };
240
327
  }
328
+
329
+ #resolveTimeoutMs(params: IrcParams): number {
330
+ if (params.timeoutMs !== undefined) {
331
+ return normalizeIrcTimeoutMs(params.timeoutMs);
332
+ }
333
+ return normalizeIrcTimeoutMs(this.session.settings.get("irc.timeoutMs"));
334
+ }
241
335
  }
242
336
 
243
337
  function errorResult(text: string, details: IrcDetails): AgentToolResult<IrcDetails> {
@@ -256,43 +350,374 @@ function normalizeIrcTimeoutMs(value: number): number {
256
350
  return Math.max(1, Math.trunc(value));
257
351
  }
258
352
 
259
- async function runIrcDispatchWithTimeout<T>(
260
- timeoutMs: number,
261
- parentSignal: AbortSignal | undefined,
262
- run: (signal?: AbortSignal) => Promise<T>,
263
- targetId: string,
264
- ): Promise<T> {
265
- if (timeoutMs <= 0) {
266
- return await run(parentSignal);
353
+ // =============================================================================
354
+ // TUI Renderer
355
+ // =============================================================================
356
+
357
+ type IrcRenderArgs = Partial<IrcParams>;
358
+
359
+ const BODY_LINES_COLLAPSED = 2;
360
+ const BODY_LINES_EXPANDED = 12;
361
+ const BODY_LINE_WIDTH = 100;
362
+
363
+ const PEER_STATUS_ORDER: Record<string, number> = { running: 0, idle: 1, parked: 2 };
364
+
365
+ function ircGlyph(theme: Theme): string {
366
+ return theme.styledSymbol("tool.irc", "accent");
367
+ }
368
+
369
+ function outcomeColor(outcome: IrcDeliveryReceipt["outcome"]): ToolUIColor {
370
+ switch (outcome) {
371
+ case "woken":
372
+ return "success";
373
+ case "revived":
374
+ return "warning";
375
+ case "injected":
376
+ return "accent";
377
+ case "failed":
378
+ return "error";
267
379
  }
380
+ }
268
381
 
269
- const controller = new AbortController();
270
- const timeoutError = new Error(`IRC timed out waiting for ${targetId} after ${timeoutMs} ms`);
271
- let timeout: NodeJS.Timeout | undefined;
272
- let parentAbortListener: (() => void) | undefined;
382
+ /** Glyph + status word, matching the agent-hub status conventions. */
383
+ function peerStatusBadge(status: string, theme: Theme): string {
384
+ switch (status) {
385
+ case "running":
386
+ return theme.fg("accent", `${theme.status.running} running`);
387
+ case "idle":
388
+ return theme.fg("success", `${theme.status.enabled} idle`);
389
+ case "parked":
390
+ return theme.fg("muted", `${theme.status.shadowed} parked`);
391
+ default:
392
+ return theme.fg("error", `${theme.status.aborted} ${status}`);
393
+ }
394
+ }
273
395
 
274
- const timeoutDeferred = Promise.withResolvers<never>();
275
- if (parentSignal) {
276
- if (parentSignal.aborted) {
277
- throw parentSignal.reason instanceof Error ? parentSignal.reason : new Error("IRC aborted");
278
- }
279
- parentAbortListener = () => {
280
- controller.abort(parentSignal.reason);
281
- timeoutDeferred.reject(parentSignal.reason instanceof Error ? parentSignal.reason : new Error("IRC aborted"));
282
- };
283
- parentSignal.addEventListener("abort", parentAbortListener, { once: true });
396
+ function messageAge(ts: number | undefined): string {
397
+ if (!ts) return "";
398
+ return formatAge(Math.max(1, Math.round((Date.now() - ts) / 1000)));
399
+ }
400
+
401
+ function textContent(result: { content: Array<{ type: string; text?: string }> }): string {
402
+ return result.content.find(part => part.type === "text")?.text?.trim() ?? "";
403
+ }
404
+
405
+ /**
406
+ * Quote-bordered message body preview. `tone` separates outbound text (dim)
407
+ * from received text (toolOutput); a trailing dim counter marks elided lines.
408
+ */
409
+ function bodyLines(
410
+ body: string,
411
+ expanded: boolean,
412
+ theme: Theme,
413
+ options: { indent?: string; tone?: "dim" | "toolOutput"; collapsedLines?: number } = {},
414
+ ): string[] {
415
+ const indent = options.indent ?? "";
416
+ const tone = options.tone ?? "toolOutput";
417
+ const max = expanded ? BODY_LINES_EXPANDED : (options.collapsedLines ?? BODY_LINES_COLLAPSED);
418
+ const total = body.split("\n").filter(line => line.trim()).length;
419
+ const quote = theme.fg("dim", theme.md.quoteBorder);
420
+ const lines = getPreviewLines(body, max, BODY_LINE_WIDTH, Ellipsis.Unicode).map(
421
+ line => `${indent}${quote} ${theme.fg(tone, replaceTabs(line))}`,
422
+ );
423
+ const hidden = total - Math.min(total, max);
424
+ if (hidden > 0) {
425
+ lines.push(`${indent}${quote} ${theme.fg("dim", `… +${hidden} more ${hidden === 1 ? "line" : "lines"}`)}`);
426
+ }
427
+ return lines;
428
+ }
429
+
430
+ /** Header title carrying the op direction: `IRC ➤ peer` out, `IRC ⟵ peer` in. */
431
+ function callTitle(args: IrcRenderArgs | undefined, theme: Theme): string {
432
+ switch (args?.op) {
433
+ case "send":
434
+ return `IRC ${theme.nav.selected} ${args.to?.trim() || "…"}`;
435
+ case "wait":
436
+ return `IRC ${theme.nav.back} ${args.from?.trim() || "anyone"}`;
437
+ case "inbox":
438
+ return "IRC inbox";
439
+ case "list":
440
+ return "IRC peers";
441
+ default:
442
+ return "IRC";
443
+ }
444
+ }
445
+
446
+ function callMeta(args: IrcRenderArgs | undefined): string[] {
447
+ const meta: string[] = [];
448
+ if (args?.op === "send") {
449
+ if (args.to === "all") meta.push("broadcast");
450
+ if (args.await) meta.push("await reply");
451
+ if (args.replyTo) meta.push("reply");
452
+ }
453
+ if (args?.op === "wait" && args.timeoutMs) meta.push(`timeout ${formatDuration(args.timeoutMs)}`);
454
+ if (args?.op === "inbox" && args.peek) meta.push("peek");
455
+ return meta;
456
+ }
457
+
458
+ /**
459
+ * Display-only transcript card for live IRC traffic: `irc:incoming` DMs
460
+ * delivered to this session and `irc:relay` observations of agent↔agent
461
+ * traffic. Shares the tool renderer's glyph + quote-border conventions so
462
+ * cards and `irc` tool output look identical in the transcript.
463
+ */
464
+ export function createIrcMessageCard(
465
+ card: {
466
+ kind: "incoming" | "relay";
467
+ from?: string;
468
+ to?: string;
469
+ body?: string;
470
+ replyTo?: string;
471
+ timestamp?: number;
472
+ },
473
+ getExpanded: () => boolean,
474
+ uiTheme: Theme,
475
+ ): Component {
476
+ const from = card.from?.trim() || "?";
477
+ const title =
478
+ card.kind === "incoming"
479
+ ? `IRC ${uiTheme.nav.back} ${from}`
480
+ : `IRC ${from} ${uiTheme.nav.selected} ${card.to?.trim() || "?"}`;
481
+ const body = card.body ?? "";
482
+ const meta: string[] = [];
483
+ if (card.replyTo) meta.push("reply");
484
+ const age = messageAge(card.timestamp);
485
+ if (age) meta.push(age);
486
+ return createCachedComponent(
487
+ getExpanded,
488
+ (width, expanded) => {
489
+ const lines = [renderStatusLine({ iconOverride: ircGlyph(uiTheme), title, meta }, uiTheme)];
490
+ if (body.trim()) {
491
+ lines.push(...bodyLines(body, expanded, uiTheme, { indent: " ", collapsedLines: 3 }));
492
+ }
493
+ return lines.map(line => truncateToWidth(line, width, Ellipsis.Unicode));
494
+ },
495
+ { paddingX: 1 },
496
+ );
497
+ }
498
+
499
+ function renderSendResult(
500
+ result: { content: Array<{ type: string; text?: string }>; isError?: boolean },
501
+ details: Partial<IrcDetails>,
502
+ args: IrcRenderArgs | undefined,
503
+ expanded: boolean,
504
+ theme: Theme,
505
+ ): string[] {
506
+ const receipts = details.receipts ?? [];
507
+ const to = details.to ?? args?.to?.trim() ?? "?";
508
+ const title = `IRC ${theme.nav.selected} ${to}`;
509
+
510
+ // Pre-delivery failures (validation) and empty broadcasts carry no receipts.
511
+ if (receipts.length === 0) {
512
+ const text = textContent(result) || (result.isError ? "Send failed." : "Nothing to deliver.");
513
+ return [
514
+ renderStatusLine({ icon: result.isError ? "error" : "warning", title }, theme),
515
+ result.isError ? formatErrorDetail(text, theme) : ` ${theme.fg("muted", replaceTabs(text))}`,
516
+ ];
284
517
  }
285
518
 
286
- timeout = setTimeout(() => {
287
- controller.abort(timeoutError);
288
- timeoutDeferred.reject(timeoutError);
289
- }, timeoutMs);
290
- timeout.unref?.();
291
-
292
- try {
293
- return await Promise.race([run(controller.signal), timeoutDeferred.promise]);
294
- } finally {
295
- if (timeout) clearTimeout(timeout);
296
- if (parentSignal && parentAbortListener) parentSignal.removeEventListener("abort", parentAbortListener);
519
+ const delivered = receipts.filter(receipt => receipt.outcome !== "failed");
520
+ const failedCount = receipts.length - delivered.length;
521
+ const waited = details.waited;
522
+ const timedOut = waited === null;
523
+
524
+ const meta: string[] = [];
525
+ if (to === "all") meta.push("broadcast");
526
+ if (receipts.length === 1) {
527
+ const receipt = receipts[0]!;
528
+ meta.push(theme.fg(outcomeColor(receipt.outcome), receipt.outcome));
529
+ } else {
530
+ if (delivered.length > 0) meta.push(theme.fg("success", `${delivered.length} delivered`));
531
+ if (failedCount > 0) meta.push(theme.fg("error", `${failedCount} failed`));
532
+ }
533
+ if (timedOut) meta.push(theme.fg("warning", "no reply"));
534
+
535
+ const icon = result.isError
536
+ ? { icon: "error" as const }
537
+ : timedOut
538
+ ? { icon: "warning" as const }
539
+ : { iconOverride: ircGlyph(theme) };
540
+ const lines = [renderStatusLine({ ...icon, title, meta }, theme)];
541
+
542
+ const sent = args?.message?.trim();
543
+ if (sent) lines.push(...bodyLines(sent, expanded, theme, { indent: " ", tone: "dim" }));
544
+
545
+ if (receipts.length > 1 || failedCount > 0) {
546
+ lines.push(
547
+ ...renderTreeList<IrcDeliveryReceipt>(
548
+ {
549
+ items: receipts,
550
+ expanded,
551
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
552
+ itemType: "recipient",
553
+ renderItem: receipt => {
554
+ const badge = formatBadge(receipt.outcome, outcomeColor(receipt.outcome), theme);
555
+ const error =
556
+ receipt.outcome === "failed" && receipt.error
557
+ ? ` ${theme.fg("error", `${theme.format.dash} ${receipt.error}`)}`
558
+ : "";
559
+ return `${theme.fg("toolOutput", receipt.to)} ${badge}${error}`;
560
+ },
561
+ },
562
+ theme,
563
+ ),
564
+ );
565
+ }
566
+
567
+ if (waited) {
568
+ const age = messageAge(waited.ts);
569
+ lines.push(
570
+ ` ${theme.fg("dim", theme.nav.back)} ${theme.fg("accent", waited.from)}${age ? ` ${theme.fg("dim", age)}` : ""}`,
571
+ );
572
+ lines.push(...bodyLines(waited.body, expanded, theme, { indent: " " }));
573
+ } else if (timedOut) {
574
+ lines.push(` ${theme.fg("warning", "No reply yet — they may answer later; check inbox or wait again.")}`);
575
+ }
576
+ return lines;
577
+ }
578
+
579
+ function renderWaitResult(
580
+ result: { content: Array<{ type: string; text?: string }>; isError?: boolean },
581
+ details: Partial<IrcDetails>,
582
+ args: IrcRenderArgs | undefined,
583
+ expanded: boolean,
584
+ theme: Theme,
585
+ ): string[] {
586
+ const waited = details.waited;
587
+ if (!waited) {
588
+ const text = textContent(result) || "No message arrived.";
589
+ return [
590
+ renderStatusLine(
591
+ { icon: "warning", title: `IRC ${theme.nav.back} ${args?.from?.trim() || "anyone"}`, meta: ["timed out"] },
592
+ theme,
593
+ ),
594
+ ` ${theme.fg("muted", replaceTabs(text))}`,
595
+ ];
596
+ }
597
+ const meta = [messageAge(waited.ts)];
598
+ if (waited.replyTo) meta.push("reply");
599
+ return [
600
+ renderStatusLine({ iconOverride: ircGlyph(theme), title: `IRC ${theme.nav.back} ${waited.from}`, meta }, theme),
601
+ ...bodyLines(waited.body, expanded, theme, { indent: " " }),
602
+ ];
603
+ }
604
+
605
+ function renderInboxResult(
606
+ details: Partial<IrcDetails>,
607
+ args: IrcRenderArgs | undefined,
608
+ expanded: boolean,
609
+ theme: Theme,
610
+ ): string[] {
611
+ const messages = details.inbox ?? [];
612
+ if (messages.length === 0) {
613
+ return [renderStatusLine({ iconOverride: ircGlyph(theme), title: "IRC inbox", meta: ["empty"] }, theme)];
297
614
  }
615
+ const meta = [`${messages.length} ${messages.length === 1 ? "message" : "messages"}`];
616
+ if (args?.peek) meta.push("peek");
617
+ const header = renderStatusLine({ iconOverride: ircGlyph(theme), title: "IRC inbox", meta }, theme);
618
+ const items = renderTreeList<IrcMessage>(
619
+ {
620
+ items: messages,
621
+ expanded,
622
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
623
+ itemType: "message",
624
+ renderItem: msg => {
625
+ const age = messageAge(msg.ts);
626
+ const replyBadge = msg.replyTo ? ` ${formatBadge("reply", "muted", theme)}` : "";
627
+ const head = `${theme.fg("accent", msg.from)}${age ? ` ${theme.fg("dim", age)}` : ""}${replyBadge}`;
628
+ return [head, ...bodyLines(msg.body, expanded, theme, { collapsedLines: 1 })];
629
+ },
630
+ },
631
+ theme,
632
+ );
633
+ return [header, ...items];
298
634
  }
635
+
636
+ function renderListResult(details: Partial<IrcDetails>, expanded: boolean, theme: Theme): string[] {
637
+ const peers = [...(details.peers ?? [])].sort(
638
+ (a, b) =>
639
+ (PEER_STATUS_ORDER[a.status] ?? 9) - (PEER_STATUS_ORDER[b.status] ?? 9) || b.lastActivity - a.lastActivity,
640
+ );
641
+ if (peers.length === 0) {
642
+ return [renderStatusLine({ icon: "info", title: "IRC peers", meta: ["no other agents"] }, theme)];
643
+ }
644
+ const counts = new Map<string, number>();
645
+ for (const peer of peers) counts.set(peer.status, (counts.get(peer.status) ?? 0) + 1);
646
+ const meta = [...counts].map(([status, count]) => `${count} ${status}`);
647
+ const unreadTotal = peers.reduce((sum, peer) => sum + peer.unread, 0);
648
+ if (unreadTotal > 0) meta.push(theme.fg("warning", `${unreadTotal} unread`));
649
+ const header = renderStatusLine({ iconOverride: ircGlyph(theme), title: "IRC peers", meta }, theme);
650
+ const items = renderTreeList(
651
+ {
652
+ items: peers,
653
+ expanded,
654
+ maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
655
+ itemType: "peer",
656
+ renderItem: peer => {
657
+ const kindText = peer.parentId ? `${peer.kind}${theme.sep.dot}of ${peer.parentId}` : peer.kind;
658
+ const unread = peer.unread > 0 ? ` ${formatBadge(`${peer.unread} unread`, "warning", theme)}` : "";
659
+ const age = messageAge(peer.lastActivity);
660
+ return `${peerStatusBadge(peer.status, theme)} ${theme.bold(replaceTabs(peer.id))} ${theme.fg("dim", kindText)}${unread}${age ? ` ${theme.fg("dim", age)}` : ""}`;
661
+ },
662
+ },
663
+ theme,
664
+ );
665
+ return [header, ...items];
666
+ }
667
+
668
+ function buildResultLines(
669
+ result: { content: Array<{ type: string; text?: string }>; isError?: boolean },
670
+ details: Partial<IrcDetails>,
671
+ args: IrcRenderArgs | undefined,
672
+ expanded: boolean,
673
+ theme: Theme,
674
+ ): string[] {
675
+ switch (details.op ?? args?.op) {
676
+ case "send":
677
+ return renderSendResult(result, details, args, expanded, theme);
678
+ case "wait":
679
+ return renderWaitResult(result, details, args, expanded, theme);
680
+ case "inbox":
681
+ return renderInboxResult(details, args, expanded, theme);
682
+ case "list":
683
+ return renderListResult(details, expanded, theme);
684
+ default: {
685
+ const text = textContent(result) || (result.isError ? "IRC call failed." : "Done.");
686
+ return [
687
+ renderStatusLine({ icon: result.isError ? "error" : "success", title: callTitle(args, theme) }, theme),
688
+ result.isError ? formatErrorDetail(text, theme) : ` ${theme.fg("muted", replaceTabs(text))}`,
689
+ ];
690
+ }
691
+ }
692
+ }
693
+
694
+ export const ircToolRenderer = {
695
+ inline: true,
696
+ mergeCallAndResult: true,
697
+
698
+ renderCall(args: IrcRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
699
+ const lines = [
700
+ renderStatusLine({ icon: "pending", title: callTitle(args, uiTheme), meta: callMeta(args) }, uiTheme),
701
+ ];
702
+ if (args?.op === "send" && args.message?.trim()) {
703
+ lines.push(...bodyLines(args.message, false, uiTheme, { indent: " ", tone: "dim", collapsedLines: 1 }));
704
+ }
705
+ return new Text(lines.join("\n"), 0, 0);
706
+ },
707
+
708
+ renderResult(
709
+ result: { content: Array<{ type: string; text?: string }>; details?: IrcDetails; isError?: boolean },
710
+ options: RenderResultOptions,
711
+ uiTheme: Theme,
712
+ args?: IrcRenderArgs,
713
+ ): Component {
714
+ const details: Partial<IrcDetails> = result.details ?? {};
715
+ return createCachedComponent(
716
+ () => options.expanded,
717
+ (width, expanded) =>
718
+ buildResultLines(result, details, args, expanded, uiTheme).map(line =>
719
+ truncateToWidth(line, width, Ellipsis.Unicode),
720
+ ),
721
+ );
722
+ },
723
+ };