/** * Slash-command registrations for pi-c2c. * * Commands are the human-facing interface (typed at the keyboard), mirroring * the LLM-callable tools. Each command reads live state from a `CommandCtx` * and uses `ctx.ui` for display. */ import type { ExtensionAPI, ExtensionContext } 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 { buildSendHops, executeSend, mergePeerLists } from "./routing.ts"; import { readSpool, clearSpool } from "./spool.ts"; import { filterNovel, markDelivered } from "./delivery.ts"; import { extractStatusMessages } from "./peer-status.ts"; import { formatDisabledStatus } from "./status.ts"; import { collectDebugState } from "./debug.ts"; import { PI_C2C_VERSION, formatDebugTable, SPOOL_DIR } from "./common.ts"; import { buildPeerListDetails, formatPeerListText } from "./ui/tool-renderers.ts"; import { type AliasUpdateResult } from "./alias.ts"; /** * Mutable state consumed by command handlers. * * Similar to `ToolCtx` but also includes mutation callbacks for commands * that change state (e.g. `/c2c-toggle`). */ export interface CommandCtx { get cli(): C2cCli | null; get identity(): Identity | null; get registered(): boolean; get registerError(): string | undefined; get pollIntervalMs(): number; 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 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; get c2cEnabled(): boolean; set c2cEnabled(v: boolean); // Mutable state for toggle get barState(): import("./status.ts").PiC2cBarState; // Methods serializeDrain(fn: () => Promise): Promise; setAlias(rawAlias: string): Promise; buildDebugState(): string; buildLocalInfoText(): Promise; ensureRelayRegistered(ui: ExtensionContext["ui"]): Promise; // Mutation callbacks for toggle / lifecycle /** Stop pollers/watchers/trackers without clearing registration (legacy). */ teardown(): void; /** Fully disable: teardown, deregister, clear identity, block tools. */ disableC2c(): Promise; /** Re-enable after disable: re-run session bootstrap when a ctx is available. */ enableC2c(ui: ExtensionContext["ui"]): Promise; setShuttingDown(v: boolean): void; } const notReadyText = "c2c: not registered yet (broker unreachable?). Run `/c2c-status` or `c2c doctor`."; function ready(ctx: CommandCtx): { cli: C2cCli; identity: Identity } | null { return ctx.cli && ctx.identity && ctx.registered ? { cli: ctx.cli, identity: ctx.identity } : null; } export function registerCommands(pi: ExtensionAPI, ctx: CommandCtx): void { pi.registerCommand("c2c-status", { description: "Show pi-c2c extension + registration status", handler: async (_args, uiCtx) => { const lines = [ `pi-c2c v${PI_C2C_VERSION}`, `enabled: ${ctx.c2cEnabled}`, `alias: ${ctx.identity?.alias ?? "(none)"}`, `session: ${ctx.identity?.sessionId ?? "(none)"}`, `broker registered: ${ctx.registered}`, ...(ctx.registerError ? [`register error: ${ctx.registerError}`] : []), `lifecycle tracker: ${ctx.statusTracker ? "active" : "absent"}`, `poll interval: ${ctx.pollIntervalMs}ms`, ]; await uiCtx.ui.select(lines.join("\n"), ["Close"]); }, }); pi.registerCommand("c2c-pi-debug", { description: "Show pi-c2c debug state as a table (alias, registration, broker, env)", handler: async (_args, uiCtx) => { const table = formatDebugTable(ctx.buildDebugState()); await uiCtx.ui.select(table, ["Close"]); }, }); pi.registerCommand("c2c-whoami", { description: "Show this session's c2c identity and broker registration state", handler: async (_args, uiCtx) => { const identity = ctx.identity; if (!identity) { uiCtx.ui.notify( ctx.c2cEnabled ? "c2c: no identity yet (session not started)." : "c2c: disabled (no identity).", "info", ); return; } if (!ctx.registered) { const reason = ctx.registerError ? ` โ€” ${ctx.registerError}` : ""; uiCtx.ui.notify( `${identity.alias} (${identity.sessionId}) ยท broker not registered${reason}`, "warning", ); return; } uiCtx.ui.notify(`${identity.alias} (${identity.sessionId}) ยท registered`, "info"); }, }); pi.registerCommand("c2c-status-now", { description: "Show this session's current c2c runtime status (idle/processing/tool/input)", handler: async (_args, uiCtx) => { const s = ctx.statusTracker?.getStatus(); if (!s) { if (ctx.registered) { return uiCtx.ui.notify( "c2c: no lifecycle status tracker (runtime idle/busy status unavailable; broker registration is separate).", "warning", ); } return uiCtx.ui.notify("c2c: broker not registered (no lifecycle status).", "warning"); } await uiCtx.ui.select(`state: ${s.state}\nsince: ${new Date(s.since).toISOString()}\nttl_ms: ${s.ttlMs}`, ["Close"]); }, }); pi.registerCommand("c2c-toggle", { description: "Enable or disable c2c entirely. Usage: /c2c-toggle [on|off]. No args = toggle. Off deregisters and blocks tools; on re-registers in-session.", handler: async (args, uiCtx) => { const arg = args.trim().toLowerCase(); let target: boolean; if (arg === "on") target = true; else if (arg === "off") target = false; else if (arg === "") target = !ctx.c2cEnabled; else return uiCtx.ui.notify("usage: /c2c-toggle [on|off]", "warning"); if (target === ctx.c2cEnabled) { uiCtx.ui.notify(`c2c is already ${ctx.c2cEnabled ? "on" : "off"}.`, "info"); return; } if (target) { ctx.c2cEnabled = true; await ctx.enableC2c(uiCtx.ui); } else { ctx.c2cEnabled = false; await ctx.disableC2c(); uiCtx.ui.setStatus("c2c", formatDisabledStatus(uiCtx.ui.theme)); uiCtx.ui.notify("c2c: disabled. Deregistered; tools blocked until re-enabled.", "info"); } }, }); // NOTE: /c2c-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 ("alias is sticky ... start a fresh session to use a // new name"), so an in-session rename can no longer work. The setAlias // plumbing (ctx.setAlias / registerAliasForIdentity) is kept for easy // re-enable if/when pi-c2c adapts to the new alias model. pi.registerCommand("c2c-peers", { description: "List LIVE c2c peers grouped by repository (subagents nested under their parent). Uses c2c's global registry, sessions broker, and public relay. Pass `all` (or `dead`) to include dead/unreachable peers.", handler: async (args, uiCtx) => { const r = ready(ctx); if (!r) return uiCtx.ui.notify(notReadyText, "warning"); const includeDead = /\b(all|dead)\b/i.test(args.trim()); try { const localPeers = await r.cli.list({ global: true, alive: !includeDead }); const remotePeers = ctx.sessionsBrokerRoot ? await r.cli.list({ brokerRoot: ctx.sessionsBrokerRoot, alive: !includeDead }).catch(() => []) : []; const relayPeers = ctx.relayRegistered ? await r.cli.relayList({ includeDead }).catch(() => []) : []; const merged = mergePeerLists(localPeers, remotePeers, relayPeers); const details = buildPeerListDetails( merged, includeDead, (alias) => ctx.peerStatusStore.get(alias)?.state, ); const text = formatPeerListText(details, "run `/c2c-peers all`"); if (details.peers.length === 0) return uiCtx.ui.notify(text, "info"); await uiCtx.ui.select(text, ["Close"]); } catch (e) { uiCtx.ui.notify(`c2c list failed: ${e instanceof Error ? e.message : String(e)}`, "error"); } }, }); pi.registerCommand("c2c-inbox", { description: "Drain and show queued c2c messages", handler: async (_args, uiCtx) => { const r = ready(ctx); if (!r) return uiCtx.ui.notify(notReadyText, "warning"); const sid = r.identity.sessionId; try { await ctx.serializeDrain(async () => { const combined = [...readSpool(SPOOL_DIR, sid), ...(await r.cli.pollInbox())]; const { messages: deliverable } = extractStatusMessages(combined, ctx.peerStatusStore); ctx.telemetry.recordPeerStatusCount(ctx.peerStatusStore.live().length); const fresh = filterNovel(deliverable, ctx.dedup); const content = fresh.length ? fresh.map((m) => `${m.from_alias}: ${m.content}`).join("\n") : "(no messages)"; await uiCtx.ui.select(content, ["Close"]); markDelivered(fresh, ctx.dedup); clearSpool(SPOOL_DIR, sid); ctx.telemetry.recordSpoolCount(0); }); } catch (e) { uiCtx.ui.notify(`c2c poll-inbox failed: ${e instanceof Error ? e.message : String(e)}`, "error"); } }, }); pi.registerCommand("c2c-send", { description: "Send a DM: /c2c-send . Routes via sessions broker first, then per-repo broker, then public relay (when registered) for cross-machine peers.", handler: async (args, uiCtx) => { const r = ready(ctx); if (!r) return uiCtx.ui.notify(notReadyText, "warning"); const m = args.trim().match(/^(\S+)\s+([\s\S]+)$/); if (!m) { return uiCtx.ui.notify("usage: /c2c-send ", "warning"); } const target = m[1]; const body = m[2]; 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); if (result.ok) { ctx.telemetry.recordSent(target, result.via); return uiCtx.ui.notify(`Sent to ${target} (via ${result.via}).`, "info"); } uiCtx.ui.notify(`c2c send failed (${result.via}): ${result.message}`, "error"); }, }); pi.registerCommand("c2c-local-info", { description: "Show local c2c info (alias, address, brokers, relay) with option to copy address", handler: async (_args, uiCtx) => { if (!ctx.relayRegistered && ctx.relayEnabled) { const connected = await ctx.ensureRelayRegistered(uiCtx.ui); if (connected) { // Refresh the info screen after connecting. } } const info = await ctx.buildLocalInfoText(); const addr = ctx.relayRegistered ? ctx.relayAddress : undefined; const options: string[] = []; if (addr) { options.push(`๐Ÿ“‹ Copy address: ${addr}`); } options.push("Close"); const choice = await uiCtx.ui.select(info, options); if (choice?.startsWith("๐Ÿ“‹ Copy address")) { try { await copyToClipboard(addr!); uiCtx.ui.notify(`Copied: ${addr}`, "info"); } catch { uiCtx.ui.notify(`Address: ${addr} (clipboard copy failed)`, "warning"); } } }, }); pi.registerCommand("c2c-live-debug", { description: "Open a live telemetry dashboard for c2c message traffic and broker health", handler: async (_args, uiCtx) => { if (uiCtx.mode !== "tui") { uiCtx.ui.notify("/c2c-live-debug requires interactive TUI mode", "error"); return; } const { createLiveDebugComponent } = await import("./ui/live-debug.ts"); await uiCtx.ui.custom((_tui, theme, _keybindings, done) => createLiveDebugComponent( ctx.telemetry, theme, { identity: ctx.identity, registered: ctx.registered, relayRegistered: ctx.relayRegistered, relayAddress: ctx.relayAddress, crossRepoEnabled: ctx.crossRepoEnabled, crossRepoSessionsRegistered: ctx.crossRepoSessionsRegistered, pollIntervalMs: ctx.pollIntervalMs, }, () => done(), ), ); }, }); }