/** * Synadia Agent Protocol for NATS channel for PI Agent. * * Implements the clean v0.4 subject scheme (see the sesh-channels SDK * `subjects` module). Every PI session becomes a discoverable agent instance * (`$SRV.PING/INFO`), addressable on the machine-rooted prompt subject * `agents.prompt...`, emitting a periodic * heartbeat on `agents.hb...` and answering status * requests on `agents.status...`. Agent identity * lives in service metadata (the `agent` key), never in a subject. * * Attachments: inline per spec §5.1/§5.2. Each `{filename, content}` is * base64-decoded (strict RFC 4648 §4 — standard alphabet, padded, no * whitespace, no URL-safe), the filename is sanitized, bytes are staged on * disk at `/attachments//-`, and their * absolute paths are prepended to the prompt text handed to PI. The staging * directory is removed on session shutdown. Spec §5.5's artifact endpoint is * still the long-term home for large files; this inline path is the * small-file story for v0.3. */ import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { randomUUID } from "node:crypto"; import { readConfig as readRoleClassConfig } from "./config"; import { resolveRawSession } from "./session"; import { resolveV04Coord, v2EnvelopeText, v2Scope, V2Stores, } from "./v04"; import { ulid } from "ulid"; import { connect, credsAuthenticator, headers, jwtAuthenticator, nkeyAuthenticator, tokenAuthenticator, usernamePasswordAuthenticator, type MsgHdrs, type NatsConnection, } from "@nats-io/transport-node"; import type { NodeConnectionOptions } from "@nats-io/transport-node"; import { Type } from "typebox"; import { Svcm } from "@nats-io/services"; import type { Service, ServiceMsg } from "@nats-io/services"; import { SDK_PROTOCOL_VERSION, SERVICE_NAME, formatHumanBytes, parseHumanBytes, parseNatsUrl, } from "@synadia-ai/agents"; import { DEFAULT_ATTACHMENTS_OK, DEFAULT_MAX_PAYLOAD, buildHeartbeatPayload, encodeChunk, encodeHeartbeatPayload, splitResponseText, } from "@synadia-ai/agent-service"; import { sanitizeSubjectToken, v04 } from "@agent-ops/sesh-channels-sdk"; import type { AgentCardPartial, Coord, Message as V2Message, Part as V2Part, ScopeRef, } from "@agent-ops/sesh-channels-sdk"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; // ── Outbound helpers ─────────────────────────────────────────────────────── // // Mirrors the helper added to claude-nats-channel in PR #6. Converts a plain // `{string:string}` map into a `MsgHdrs` instance suitable for `nc.publish` / // `nc.request`. Returns `null` when the input is missing or empty so callers // can omit the `headers` option entirely. function buildOutboundHeaders(input: unknown): MsgHdrs | null { if (!input || typeof input !== "object") return null; const entries = Object.entries(input as Record); if (entries.length === 0) return null; const hdrs = headers(); for (const [k, v] of entries) { if (typeof v !== "string") { throw new Error(`headers.${k}: value must be a string`); } hdrs.set(k, v); } return hdrs; } // ───────────────────────────────────────────────────────────────────────────── // PI-specific protocol constants // ───────────────────────────────────────────────────────────────────────────── const SERVICE_VERSION = "0.4.0"; // Heartbeat cadence on `agents.hb...`. Locally // pinned at 5s so the dashboard's stale-eviction loop (3× intervalS) drops a // dead `pi` agent in ~15s instead of ~90s. The SDK's // `DEFAULT_HEARTBEAT_INTERVAL_S` stays at 30s as a sensible third-party // default — first-party harnesses opt into the snappier cadence. const HEARTBEAT_INTERVAL_S = 5; // Spec §2, Appendix C: `pi` is both the canonical agent identifier and its // conventional subject abbreviation, so `metadata.agent` and the wire // subject's 3rd token are the same — no `subjectToken` override needed. const AGENT_ID = "pi"; /** Fallback values used only when `nc.info.max_payload` isn't available. * The live cap comes from the broker after connect — see `maxPayloadBytes` * / `maxPayloadStr` in the extension closure. */ const DEFAULT_MAX_PAYLOAD_BYTES_FALLBACK = parseHumanBytes(DEFAULT_MAX_PAYLOAD); // Keep-alive `ack` status emitted during long tool runs so the caller's // inactivity timer (§6.6, typically 60s) doesn't fire before text_delta output. const ACK_KEEPALIVE_MS = 20_000; // MOCK_MODE: short-circuit upstream Pi dispatch and answer with a canned // reply directly from the handler. Used by Slice 7 cross-repo integration // tests so CI doesn't burn LLM credits on every PR. Production paths // (MOCK_MODE unset) are entirely unchanged. const MOCK_MODE = process.env.MOCK_MODE === "1"; // ───────────────────────────────────────────────────────────────────────────── // Config / paths // ───────────────────────────────────────────────────────────────────────────── const STATE_DIR = join(homedir(), ".pi", "agent"); const CONFIG_FILE = join(STATE_DIR, "nats-channel.json"); const ATTACHMENTS_ROOT = join(STATE_DIR, "attachments"); const NATS_CONTEXT_DIR = join(homedir(), ".config", "nats", "context"); // Sesh's live URL files. `~/.sesh/hub.nats.url` is the hub's *client* URL — // atomically rewritten by `sesh hub serve` (cli/hubinfo.go:writeAtomic) on // every startup with the freshly-bound ephemeral port, and removed on clean // shutdown. `/.sesh/sessions/