diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6af9b842c..92d6a3a06 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added IRC relay observation in the main agent UI so every IRC exchange between agents is rendered in the main transcript, even when the main agent is not a direct participant + ## [14.5.3] - 2026-04-27 ### Added diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index e0014f150..1d95c22d4 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -127,14 +127,38 @@ export class UiHelpers { this.ctx.chatContainer.addChild(component); break; } - if (message.customType === "irc:incoming" || message.customType === "irc:autoreply") { + if ( + message.customType === "irc:incoming" || + message.customType === "irc:autoreply" || + message.customType === "irc:relay" + ) { const details = ( - message as CustomMessage<{ from?: string; to?: string; message?: string; reply?: string }> + message as CustomMessage<{ + from?: string; + to?: string; + message?: string; + reply?: string; + body?: string; + kind?: "message" | "reply"; + }> ).details; - const isIncoming = message.customType === "irc:incoming"; - const peer = isIncoming ? (details?.from ?? "?") : (details?.to ?? "?"); - const body = isIncoming ? (details?.message ?? "") : (details?.reply ?? ""); - const arrow = isIncoming ? `\u21e6 ${peer}` : `\u21e8 ${peer} (auto)`; + let arrow: string; + let body: string; + if (message.customType === "irc:incoming") { + const peer = details?.from ?? "?"; + body = details?.message ?? ""; + arrow = `\u21e6 ${peer}`; + } else if (message.customType === "irc:autoreply") { + const peer = details?.to ?? "?"; + body = details?.reply ?? ""; + arrow = `\u21e8 ${peer} (auto)`; + } else { + const from = details?.from ?? "?"; + const to = details?.to ?? "?"; + body = details?.body ?? ""; + const suffix = details?.kind === "reply" ? " (auto)" : ""; + arrow = `${from} \u21e8 ${to}${suffix}`; + } const header = `${theme.fg("accent", `[IRC] ${arrow}`)}`; this.ctx.chatContainer.addChild(new Text(header, 1, 0)); if (body) { diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index d0b669596..d0ef2fd8c 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1601,6 +1601,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} ttsrManager, obfuscator, asyncJobManager, + agentId: resolvedAgentId, + agentRegistry, }); hasSession = true; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 11c5105cb..037068334 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -130,6 +130,7 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool type: "text", }; import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" }; +import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry"; import { deobfuscateSessionContext, type SecretObfuscator } from "../secrets/obfuscator"; import { resolveThinkingLevelForModel, toReasoningEffort } from "../thinking"; import { assertEditableFile } from "../tools/auto-generated-guard"; @@ -263,6 +264,10 @@ export interface AgentSessionConfig { obfuscator?: SecretObfuscator; /** Logical owner for retained Python kernels created by this session. */ pythonKernelOwnerId?: string; + /** Agent identity (registry id like "0-Main" or "3-Alice") used for IRC routing. */ + agentId?: string; + /** Shared agent registry (for forwarding IRC observations to the main session UI). */ + agentRegistry?: AgentRegistry; } /** Options for AgentSession.prompt() */ @@ -478,6 +483,9 @@ export class AgentSession { // Drained into history (via emitExternalEvent) once the recipient becomes idle. #pendingBackgroundExchanges: CustomMessage[][] = []; #scheduledBackgroundExchangeFlush = false; + // Agent identity + registry for IRC relay forwarding to the main session UI. + #agentId: string | undefined; + #agentRegistry: AgentRegistry | undefined; // Extension system #extensionRunner: ExtensionRunner | undefined = undefined; #turnIndex = 0; @@ -611,6 +619,8 @@ export class AgentSession { ); this.#ttsrManager = config.ttsrManager; this.#obfuscator = config.obfuscator; + this.#agentId = config.agentId; + this.#agentRegistry = config.agentRegistry; this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => { const event: AgentEvent = { type: "message_update", @@ -5999,6 +6009,13 @@ export class AgentSession { timestamp: incomingTimestamp, }; void this.#emitSessionEvent({ type: "irc_message", message: incomingRecord }); + this.#forwardIrcRelayToMain({ + from: args.from, + to: this.#agentId ?? "?", + body: args.message, + kind: "message", + timestamp: incomingTimestamp, + }); if (!awaitReply) { this.#queueBackgroundExchangeInjection([incomingRecord]); @@ -6024,11 +6041,59 @@ export class AgentSession { timestamp: Date.now(), }; void this.#emitSessionEvent({ type: "irc_message", message: replyRecord }); + this.#forwardIrcRelayToMain({ + from: this.#agentId ?? "?", + to: args.from, + body: replyText, + kind: "reply", + timestamp: replyRecord.timestamp, + }); this.#queueBackgroundExchangeInjection([incomingRecord, replyRecord]); return { replyText }; } + /** + * Forward an IRC exchange observation to the main agent's session UI so the + * user can see every IRC conversation in the main transcript, even when the + * main agent is not a direct participant. The relay record is display-only: + * it is NOT injected into the main agent's persisted history. + */ + #forwardIrcRelayToMain(args: { + from: string; + to: string; + body: string; + kind: "message" | "reply"; + timestamp: number; + }): void { + const registry = this.#agentRegistry; + if (!registry) return; + // If this session is the main agent, the local emit already reached the main UI. + if (this.#agentId === MAIN_AGENT_ID) return; + const mainRef = registry.get(MAIN_AGENT_ID); + const mainSession = mainRef?.session; + if (!mainSession || mainSession === this) return; + const arrow = args.kind === "reply" ? "\u2192 (auto)" : "\u2192"; + const relayRecord: CustomMessage = { + role: "custom", + customType: "irc:relay", + content: `[IRC \`${args.from}\` ${arrow} \`${args.to}\`]\n\n${args.body}`, + display: true, + details: { from: args.from, to: args.to, body: args.body, kind: args.kind }, + attribution: "agent", + timestamp: args.timestamp, + }; + mainSession.emitIrcRelayObservation(relayRecord); + } + + /** + * Emit an IRC relay observation event on this session for UI rendering only. + * Does not persist the record to history. Public so other sessions can forward. + */ + emitIrcRelayObservation(record: CustomMessage): void { + void this.#emitSessionEvent({ type: "irc_message", message: record }); + } + /** * Run a single ephemeral side-channel turn against this session's current * model + system prompt + history. No tools are used; the side request