pi-radar
v0.13.3
Published
Radar daemon client, communication, publication, execution, location, launch and inspection surface for Pi extensions.
Readme
pi-radar
pi-radar is the standalone Pi baseline registrar, daemon client and publication library for Radar's Unix-socket control plane. It is bundled as ESM JavaScript for runtime imports and includes TypeScript source for editor/type tooling. The package has no runtime dependencies.
import { client } from "pi-radar";
const radar = client.createRadarClient();
const handshake = await radar.ping();Focused subpaths are also exported: pi-radar/client, pi-radar/communication, pi-radar/attention, pi-radar/execution, pi-radar/publication, pi-radar/runtime, pi-radar/launch, pi-radar/location, and pi-radar/inspection. The shared runtime handle exposes services.communication, a handoff port bound to the baseline's existing client, this process's current registration, and the managed parent injected by Radar. Callers do not choose sender or recipient identity; communication.createRadarHandoffPort is the underlying SDK factory. The runtime subpath provides a versioned pi.events request/reply handoff so multiple extensions can consume one baseline publication without sharing bundled module singletons. Existing extension deep imports under pi-radar/extension/ remain exported.
Pi baseline extension
Installing pi-radar directly as a Pi package loads extension/baseline.ts, which creates one baseline publisher and exposes its lazy runtime handle through Pi's event bus. Herdsman must request that handle rather than starting another RadarExecutionAdapter. Do not load the baseline extension with an older Herdsman integration that still creates its own publisher; first migrate Herdsman to the runtime handle.
The baseline reports session presence through one flat request. Every session load presents a fresh claim; a managed launch's first load binds the registration Radar already minted. Heartbeat/reconnect retries reuse the same claim. publication.presence.management() reports managed, duplicate, pending or unmanaged; session_shutdown awaits release of the exact claim. A duplicate remains rejected until a fresh Pi session load. subject() returns Radar's current subject only after a grant; no session marker is written to the Pi transcript.
Daemon contract
Managed execution: a launch Radar admits arrives with PI_RADAR_REGISTRATION_ID in its environment. The baseline captures it, removes it from this process's environment before any child starts, and reports it as registration_id, so a managed launch binds the registration Radar already allocated instead of registering itself. A session claim is still fresh per load, and a reload or /resume takes a new one under the same registration; nothing about the process identity is presented or reused as a claim. launch.admit is the method radar run uses and is not part of the client surface. Agent records (subject.get/subject.list) and observation agents carry scoped: true for a process Radar launched into a scope, false for an observed process it did not.
pi-radar speaks protocol 2 and runs with the daemon it was released with. A managed launch gets its process identity up front: Radar mints the registration_id, passes it as PI_RADAR_REGISTRATION_ID, and holds a reserved claim that grants nothing until the first verified report replaces it with the session's own claim. Presence (agent.presence/agent.release) is how a session acquires its subject. The agent is subject_id, one attempt is claim_id, and lifecycle controls name the claim they were issued against. Message-lane methods additionally require the independently negotiated communication.messages.v1 contract; its timestamps remain exactly representable as JSON numbers. It reads revision and the public contracts list from daemon.ping, preserves unfamiliar contract names, and preflights only contracts needed for a multi-step or versioned operation. Backend mux.* primitives are private to the daemon; direct mux methods are dispatched and return the daemon's backend refusal when unavailable.
The client uses registry.v1 for registry operations and additionally requires registry.facts.v1 for the facts channel and assignment extensions. Managed children require spawn.v1, child-pane removal requires spawn.close.v1, event reads require events.v1, and the message lane (publish, inbox, retrieve, receipt) requires communication.messages.v1. The expected wire examples are maintained in this repository's docs/control-plane.fixture.jsonl and docs/agent-registration.fixture.jsonl and are exercised by the package tests.
RadarFactsSnapshot matches the daemon facts channel: optional model/provider/thinking/session name, context_window: { tokens, window }, typed background_tasks: { id, phase }[], and background_started_unix_ms. Context counts and the timestamp are JSON numbers bounded by JavaScript's safe-integer limit. The old text context_usage field is not accepted; Rust retains cross-field validation such as a positive window and tokens not exceeding it.
Addressing a message, and learning there is one
A message is one immutable body with a frozen set of recipient subjects, and a separate receipt per recipient. page() returns pending IDs only; retrieve each payload with get() and record it durably before receive(), so a batch cannot exceed the RPC response bound.
Two things tell a process that work is waiting, and both report the same mailbox state — { revision, pending } for its own subject:
- Heartbeat. An accepted
agent.publishreply carriesattention. This is the only path an idle process needs, and it holds no socket. - Subscription.
messages.watchopens a JSON opener, then length-prefixed MessagePack frames of that state, a gap-free baseline first and latest-wins after. It is opt-in and short-lived.
createRadarAttention is the consumer: startup, a heartbeat hint and a pushed update all funnel into one serialized drain that calls receive() only after a registered consumer durably captured the message, then releases the subscription after 30 seconds of continuous idle. waiting does not count; waiting for a child is when a message matters most.
import * as attention from "pi-radar/attention";
const loop = attention.createRadarAttention({
mailbox, // createRadarMailboxPort(...)
consumers: [{ kind: "handoff", capture }], // capture returns true once recorded durably
scheduler,
activity: () => herdsmanActivity(), // "idle" | "working" | "waiting"
watch: (onAttention, onEnd) =>
client.watchAttention({ registration_id, onAttention, onEnd }),
});
publication.facts.onAttention((hint) => loop.hint(hint));
loop.drain(); // reconcile what a previous process leftcapture must return false only for a transient failure: the drain stops so the message is retried, and a permanently rejected message would block every later one. Record the rejection and return true. A subscription that ends after its baseline is reported through onEnd, so a consumer reconnects from a fresh baseline instead of holding a dead handle.
Durable handoffs
The handoff API stores immutable bounded content separately from the recipient receipt. inbox() returns pending IDs only; retrieve each payload with get() and persist it before ack(), so a batch cannot exceed the RPC response bound. A worker's first Herdsman handoff is addressed only to the stable parent_subject_id recorded by Radar for its accepted managed launch. The parent gets its stable subject ID from RadarSubject and supplies it as RadarWorkerLaunch.parent.subject_id; launchRadarWorker forwards it as managed.parent_subject_id, and Radar injects the accepted value into the worker as PI_RADAR_MANAGED_PARENT_SUBJECT_ID. The handoff port reads that process context; callers cannot choose a destination. The daemon checks it against the durable parent record. Do not substitute a registration ID, Pi session, owner-session field, pane, or inferred lineage; unmanaged/session routing is deferred. The owner must durably capture the payload locally before calling ack; acknowledgement does not settle Herdsman assignment state or authorize worker closure. Pending inbox scans recover work after daemon restart or missed pointer events, and acknowledged content remains retrievable during the retention window. See docs/control-plane.md for wire shapes, bounds and recovery semantics.
Launching a worker
launchRadarWorker takes the parent from the caller's own publication, so a consumer passes the pane it splits and no registration or subject for the parent. The result names the child by its subject_id, the key that stays the same when the child restarts or resumes; keep that as the child's identifier. A received handoff carries its sender's subject_id; match children by it.
To start a released child again, pass continues: subject_id: the daemon takes the session from its own record, so no session id or path is sent, and it refuses a child that is not yours or is still running. client.restart replaces your own live child under a new program. Both need Radar 0.4.0 or newer.
A launch is ready when the daemon grants the child's first presence, and failed when the daemon withdraws it. The daemon owns a 30-second deadline and records the withdrawal on the launch, so failed means the launch is already cleaned up and a retry is safe. launchRadarWorker has no timeout of its own; pass a signal to stop waiting. unresolved is left for a launch whose outcome cannot be read at all. children.restart(subject, program) replaces a live child in its own pane and waits on the same outcome. children.close(subject, intent, requestId?) closes the child's pane by subject: completed, already_exited, refused (nothing was touched) or unknown (the answer was lost; resend with the same requestId). The agent stays retained, so it can be continued.
Addressing children by subject
The runtime's children service (RADAR_RUNTIME_VERSION 5) is the surface for everything after a launch. list(selection?) returns the caller's children as agent records — subject_id, session, managed metadata, attachment state (live, starting, detached, unknown), location when verified and channels — and defaults to every child, so a retained worker stays findable for a continue. observe(subject) reports live, gone or unknown; inspect(subject) returns bounded output and foreground evidence captured by Radar; restart(subject, program) replaces a live child in its own pane. Radar resolves attachment, process and channels on every call, and a subject replaced by a new process is reached by the same key. Assignment retention and delivery belong to Radar: assignment(subject, owner) publishes the owner's snapshot once, and a replacement that attaches later receives it without a read or a replay. No child operation takes or returns a registration or claim id.
Generated protocol contracts
The selected Rust wire DTOs are the source for the draft 2020-12 schemas in schema/public/v1/, shipped and exported under pi-radar/schema/. They are this package's contract of record: a daemon change lands as a schema regeneration, and the method-to-schema index lives in docs/agent-registration.md in the Radar repository. Serialize and deserialize schemas are separate because Serde defaults, null handling and omission differ by direction. The SDK's ping, Pi presence report/answer/release, handoff inbox, publication-parameter, immutable handoff, receipt, managed-launch-metadata input, facts-input, spawn-exit, and observation request/snapshot/response types are generated into extension/generated-contracts.ts; do not edit that file by hand.
The public process identifier is registration_id. Durable agent identity is subject_id, which survives process replacement; a Pi session's subject is granted by agent.presence and released by agent.release. Breaking wire changes bump this package's minor version, so a consumer must move its pin and restart the daemon in one step.
From the repository checkout, run npm run generate:contracts in this directory to regenerate schemas and TypeScript declarations. npm test checks the generated artifacts are current, typechecks the public SDK aliases, validates canonical wire fixtures and integer bounds against the schemas, and runs the package tests. Generation uses the locked Schemars crate plus pinned json-schema-to-typescript and TypeScript development dependencies; it adds no runtime package dependency.
Operation sequences and event cursors stay exactly representable as JavaScript numbers and are unchanged by the schemas in this package; their long-horizon representation remains a separate wire decision.
Run npm pack to build the bundled runtime entrypoints before creating the tarball.
Testing against this SDK
Fake the port, not the daemon. Every consumer-facing class here depends on a
narrow Pick of the client, so a test double implements a handful of methods and
no socket, framing, IDs, error envelope or durable state:
| Port | Methods | Where |
| --- | --- | --- |
| RadarPublicationClient | ping, presence, releasePresence, acquire, publish, retire | pi-radar/publication |
| RadarMailboxPort | publish, page, get, receive | pi-radar/communication |
| anything else | Pick<RadarClient, ...> of the reads you call | pi-radar/client |
import type { RadarPublicationClient } from "pi-radar/publication";
import { createRadarPublication } from "pi-radar/publication";
const client: RadarPublicationClient = {
ping: async () => ({
ok: true,
value: {
protocol: 2, backend: "tmux", revision: 4, instance: null,
contracts: ["registry.v1"], tasks: { available: false, error: null },
},
}),
presence: async (report) => ({
ok: true,
value: {
registration_id: REGISTRATION,
claim_id: report.claim_id,
standing: "granted",
subject_id: SUBJECT,
},
}),
releasePresence: async (release) => ({
ok: true,
value: {
registration_id: release.registration_id,
claim_id: release.claim_id,
standing: "released",
},
}),
acquire: async () => ({
ok: true,
value: {
handle: "writer", source: "consumer", incarnation: INCARNATION,
generation: 1, sequence: 0,
},
}),
publish: async ({ sequence }) => ({ ok: true, value: { sequence } }),
retire: async () => ({ ok: true, value: undefined }),
};
const publication = createRadarPublication({
dataRoot, client, registration: { source: "consumer", label: "lead" },
});A socket-level fake daemon is a second implementation of Radar's protocol maintained by someone who does not own it. It drifts silently: it keeps passing while the real daemon refuses the same call, which is worse than having no fake at all. That is not hypothetical — a consumer's fake still answered the retired registration methods after the daemon had moved to presence, and its suite stayed green through the whole contract change.
Wire-level proof belongs to one integration test against the real daemon (a
temporary socket path and state root are enough; tests/agent_publisher.py in
the Radar repository shows the shape), not to a fake every consumer re-implements.
Releasing
Publishing is one tag push and it runs in CI; no npm token is stored anywhere.
.github/workflows/publish-pi-radar.yml publishes on a pi-radar-vX.Y.Z tag
push, authenticating with npm trusted publishing (OIDC) and emitting provenance.
One-time setup, on npmjs.com under the pi-radar package's settings: add a
trusted publisher of type GitHub Actions naming owner Shrub24, repository
agent-radar, workflow filename publish-pi-radar.yml, and no environment.
The workflow sets no environment:, so a non-empty value here stops it from
publishing; the same is true of an NPM_TOKEN secret, which trusted publishing
does not use.
To release:
- Bump
versioninpackage.jsonandpackage-lock.jsononmainand push. Semantics: a breaking wire change bumps the minor, additive changes the patch. - Tag that commit and push the tag:
git tag pi-radar-vX.Y.Z && git push origin pi-radar-vX.Y.Z.
The workflow refuses to publish unless the tag's version matches
package.json, the tagged commit is on main, and that version is not already
on npm; then it installs from the lockfile, runs npm test, preflights the
tarball with npm pack --dry-run (which builds dist/ through prepack), and
publishes. A failed run is re-runnable by fixing the cause and re-pushing the
tag; an already-published version fails by design rather than republishing.
