paperclipai/paperclip · error
Durable PRP control plane is not listening.
Error message
Durable PRP control plane is not listening.
What it means
The connectUrl getter returns the ws://127.0.0.1:<port>/durableRecovery/connect URL for the control plane. It throws when the internal HTTP server has not been started (port is null), because there is no endpoint to connect to yet.
Source
Thrown at packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts:1532
getCommand(commandId: string): DurableRecoveryCoreCommand | undefined {
return (
this.#store.state.commands.find(
(command) => command.commandId === commandId,
) ??
(this.#store.state.warmTransition?.command.commandId === commandId
? this.#store.state.warmTransition.command
: undefined) ??
(this.#store.state.completedWarmTransition?.command.commandId ===
commandId
? this.#store.state.completedWarmTransition.command
: undefined)
);
}
get connectUrl(): string {
if (this.#port === null) {
throw new Error("Durable PRP control plane is not listening.");
}
return `ws://127.0.0.1:${this.#port}/durableRecovery/connect`;
}
async start(port = 0): Promise<void> {
if (this.#server !== null) {
throw new Error("Durable PRP control plane is already running.");
}
const server = createServer((_request, response) => {
response.writeHead(404).end();
});
this.#server = server;
server.on("upgrade", (request, socket, head) =>
this.handleUpgrade(request, socket, "/durableRecovery/connect", head),
);
await new Promise<void>((resolveListen, rejectListen) => {
server.once("error", rejectListen);
server.listen(port, "127.0.0.1", () => {View on GitHub (pinned to 01ad858492)
Solutions
- Call `await controlPlane.start()` (or start(port)) before reading connectUrl.
- Use the value returned by start()/the port it bound rather than caching connectUrl from before startup.
- Check start() errors: a bind failure (EADDRINUSE) leaves the plane not listening — pick a free port.
- Re-start the server after a stop() before handing the URL to recovery clients.
Example fix
// before const url = controlPlane.connectUrl; await controlPlane.start(); // after await controlPlane.start(); const url = controlPlane.connectUrl;
Defensive patterns
Strategy: try-catch
Validate before calling
if (!controlPlane.isListening) throw new Error('call start() before obtaining connectUrl'); Try / catch
let url: string;
try {
url = controlPlane.connectUrl;
} catch (e) {
if (e.message.includes('not listening')) {
await controlPlane.start();
url = controlPlane.connectUrl;
} else throw e;
} Prevention
- Always await start() before exposing connectUrl to recovery clients.
- Handle start() bind failures (choose a free port or detect EADDRINUSE).
- Invalidate cached URLs after stop().
When it happens
Trigger: Reading `connectUrl` before calling start(), or after the server was stopped/failed to bind, while `#port` remains null.
Common situations: Client code constructs the control plane and reads connectUrl immediately instead of awaiting start(); start() threw on bind failure (e.g. port in use) so port was never set; server was stopped then connectUrl read.
Understand the failure class
Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.
Related errors
- Capability live session is not connected
- provider turn ended with status ${turn.status}
- Plugin UI is not available (status: ${plugin.status})
- Plugin is not ready (current status: ${plugin.status})
- ${message}
AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10).
Data as JSON: /api/errors/8cf6c11edb5ac423.
Report an issue: GitHub.