/** * LLM-callable tool registrations for pi-c2c. * * Each tool is registered on the pi API and reads live state from an * `ExtensionCtx` snapshot. The tools are pure consumers of state — they * never mutate watchers, pollers, or lifecycle flags directly. */ import { Type } from "typebox"; import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent"; import { copyToClipboard } from "@earendil-works/pi-coding-agent"; import type { C2cCli, C2cMessage } from "./c2c-cli.ts"; import type { Identity } from "./identity.ts"; import { computeHostHash, deriveRelayAlias } from "./relay.ts"; import { buildSendHops, executeSend, mergePeerLists } from "./routing.ts"; import { readSpool, clearSpool } from "./spool.ts"; import { filterNovel, formatEnvelope, markDelivered } from "./delivery.ts"; import { extractStatusMessages } from "./peer-status.ts"; import { HELP_TOPICS, renderC2cPiHelp } from "./help.ts"; import { collectDebugState } from "./debug.ts"; import { PI_C2C_VERSION, formatDebugTable, type RelayToC2cFn, SPOOL_DIR } from "./common.ts"; import { type AliasUpdateResult } from "./alias.ts"; import { buildPeerListDetails, formatPeerListText, renderEmptyCall, renderInboxResult, renderJoinRoomResult, renderListResult, renderLocalInfoResult, renderRoomsResult, renderSendResult, renderStatusResult, renderWhoamiResult, type InboxToolDetails, type ListToolDetails, type LocalInfoToolDetails, type RoomToolDetails, type RoomsToolDetails, type SendToolDetails, type StatusToolDetails, type WhoamiToolDetails, } from "./ui/tool-renderers.ts"; /** A pi tool/command result: text blocks plus opaque details. */ function toolText(text: string, details?: unknown) { return { content: [{ type: "text" as const, text }], details }; } /** * Mutable state snapshot consumed by tool executors. * * Every field is a getter so tools always read the latest value — the * extension factory captures these as closures over its live variables. */ export interface ToolCtx { get cli(): C2cCli | null; get identity(): Identity | null; get registered(): boolean; get registerError(): string | undefined; get relayRegistered(): boolean; get relayAddress(): string | undefined; get relayHostId(): string | undefined; get relayHostIdVerified(): boolean; get relayWsState(): import("./relay-watcher.ts").RelayWatcherState | undefined; get crossRepoEnabled(): boolean; get sessionsBrokerRoot(): string | undefined; get crossRepoSessionsRegistered(): boolean; get crossRepoSessionsError(): string | undefined; get relayEnabled(): boolean; get pollIntervalMs(): number; get peerStatusStore(): import("./peer-status.ts").PeerStatusStore; get statusTracker(): import("./status-sync.ts").StatusTracker | null; get telemetry(): import("./telemetry.ts").LiveTelemetry; get dedup(): import("./delivery.ts").DeliveryDedup; get queuedSinceMs(): number | undefined; get shuttingDown(): boolean; // Methods that cross the state boundary serializeDrain(fn: () => Promise): Promise; setAlias(rawAlias: string): Promise; drainAllSources(): Promise; buildDebugState(): string; buildLocalInfoText(): Promise; buildLocalInfoDetails(): Promise; ensureRelayRegistered(ui: import("@earendil-works/pi-coding-agent").ExtensionContext["ui"]): Promise; } const notReadyText = "c2c: not registered yet (broker unreachable?). Run `/c2c-status` or `c2c doctor`."; function ready(ctx: ToolCtx): { cli: C2cCli; identity: Identity } | null { const cli = ctx.cli; const identity = ctx.identity; return cli && identity && ctx.registered ? { cli, identity } : null; } // ── Tool registrations ────────────────────────────────────────────────────── export function registerTools(pi: ExtensionAPI, ctx: ToolCtx): void { // ── c2c_pi_help ─────────────────────────────────────────────────────────── pi.registerTool({ name: "c2c_pi_help", label: "c2c help", description: "Teach a pi agent how to use pi-c2c tools, reply to inbound c2c messages, and map to generic c2c MCP/CLI concepts.", parameters: Type.Object({ topic: Type.Optional( Type.Union( HELP_TOPICS.map((topic) => Type.Literal(topic)), { description: "Help topic to show. Defaults to overview." }, ), ), }), async execute(_id, { topic }) { return toolText(renderC2cPiHelp(topic)); }, }); // ── c2c_pi_debug ────────────────────────────────────────────────────────── pi.registerTool({ name: "c2c_pi_debug", label: "c2c pi debug", description: "Return useful debugging metadata as a single text block.", parameters: Type.Object({}), async execute() { return toolText(ctx.buildDebugState()); }, }); // ── c2c_pi_send ─────────────────────────────────────────────────────────── pi.registerTool({ name: "c2c_pi_send", label: "c2c send", description: "Send a c2c direct message to a peer agent by alias or full relay address (`alias@host_hash`). Prefer this over the generic c2c_send tool: this extension routes via the sessions broker first (cross-repo), then the per-repo broker, then the public relay (when registered) for cross-machine peers. Set `nonurgent` to opt out of interrupt+steer delivery on the receiver side (default is triggerTurn+steer — c2c messages are high-priority by default).", parameters: Type.Object({ target: Type.String({ description: "Recipient alias (e.g. 'lyra-quill'), session id, or relay address like 'pi-123abc@3d08761ae3f3'." }), body: Type.String({ description: "Message body." }), nonurgent: Type.Optional( Type.Boolean({ description: "If true, the receiver uses followUp delivery (no interrupt, no steer) instead of the default triggerTurn+steer. Use for non-time-sensitive messages like FYIs or status updates." }), ), }), renderShell: "self", async execute(_id, { target, body, nonurgent }) { const r = ready(ctx); const details: SendToolDetails = { kind: "dm", target, body, nonurgent: nonurgent ?? false }; if (!r) return toolText(notReadyText, { ...details, error: "not registered" }); const hops = buildSendHops({ sessionsBrokerRoot: ctx.sessionsBrokerRoot, relayRegistered: ctx.relayRegistered && !!ctx.relayAddress }); const result = await executeSend(r.cli, hops, target, body, ctx.relayAddress, r.identity.alias); details.via = result.via; if (result.ok) { ctx.telemetry.recordSent(target, result.via); const tag = nonurgent ? " (nonurgent)" : ""; return toolText(`Sent to ${target} (via ${result.via})${tag}.`, details); } return toolText(`c2c_pi_send failed (${result.via}): ${result.message}`, { ...details, error: "failed", errorDetail: result.message }); }, renderCall: () => renderEmptyCall(), renderResult: (result, options, theme, context) => renderSendResult( (result.details as SendToolDetails) ?? (context.args as unknown as SendToolDetails), context.isError, theme, options.expanded, ), }); // ── c2c_pi_send_all ─────────────────────────────────────────────────────── pi.registerTool({ name: "c2c_pi_send_all", label: "c2c broadcast", description: "Broadcast a c2c message to all registered peers. Prefer this over the generic c2c_send_all tool because this extension broadcasts through the extension's registered identity.", parameters: Type.Object({ body: Type.String({ description: "Message body." }), exclude: Type.Optional( Type.Array(Type.String(), { description: "Aliases to skip." }), ), }), renderShell: "self", async execute(_id, { body, exclude }) { const r = ready(ctx); const details: SendToolDetails = { kind: "broadcast", body, via: "sessions" }; if (!r) return toolText(notReadyText, { ...details, error: "not registered" }); try { await r.cli.sendAll(body, { exclude, from: r.identity.alias }); ctx.telemetry.recordSent("(broadcast)", "broadcast"); return toolText("Broadcast sent.", details); } catch (e) { const message = e instanceof Error ? e.message : String(e); return toolText(`c2c_pi_send_all failed: ${message}`, { ...details, error: "failed", errorDetail: message }); } }, renderCall: () => renderEmptyCall(), renderResult: (result, options, theme, context) => renderSendResult( (result.details as SendToolDetails) ?? { kind: "broadcast" }, context.isError, theme, options.expanded, ), }); // ── c2c_pi_list ─────────────────────────────────────────────────────────── pi.registerTool({ name: "c2c_pi_list", label: "c2c peers", description: "List LIVE registered c2c peers, grouped by repository. It uses c2c's global registry plus the sessions broker and public relay (when registered), so one call shows every reachable alias. Subagents are nested under their parent within each repository. Long-dead registrations are excluded at the c2c query boundary by default; pass include_dead=true to list them. Prefer this over the generic c2c_list tool.", parameters: Type.Object({ include_dead: Type.Optional( Type.Boolean({ description: "Include dead/unreachable peers. Default false (live peers only)." }), ), }), renderShell: "self", async execute(_id, { include_dead }) { const r = ready(ctx); if (!r) return toolText(notReadyText, { peers: [], error: "not registered" } as ListToolDetails); try { const localPeers = await r.cli.list({ global: true, alive: include_dead !== true }); const remotePeers = ctx.sessionsBrokerRoot ? await r.cli.list({ brokerRoot: ctx.sessionsBrokerRoot, alive: include_dead !== true }).catch(() => []) : []; const relayPeers = ctx.relayRegistered ? await r.cli.relayList({ includeDead: include_dead === true }).catch(() => []) : []; const merged = mergePeerLists(localPeers, remotePeers, relayPeers); const details = buildPeerListDetails( merged, include_dead === true, (alias) => ctx.peerStatusStore.get(alias)?.state, ); const text = formatPeerListText(details); return toolText(text, details); } catch (e) { return toolText(`c2c_pi_list failed: ${e instanceof Error ? e.message : String(e)}`, { peers: [], error: "failed" } as ListToolDetails); } }, renderCall: () => renderEmptyCall(), renderResult: (result, _options, theme, context) => renderListResult((result.details as ListToolDetails) ?? { peers: [] }, context.isError, theme), }); // ── c2c_pi_poll_inbox ───────────────────────────────────────────────────── pi.registerTool({ name: "c2c_pi_poll_inbox", label: "c2c inbox", description: "Drain and return any queued inbound c2c messages now. Prefer this over the generic c2c_poll_inbox tool: it drains per-repo, sessions-broker, and public-relay (when registered) so a manual call shows the same picture as the background poller.", parameters: Type.Object({}), renderShell: "self", async execute() { const r = ready(ctx); if (!r) return toolText(notReadyText, { messages: [], error: "not registered" } as InboxToolDetails); const sid = r.identity.sessionId; try { const { text, messages } = await ctx.serializeDrain(async () => { const drained = await ctx.drainAllSources(); const combined = [...readSpool(SPOOL_DIR, sid), ...drained]; const { messages: deliverable } = extractStatusMessages(combined, ctx.peerStatusStore); ctx.telemetry.recordPeerStatusCount(ctx.peerStatusStore.live().length); const fresh = filterNovel(deliverable, ctx.dedup); const rendered = fresh.length === 0 ? "(no messages)" : fresh.map((m) => formatEnvelope(m, r.identity.alias)).join("\n\n"); const inboxMessages: InboxToolDetails = { messages: fresh.map((m) => ({ from: m.from_alias || "unknown", preview: m.content.slice(0, 200) })), }; markDelivered(fresh, ctx.dedup); clearSpool(SPOOL_DIR, sid); ctx.telemetry.recordSpoolCount(0); return { text: rendered, messages: inboxMessages.messages }; }); return toolText(text, { messages } as InboxToolDetails); } catch (e) { return toolText(`c2c_pi_poll_inbox failed: ${e instanceof Error ? e.message : String(e)}`, { messages: [], error: "failed" } as InboxToolDetails); } }, renderCall: () => renderEmptyCall(), renderResult: (result, _options, theme, context) => renderInboxResult((result.details as InboxToolDetails) ?? { messages: [] }, context.isError, theme), }); // ── c2c_pi_whoami ───────────────────────────────────────────────────────── pi.registerTool({ name: "c2c_pi_whoami", label: "c2c whoami", description: "Show this session's c2c identity (alias + session id). Prefer this over the generic c2c_whoami tool when working inside this pi-c2c extension.", parameters: Type.Object({}), renderShell: "self", async execute() { const identity = ctx.identity; if (!identity) { return toolText( "c2c: no identity yet (session not started, or c2c disabled). Run `/c2c-status` or `c2c doctor`.", { alias: "", sessionId: "", registered: false, error: "no identity" } as WhoamiToolDetails, ); } const details: WhoamiToolDetails = { alias: identity.alias, sessionId: identity.sessionId, registered: ctx.registered, }; const regLine = ctx.registered ? "registered: true" : `registered: false${ctx.registerError ? ` (${ctx.registerError})` : ""}`; return toolText( `alias: ${identity.alias}\nsession_id: ${identity.sessionId}\n${regLine}`, details, ); }, renderCall: () => renderEmptyCall(), renderResult: (result, _options, theme, context) => renderWhoamiResult((result.details as WhoamiToolDetails) ?? { alias: "", sessionId: "", registered: false }, context.isError, theme), }); // NOTE: c2c_pi_set_alias is intentionally disabled. c2c 0.11.0+ makes aliases // sticky per session — re-registering a different alias for an existing // session is rejected, so an in-session rename can no longer work. The // setAlias plumbing (ctx.setAlias) is kept for easy re-enable if/when pi-c2c // adapts to the new alias model. // ── c2c_pi_status ───────────────────────────────────────────────────────── pi.registerTool({ name: "c2c_pi_status", label: "c2c status", description: "Show this session's current c2c runtime status (idle/processing/tool/input).", parameters: Type.Object({}), renderShell: "self", async execute() { const s = ctx.statusTracker?.getStatus(); if (!s) { if (ctx.registered) { return toolText( "c2c: no lifecycle status tracker (runtime idle/busy status unavailable; broker registration is separate).", { registered: true, unavailableReason: "no_tracker" } as StatusToolDetails, ); } return toolText( "c2c: broker not registered (no lifecycle status).", { registered: false, unavailableReason: "not_registered" } as StatusToolDetails, ); } const sinceIso = new Date(s.since).toISOString(); return toolText( `state: ${s.state}\nsince: ${sinceIso}\nttl_ms: ${s.ttlMs}`, { state: s.state, since: sinceIso, ttlMs: s.ttlMs, registered: true } as StatusToolDetails, ); }, renderCall: () => renderEmptyCall(), renderResult: (result, _options, theme, context) => renderStatusResult((result.details as StatusToolDetails) ?? { registered: false, unavailableReason: "not_registered" }, context.isError, theme), }); // ── c2c_pi_join_room ────────────────────────────────────────────────────── pi.registerTool({ name: "c2c_pi_join_room", label: "c2c join room", description: "Join a c2c room (N:N channel). Prefer this over the generic c2c_join_room tool when working inside this pi-c2c extension. Room messages auto-deliver to your transcript.", parameters: Type.Object({ room: Type.String({ description: "Room id (e.g. 'swarm-lounge')." }) }), renderShell: "self", async execute(_id, { room }) { const r = ready(ctx); const details: RoomToolDetails = { room, joined: true }; if (!r) return toolText(notReadyText, { ...details, error: "not registered" }); try { await r.cli.joinRoom(room, r.identity.alias); return toolText(`Joined room ${room}.`, details); } catch (e) { return toolText(`c2c_pi_join_room failed: ${e instanceof Error ? e.message : String(e)}`, { ...details, error: "failed" }); } }, renderCall: () => renderEmptyCall(), renderResult: (result, _options, theme, context) => renderJoinRoomResult( (result.details as RoomToolDetails) ?? { room: (context.args as { room: string }).room }, context.isError, theme, ), }); // ── c2c_pi_send_room ────────────────────────────────────────────────────── pi.registerTool({ name: "c2c_pi_send_room", label: "c2c room send", description: "Send a message to a c2c room you have joined. Prefer this over the generic c2c_send_room tool when working inside this pi-c2c extension.", parameters: Type.Object({ room: Type.String({ description: "Room id." }), body: Type.String({ description: "Message body." }), }), renderShell: "self", async execute(_id, { room, body }) { const r = ready(ctx); const details: SendToolDetails = { kind: "room", room, body, via: "sessions" }; if (!r) return toolText(notReadyText, { ...details, error: "not registered" }); try { await r.cli.sendRoom(room, body, { from: r.identity.alias }); ctx.telemetry.recordSent(`room:${room}`, "room"); return toolText(`Sent to room ${room}.`, details); } catch (e) { const message = e instanceof Error ? e.message : String(e); return toolText(`c2c_pi_send_room failed: ${message}`, { ...details, error: "failed", errorDetail: message }); } }, renderCall: () => renderEmptyCall(), renderResult: (result, options, theme, context) => renderSendResult( (result.details as SendToolDetails) ?? { kind: "room", room: (context.args as { room: string }).room }, context.isError, theme, options.expanded, ), }); // ── c2c_pi_local_info ───────────────────────────────────────────────────── pi.registerTool({ name: "c2c_pi_local_info", label: "c2c local info", description: "Show local c2c node info: alias, session, relay address, broker status. Use when the user asks about their c2c address, identity, or connection status. If not connected to the public relay, advises that connecting enables cross-machine messaging.", parameters: Type.Object({}), renderShell: "self", async execute() { const r = ready(ctx); if (!r) { return toolText(notReadyText, { alias: "(not registered)", sessionId: "(none)", broker: "not connected", crossRepo: "unknown", relay: "unknown", relayWsState: "---", relayHost: "---", error: "not registered", } as LocalInfoToolDetails); } const info = await ctx.buildLocalInfoText(); const addr = ctx.relayRegistered ? ctx.relayAddress : undefined; const parts = [info]; if (!addr) { parts.push(""); parts.push( "Connect to the public relay to get a persistent address and receive messages over the network. " + "Use /c2c-local-info to interactively connect.", ); } return toolText(parts.join("\n"), await ctx.buildLocalInfoDetails()); }, renderCall: () => renderEmptyCall(), renderResult: (result, _options, theme, context) => renderLocalInfoResult( (result.details as LocalInfoToolDetails) ?? { alias: "(unknown)", sessionId: "(unknown)", broker: "unknown", crossRepo: "unknown", relay: "unknown", }, context.isError, theme, ), }); // ── c2c_pi_rooms ────────────────────────────────────────────────────────── pi.registerTool({ name: "c2c_pi_rooms", label: "c2c rooms", description: "List the c2c rooms this session is a member of.", parameters: Type.Object({}), renderShell: "self", async execute() { const r = ready(ctx); if (!r) return toolText(notReadyText, { rooms: [], error: "not registered" } as RoomsToolDetails); try { const rooms = await r.cli.myRooms(); const details: RoomsToolDetails = { rooms }; return toolText(rooms.length ? rooms.join("\n") : "(no rooms joined)", details); } catch (e) { return toolText(`c2c_pi_rooms failed: ${e instanceof Error ? e.message : String(e)}`, { rooms: [], error: "failed" } as RoomsToolDetails); } }, renderCall: () => renderEmptyCall(), renderResult: (result, _options, theme, context) => renderRoomsResult((result.details as RoomsToolDetails) ?? { rooms: [] }, context.isError, theme), }); }