pi-daemon1.3

Reference

Operations

Development shell

nix develop github:harryaskham/pi-daemon
npm ci --ignore-scripts
npm test

User-local release updates

A release-managed install can advance independently of a full nix-darwin or Home Manager rebuild:

pi-daemon self-update status
pi-daemon self-update check
pi-daemon update

update is shorthand for self-update run. It reads GitHub's latest release, downloads the published npm artifact and SHA-256 sidecar, verifies both the release checksum and the artifact's exact npm-shrinkwrap.json integrity, and runs npm with lifecycle scripts disabled. Versions live under the owner-private ~/.local/share/pi-daemon/versions/; current and ~/.local/bin/pi-daemon are switched atomically. As a one-time migration, update may adopt the exact npm-global symlink produced by just install under the same prefix. Regular files, arbitrary existing links, mismatched CLI targets, and every target outside either the managed version root or the exact @harryaskham/pi-daemon/dist npm-global paths still fail closed before download. The command never restarts services or moves daemon state or credentials.

For Home Manager services, opt into the stable mutable-runtime shim once:

services.pi-daemon.mutableRuntime.enable = true;
home.sessionPath = [ "$HOME/.local/bin" ]; # makes the same managed CLI win interactively

After activating, type -a pi-daemon should list ~/.local/bin/pi-daemon; a pre-existing shell may need rehash (zsh) or hash -r (bash). The service launcher itself uses the exact configured path and does not depend on interactive PATH ordering.

That one declarative activation installs a Nix-store launcher which chooses the executable owner-local ~/.local/bin/pi-daemon when present and always retains the configured immutable package as fallback. Subsequent release updates need no system rebuild; explicitly restart the service after reviewing the new version:

launchctl kickstart -k "gui/$UID/com.pi-daemon"   # macOS
systemctl --user restart pi-daemon                 # Linux

Use pi-daemon self-update rollback and restart to atomically select the one retained previous release. status is offline; check and run require GitHub plus npm on PATH. Private repository releases require a memory-only GH_TOKEN or GITHUB_TOKEN for both metadata and asset downloads:

GH_TOKEN="$(gh auth token --hostname github.com)" pi-daemon update

The token is sent only as a GitHub bearer header and is never persisted or printed. A newer verified CLI can also bootstrap an older managed installation: run that CLI's update command with the same installation paths. status then reports localInstallRequired: true until the owner-local launcher matches the newer CLI; merely running the current release outside that launcher is not an installed-version receipt. The usual artifact checks and rollback retention still apply, and a release older than either the running CLI or managed active version is refused.

If a configured corporate npm mirror has stale package metadata, an operator may select the canonical public registry for that invocation with npm_config_registry=https://registry.npmjs.org pi-daemon update; never include registry credentials in argv or logs. Custom test/install roots are available through --install-root and --bin-dir; production services should use the defaults.

Session store upgrades

Each session is a Pi Durable SQLite store. Pi 1.4, from pi-daemon's release with it, upgrades a store the first time it opens it: schema version 1 to 2, which rebuilds only the task table, so a task can sleep. The upgrade is atomic, and conversations, entries and documents are untouched.

  • It cannot be undone in place. An earlier pi-daemon still starts and lists the sessions, but refuses each upgraded one: Durable SQLite schema version 2 is newer than supported version 1. It changes nothing; starting Pi 1.4 again opens them as before.
    • To keep a way back, copy stateDir/sessions (with the daemon stopped) before the first start.
    • Roll out one node first.
  • Profile modules' documents need a checkpointWhen policy under Pi 1.4. A document token without one fails every commit to it.
    • agent-utils has it from 8c001a71. Update it before or with the daemon.
    • pi-daemon's own documents have it.
    • () => false keeps changes as deltas, as before.
  • The crash-loop guard stays off (restartLimit: Infinity), unless a module's harnessOptions settings set it. Durable would park a task cut off by 5 crashes in a row, any session's in a crashed daemon or worker, and no client can retry one yet.

Pi 1.5, from pi-daemon's release with it, changes no table. It writes a conversation's live state (pi.live) and its generation and tool tasks in newer versions the first time the conversation runs.

  • It cannot be undone in place either. An earlier pi-daemon still reads a store that Pi 1.5 has only read. After one run on Pi 1.5 it fails every read and run of that conversation: Document N (pi.live) has newer version 3 than 1.
    • To keep a way back, copy stateDir/sessions (with the daemon stopped) before the first start.
  • Clients get what they got. /chord's events and views, AHP and ACP are made as before (agent events and views). Three fields are new: an assistant message carries its thinkingLevel, a view's pi.live keeps runFailure after a run that ended without an answer, and a compaction's status there names its trigger (events and snapshots keep reason).
  • A profile module that watches a document is called differently: Pi 1.5 calls a watch.start() listener with one { kind, value, ops, retired } object, where Pi 1.4 passed (value, ops, context).
    • agent-utils reads both from a3ec749e. Update it before or with the daemon.
  • A module's afterTool hook now also runs on the results Durable writes without running the tool: a denied or invalid call, an abort, an interruption. Their one error diagnostic has the code (blocked, invalid_arguments, aborted, …); a hook that assumed the tool ran checks it first.
  • Tool output has no escape sequences. Durable's output buffer dropped only the escape character of a colour sequence before, so [31m and the like stayed in a tool's output; it now drops the whole sequence.
  • A model request cut off by a crash counts as a failed attempt under the profile's retry settings, and is sent again after their delay; it was sent again at once and uncounted. A daemon that was stopped still resends without counting. When the retries are used up, the run ends without an answer (interrupted).
  • Streamed text and tool output are stored a little after they are shown (within a second), together, instead of at each step. A crash loses at most that much of an unfinished answer.

Agent events and views on Pi 1.5

Durable 1.5 has no watchEvents(), and its conversation view holds a window of the newest entries of the whole history where 1.4's held the active transcript. pi-daemon makes both itself, in the worker (src/agent-events.ts), from Durable's synchronous observers, so conversation.watchEvents, conversation.watch (its tail, olderEntries and conversation.history), the AHP and ACP projections and module observers are as on Pi 1.4.

  • Nothing polls: Durable calls the observers as it adopts each change.
  • A conversation's active transcript is held once per worker while a client watches it, as Durable 1.4 held it. A conversation that only profile modules observe is not read into memory at all (Durable 1.4 read it for them too).
  • A watcher that falls more than 100 batches behind gets one snapshot (a view: one replacement) in place of them, as before.
  • test/wire-golden.test.mjs compares sixteen scripted sessions with fixtures recorded on Pi 1.4.0.

Serve

pi-daemon serve runs the host. It loads the instance configuration, creates the owner-private state and Pi agent directories, seeds auth.json once when an auth seed is configured, and creates or validates the bearer token file. It starts one supervised worker process per configured profile and resumes the sessions that hold unfinished work. It then serves one HTTP server until SIGTERM or SIGINT:

  • GET /healthz returns 200 while the process is serving.
  • GET /readyz returns 200 {"status":"ready",…} once startup has finished, and 503 while draining.
  • Every other path, including the protocol routes (/ahp, /acp, /chord) and their WebSocket upgrades, requires Authorization: Bearer <token> with the instance's bearer (by default the owner-only STATE_DIR/api-token). Missing or wrong bearers get 401 before any route runs.
  • /ahp, /acp and /chord run sessions in their profiles' workers. Other authenticated paths return 404.
  • /web is a page over /chord (through /fleet) with pi-client's views: the sessions, the live conversation, the composer and pi-client's commands. It mounts beside /chord. The page needs no bearer; logging in posts the bearer to /web/login, which sets an HttpOnly, SameSite=Strict cookie (Secure over TLS). The cookie admits only same-origin /chord WebSocket upgrades, lasts 12 hours and is held in memory. The page remembers the bearer in the browser's localStorage, so after a restart or an expired cookie it logs in again without asking. Logging out forgets the bearer, and so does a rejected one, for example after the token rotates. Anyone using that browser profile can read the bearer, so log out on a shared machine.

The listener binds 127.0.0.1:7463 by default. Bind another address, such as a tailnet address, with --api-bind or api.bind. --api-enabled false runs without a listener.

pi-daemon serve --config ~/.config/pi/daemon/work/config.yaml --instance work
pi-daemon serve --state-dir ~/.local/state/pi-daemon --api-bind 100.64.0.1 --api-port 7463
pi-daemon status --instance work
pi-daemon sessions list --instance work

pi-daemon status probes the instance's /readyz (or --url) and prints its instance, URL, state (ready, not_ready or unreachable) and session count. It exits 0 only when the host is ready. pi-daemon sessions list prints the instance's session catalog as JSON, most recently active first.

Both read the state directory from --state-dir, then the YAML's stateDir, then PI_DAEMON_STATE_DIR when the environment is that instance's (PI_DAEMON_INSTANCE, or default), then ~/.local/state/pi-daemon. The Home Manager module passes the service its state directory as a flag, not in the YAML, and sets the variable for the service, its sessions and login shells, so both commands work there without flags. serve never reads the variable: a host started from inside a session must not open the running service's state.

AHP (/ahp)

/ahp is a WebSocket speaking the Agent Host Protocol 1.0, and 0.8.0 to clients that offer only it (AHP 1.0 with a 0.8 fallback) (JSON-RPC text frames, no subprotocol), authenticated by the same bearer on the upgrade. It currently implements:

  • initialize (protocol 1.0 or 0.9 when offered, else 0.8.0) and ping;

  • the ahp-root:// channel (subscribe, unsubscribe, initialSubscriptions), whose state is the profiles as agents and the session count as activeSessions;

  • listSessions, paged most recently active first, kept live by root/sessionAdded, root/sessionRemoved and root/sessionSummaryChanged (title, status, modifiedAt);

  • session channels ahp-session:/<id>, whose state lists the session's chats: every Durable conversation of the session's store. The root conversation is the default chat, ahp-chat:/<id>/1. Forks, side chats and subagents follow, under the URI a client created them with or as ahp-chat:/<id>/<conversation>, each with its origin: fork or sideChat with the source chat and turn, tool with the chat and tool call that ran a subagent (whose chat is read-only), or user for an empty chat;

  • chat channels, whose snapshot holds the newest turns (50 unless view.turns asks for another number, fewer if they would not fit in one message) and a turnsNextCursor. fetchTurns with that cursor sends the subscriber a chat/turnsLoaded action with the next older turns. Turns are projected from the session's committed Durable transcript through the official AHP chat reducer: each user message starts a turn, followed by its reasoning, text, tool calls with their input and results, and the turn's summed token usage. The usage's _meta carries live context telemetry: contextTokens (the context the turn's latest model response left, measured as TUI Pi does), contextWindow (that model's window, when its metadata is known) and contextState (measured, or compacted / reset once the context changed after that response, when contextTokens is omitted until the next response). It also carries spend in US dollars, as pi prices models: cost (the turn's own, summed over its model responses) and, on the newest turn only, conversationCost (the conversation's total so far, every model's and tool's, from Durable's pi.usage; it follows that document while a run goes). Each is left out when no spend is known. A turn of a run still going is the chat's activeTurn;

  • reconnect, which replays recent root actions from a bounded in-memory window and otherwise returns fresh snapshots. Session and chat channels are not replayed; they are reported missing so the client subscribes again. After a daemon restart the new incarnation's serverSeq starts above any earlier one, so reconnecting clients always get snapshots;

  • resolveSessionConfig, whose schema asks for cwd (default: the profile's first cwd root, or the given workingDirectory), model (provider/model), thinking and an optional title, with the profile's defaults filled in;

  • createSession with a profile (provider, optional when only one profile is configured), a cwd under the profile's roots (config.cwd or the first workingDirectories entry) and the resolved config. The session id is the ahp-session:/<id> channel. Retrying with the same profile and cwd succeeds without creating anything; a different session at that URI is SessionAlreadyExists. A model or thinking level in config is applied to the session's root conversation, and a title names the session. The config may also carry the session's own configuration (see A session's own configuration), which hosts that take it advertise in the initialize result's and root state's _meta under pi-daemon/sessionProfile;

  • session titles. A session created without a title is titled from its first prompt, as TUI Pi labels a session: the prompt's text on one line, cut to 100 characters. Until then it shows its id. A client renames a session by dispatching session/titleChanged to it. Titles are kept in the catalog, and a title that is set (at creation or by a rename) is never replaced by a prompt. Every change goes to the session's subscribers as session/titleChanged and to the root's as root/sessionSummaryChanged;

  • disposeSession, which closes the session's store and deletes it with its catalog entry;

  • turns, on a host that runs workers: a client subscribed to a chat dispatches chat/turnStarted (a user message with text). The prompt is submitted to Durable with the request id ahp:<clientId>:<turnId>, and once it is in the chat every subscriber gets the chat/turnStarted echo with the client's origin, then the answer as it streams: chat/responsePart, chat/delta and chat/reasoning, tool calls (chat/toolCallStart, chat/toolCallDelta as the arguments stream, chat/toolCallReady with the input, chat/toolCallContentChanged with the running output so far, and chat/toolCallComplete with the result), chat/usage, and chat/turnComplete, chat/turnCancelled or chat/error. The turn keeps the client's turn id in history. The same turn sent again (after a reconnect, say) is not run twice: the sender gets the action back with a rejectionReason. So is a turn sent while another runs, any other client action except chat/inputCompleted, the tool confirmations, the queue actions, session/configChanged, session/titleChanged and the MCP server start and stop requests, and a turn on a chat the client has not subscribed to. A client that subscribes during a turn gets it as the snapshot's activeTurn, with the response so far, and the rest streams. If a chat's stream breaks (its worker restarts), its subscribers are disconnected and reconnect for fresh snapshots;

  • media (see Media below): a turn's or steering/queued message's embeddedResource and resource attachments reach the model, a tool result's images and shared media are resource content, shared media is also a contentRef response part, and resourceRead serves all of it under the session's ahp-session:/<id>/content/<id> URIs;

  • / commands and ! shell commands, on a host that runs workers. initialize advertises completionTriggerCharacters: ["/"] and terminalCommandPrefix: "!". completions on a chat, for a message that starts with /, offers the built-in commands (compact, model, thinking, fork, new), the profile's module commands (see Extension modules), the prompt templates and the /skill:<name> commands that TUI Pi offers in the session's cwd with the profile's agentDir. A turn starting with a template or skill command is expanded as TUI Pi expands it. A built-in command runs on the idle chat: /compact [instructions], /model <provider>/<model>, /thinking <level>, /fork (a new chat forked from this one) and /new (an empty chat), shown as a turn with its result. A turn starting with ! runs the rest in the session's shell, cwd and env and shows the tail of its output; the model sees it in its next turn, as in TUI Pi. sessionConfigCompletions completes cwd with directories under the profile's roots;

  • input requests for agents' ask_user questions, when the host is given the sessions' workers. A pending question of one of the session's chats is listed in the session's inputNeeded (status InputNeeded) and is an inputRequest part of the chat's active turn after the ask_user tool call. The request id is the tool call's Durable task id and it has one question, answer: a single select over the question's choices, or free text. A question with choices whose document says freeText: true takes words too: its select allows free-form input (allowFreeformInput), and session.questions reports freeText (bd-dab67b). A client answers by dispatching chat/inputCompleted to the chat (accept with a submitted text or selected answer to answer; decline, cancel or a skipped answer cancels it). The first response wins: it is echoed to the chat's subscribers, and session/inputNeededRemoved goes to the session's. Any other response, such as an answer that is not one of the choices, comes back to its client only, with a rejectionReason. While a session works, its questions are read twice a second: a new one goes to the session's subscribers as session/inputNeededSet (and to a chat followed live as chat/inputRequested), and the session list shows the session as InputNeeded until it is answered;

  • tool call approvals, for the tools a profile lists in requireApproval (calls) and requireResultApproval (results). A beforeTool hook holds such a call before it runs, and the chat shows it pending-confirmation (a chat/toolCallReady without confirmed). A client approves or denies it by dispatching chat/toolCallConfirmed; an approved call runs, a denied one is cancelled and the model is told it was blocked (with the client's reasonMessage, if any). An afterTool hook likewise holds a listed tool's result, shown as a chat/toolCallComplete with requiresResultConfirmation, until a client dispatches chat/toolCallResultConfirmed; a denied result reaches the model as an error. The first decision wins and is echoed to the chat's subscribers; another comes back to its client only, with a rejectionReason. The wait and the decision are Durable state of the call's tool task, so a waiting call keeps waiting across restarts and a decision is never lost. Edited tool input is not supported;

  • steering, queued messages and cancel, on the same chats: chat/pendingMessageSet submits the message to Durable as a steer (steering) or follow-up (queued) with the request id ahp:<clientId>:<id>. Once Durable queues it, every subscriber gets the echo; a steer joins the run in progress at its next boundary and a queued message starts a turn after the run finishes, each as a chat/turnStarted with the message's id as turnId and queuedMessageId. Sent while the chat is idle, it starts at once. A newer steering message replaces a waiting one. chat/pendingMessageRemoved withdraws a waiting message. chat/turnCancelled on the turn in progress aborts the run; the turn ends cancelled and the queued messages are dropped. Snapshots show waiting messages as steeringMessage and queuedMessages. A message sent again, a removal of a message that already started, and a cancel of a turn that already ended are rejected to the sender. chat/queuedMessagesReordered puts the queued messages in the order given (unknown ids are ignored, unnamed ones follow in their order) in Durable's inbox too, so they start in that order;

  • model and thinking, when the host is given the sessions' workers. A session's snapshot has config with two sessionMutable properties, model (<provider>/<model>) and thinking (one of the thinking levels), holding the root conversation's current values. A client changes either or both by dispatching session/configChanged to the session (a merge, not replace); it is applied to the root conversation, so its next turn uses it, and echoed to the session's subscribers. Any other key or value comes back to its client only, with a rejectionReason;

  • MCP servers, tools and customizations, when the host is given the sessions' workers. A session's snapshot has serverTools, every tool its agent can use (built-in, extension and MCP tools, with their parameter schemas until they reach 1 MiB), and customizations: each of the profile's mcpServers as an mcpServer entry (id mcp:<name>, URI pi-daemon-mcp:/<name>, so its command, URL and env are never shown) with its state (starting, ready, error while it fails to start and is retried, or stopped), a directory for each directory the session's skills and prompt templates come from, with them as children, and a plugin for each of the profile's extension modules. While a session's store is open, its subscribers get session/mcpServerStateChanged whenever a server's state changes and session/serverToolsChanged whenever its tools do. A client stops a server by dispatching session/mcpServerStopRequested: the server is disconnected (a stdio server's process ends), its tools are taken away, and it is not reconnected, even when the session's store closes and reopens, until a client dispatches session/mcpServerStartRequested, which starts it again (or restarts a running one). Each request is echoed to the session's subscribers and followed by the server's state; a request for a server the session does not have comes back to its client only, with a rejectionReason. Stopped servers are kept in the worker's memory, so a worker restart starts them again. Enabling or disabling customizations is not supported;

  • createChat, on a host that runs workers, whose agents advertise capabilities.multipleChats with fork and sideChat. The client chooses the chat's ahp-chat:/ URI (not ahp-chat:/<session>/<n>, which names this host's conversations). source.kind: "fork" forks the source chat's Durable conversation at the end of a completed turn: the fork shows and continues from the history through that turn. sideChat forks the same way, also from the turn in progress (as committed so far), but shows only its own messages; its model still sees the source history, and source.selection is kept in its origin. Without a source the chat is an empty conversation with the session's agent. An initialMessage starts the chat's first turn. Subscribers of the session get session/chatAdded, and a subagent tool result names its chat with subagent content. Retrying a creation that succeeded succeeds again without creating anything. disposeChat stops a chat's work and removes it from its session (session/chatRemoved); its history stays in the session's store. The root chat goes with its session, and a subagent's chat with its tool call;

  • session lifecycle and marks. A session's snapshot is ready; if createSession could not set the requested model or thinking, it is creationFailed with a creationError (and session/creationFailed is sent) until a retried createSession succeeds (session/ready). Clients dispatch session/isReadChanged and session/isArchivedChanged; both are kept in the catalog and show as the IsRead and IsArchived status bits in the session list. A run that ends marks its session unread again (session/isReadChanged). A session's status is InProgress while it works and InputNeeded while it waits for an answer;

  • default chat. session/defaultChatChanged to one of the session's chats is kept in the catalog; without a choice, or after that chat is disposed of, the root chat is the default;

  • drafts. chat/draftChanged with a user message (at most 64 KiB) saves the chat's draft in the catalog for every client of the chat; without a draft it clears it. /chord clients read and save the same draft (conversation.draft, setDraft, watchDraft), and their changes reach AHP clients as chat/draftChanged; so do the daemon's own: an abort keeps the queued inputs it withdraws as the draft;

  • active clients. A client lists itself in a session's activeClients with session/activeClientSet (its own clientId, at most 128 tools) and leaves with session/activeClientRemoved, or automatically when it unsubscribes from the session or disconnects. The list is not kept across restarts, and the session's agent does not call client tools;

  • activity. While a chat is followed live, chat/activityChanged says what it is doing (Thinking, Responding, Running <tool>, then nothing), the session's subscribers get session/chatUpdated with the chat's activity and, for chats other than the root, its status, and session/activityChanged follows the default chat. The root chat's status follows the session's. A session's _meta holds its catalog name ({"pi-daemon": {"name": ...}}), and a rename sends session/metaChanged;

  • refused actions. chat/truncated (Durable history is append-only; fork the chat instead) and the session/workingDirectory* and chat/workingDirectory* actions (a session works in its one cwd) come back to the client with a rejectionReason;

  • file resources, on a host with profiles: resourceRead, resourceWrite (truncate, append and insert modes, createOnly, ifMatch against resourceResolve's etag), resourceList, resourceResolve, resourceMkdir, resourceCopy, resourceMove, resourceDelete and resourceRequest for file: URIs within the profiles' cwd roots. Every path is resolved to its real path first; one outside the roots, by .. or a symlink (including a dangling one), is refused with PermissionDenied, and a root itself cannot be moved or deleted. Moves and deletes act on a symlink itself, not its target. A file larger than fits in one message (about 3 MiB) is not read;

  • changesets, on a host that runs workers. Before a chat's write or edit call first changes a file, the file's content (up to 1 MiB of UTF-8 text) is recorded as Durable state of that chat's conversation; a fork starts with no changes of its own. A chat's changeset ahp-changeset:/<id>/<conversation> lists its tracked files (at most 256) whose content now differs from that record, each with before and after content references (read with resourceRead) and its added and removed lines. The session's changesets catalogue lists the chats with changes, described with their counts. invokeChangesetOperation runs diff, whose follow-up is the unified diff of one file or the whole changeset, and revert, which writes a file (or every file) back as it was, deleting a file the chat created. After a tool call finishes in a chat with live subscribers, or a revert, the session's subscribers get session/changesetsChanged and the changeset's subscribers changeset/contentChanged when they changed. Review marks and range operations are not supported;

  • resource watches: createResourceWatch on a file or directory within the roots returns an ahp-resource-watch:/ channel. Its subscribers get resourceWatch/changed batches (added, updated, deleted), filtered by excludes and includes (**, * and ? globs, or relative paths). The watch is released when its last subscriber unsubscribes or disconnects, or if nobody subscribes within a minute. At most 64 watches exist at once;

  • terminals, on a host that runs workers: createTerminal on a client-chosen ahp-terminal:/ channel starts the shell ($SHELL, which a profile env file may set, else /bin/sh) in a pseudoterminal in a session's worker, with the session's env. The session is the one a session claim names, or, for a client claim (which must name the creating client), the most recently active session whose working directory is cwd; else the latest session of a profile whose roots hold cwd (the shell starts in cwd); without a cwd, the most recently active session (as Culture opens terminals). cwd defaults to the session's and must lie under its profile's roots; cols and rows default to 80 × 24. The root state's terminals lists every terminal, and root/terminalsChanged follows creation, renames, claims, exits and disposeTerminal. A terminal's subscribers get its output as terminal/data, its title and working directory as the shell reports them (OSC 0/2 and 7) as terminal/titleChanged and terminal/cwdChanged, and terminal/exited with the exit code. The client holding the claim dispatches terminal/input, terminal/resized, terminal/titleChanged, terminal/cleared and terminal/claimed (which a session-claimed terminal also accepts from any client); each applies in order and is echoed, and anything else comes back with a rejectionReason. A snapshot holds the newest 256 Ki characters of output. At most 64 terminals exist at once. disposeTerminal, deleting the session and its worker exiting end the shell; terminals are live processes, not Durable state, so a restart ends them too. Their record outlasts it, though:

    • Saved: each terminal's title, cwd, size, claim, exit and newest output go to an owner-only file under the state directory's terminals/, at most every 2 s while there is output, and when it ends or the daemon stops.
    • After a restart or a crash: they are listed again as exited, their output in their snapshot. One still running then shows exit code 129 and "(ended by a restart)" in its title.
    • Removed: when disposed, or when their session is deleted.

    The host needs the script and stty utilities (util-linux on Linux, the BSD tools on macOS).

  • agent terminals: a profile module exporting { TerminalsExtension as extension } from pi-daemon's dist/index.js (as ask_user's InputExtension) gives its agents these tools:

    • terminal_open (an optional command, title, cwd, cols, rows: 120 × 40 by default);
    • terminal_send (text, then named keys: Enter, Tab, Escape, arrows, C-c, M-x, F1-F12, …);
    • terminal_read (the screen as a person sees it, rendered by a headless terminal emulator, or the tail with scrollback; optionally first waiting for waitFor, a regular expression, or idleMs of quiet output, up to two minutes);
    • terminal_list, terminal_resize and terminal_close.

    The shells run in the session's worker, cwd and env, as clients' terminals do. AHP lists them with a session claim naming the session and the tool call (toolCallId), so every client sees them. A client takes one over with terminal/claimed; while a client holds it, the agent's input is refused, until the claim is handed back to the session. An exited terminal stays listed until the agent closes it. A session's agent has at most 16.

All of it is derived from the session catalog and the sessions' Durable stores. The only AHP-specific records are the catalog's index from a client-chosen chat URI to its conversation, the disposed chats, the drafts, and each session's read and archive marks and default chat, all deleted with the session. A client whose unsent backlog exceeds 16 MiB is disconnected and catches up with reconnect. Other methods answer MethodNotFound, other channels NotFound, and no unimplemented capability is advertised.

Message origin and retries

AHP chat/turnStarted and chat/pendingMessageSet accept the kinds listed in _meta["pi-daemon/messageOrigins"]: user and systemNotification. Origin is presentation metadata, not model authority: both deliver ordinary user input to the model. The first accepted origin remains the origin shown in history.

Hosts with the origin-retry fix advertise _meta["pi-daemon/messageOriginRetry"]: "reject-conflict" in AHP initialize and the root snapshot, and in native /chord host.info. This means:

  • Within one session/conversation, a client/turn identity cannot be admitted again under the other origin. Conflicting AHP actions get a rejectionReason naming the origin conflict; native calls get turn_origin_conflict.
  • Same-origin retries keep their existing Durable submission. Cross-origin retries are refused even while pending or active, after completion, outside the snapshot window, after reconnect/restart, and during concurrent admission.
  • Existing ahp:<encodedClientId>:<turnId> user keys and ahp-n:<encodedClientId>:<turnId> notification keys are not rewritten. The worker checks the opposite spelling in Durable's full submission index before admitting work. A store already containing both spellings refuses either retry without running more work; it does not erase the old duplicates.
  • Built-in/shell commands use the same check before effects. Module commands also refuse opposite-origin reuse within their existing 1024-command retry window; they are notices, not transcript submissions.

Consumer transition: choose origin for a new turn once and keep that choice with its retry identity. Require both the desired messageOrigins kind and the messageOriginRetry policy before adopting notifications. On hosts lacking the policy, keep the previous origin for retries; the old messageOrigins flag alone does not promise safe adoption. An uncertain older user delivery must not be retried as a notification just because capabilities changed. A conflict is a safe refusal, not proof of completion; retry with the original identity, never mint another ID to evade it.

Native /chord limitation: conversation.submit currently has no separate origin argument. Keep its exact existing requestId, including existing ahp: keys; do not change prefixes to request notification rendering. The capability above covers safe refusal of conflicting legacy spellings, not a native input origin API. Native origin metadata orthogonal to the request ID requires a separate producer change. Plain native requests continue to deduplicate by their exact request ID.

ACP (/acp)

/acp is a WebSocket speaking the Agent Client Protocol (protocol version 1, via the pinned @agentclientprotocol/sdk) for editors. Each text frame is one ACP JSON-RPC message; the same bearer authenticates the upgrade. A client may offer the agent-client-protocol.v1 subprotocol, which is then confirmed, but need not offer one. It implements:

  • initialize, advertising loadSession and text, image, audio and embedded prompts. There are no ACP auth methods; the bearer is the authentication. Audio, and an embedded binary resource that is not an inline image, is stored in the session's media and referenced to the model as text (see Media).
  • session/new creates a catalog session in the request's cwd, which must be under the profile's cwd roots. The profile is the one named by the path (/acp/<profile>), else by _meta["pi-daemon/profile"], else the only configured profile. MCP servers come from profiles; servers the client offers are ignored.
  • session/load replays the session's committed root transcript as session/updates (user, agent and thought chunks, tool calls and their results; at most the newest 1024 turns), then sends available_commands_update and answers. session/new sends the same update after it answers. The commands are the session's prompt templates, skills and its profile's module commands.
  • session/prompt submits to the session's root conversation and streams its Durable agent events as session/updates: text and thinking deltas as agent message and thought chunks, tool executions as tool_call and tool_call_update. A tool result's images are image content (a resource_link once they pass 2 MiB per update), and media a tool shares is a resource_link in its result and in an agent message chunk. It answers end_turn once the turn settles, or cancelled. A text prompt naming a prompt template or skill is expanded, and one naming a module command runs it, answering end_turn with its notices as an agent message. A _meta["pi-daemon/requestId"] becomes the Durable request id, so a retried prompt is accepted once. One prompt runs per session per connection.
  • session/cancel aborts the running turn.
  • An ask_user question during a prompt becomes session/request_permission. Each choice is an option; a free-text question offers Yes and No. A cancelled request cancels the question.

A connection may use any session in the catalog. Closing it stops its streams; a running turn continues in its worker.

Durable views (/chord)

/chord serves Durable's own views and methods to JavaScript clients and the /web page. It is a WebSocket with the subprotocol pi-daemon.chord.v1, authenticated by the same bearer. Each text frame is JSON: a call { id, method, params } is answered by { id, result } or { id, error: { code, message } }. A *.watch* call opens a subscription named by its id; its items arrive as { sub, items } and its end as { sub, end }. A missing conversationId means the session's root conversation.

  • sessions.list { limit?, after?, withModel? }: a catalog page. Each entry has the session's title, profile, cwd, liveWork (true while it has unfinished work) and activeAt (its last activity). With withModel: true (pages of at most 100) each also has its root conversation's model (provider/modelId) and thinkingLevel, read from its worker. Session entries carry an empty env: a session's variables are never sent to clients.
  • host.info {}: { name, version, _meta }, the host and, under pi-daemon/sessionProfile, what its creates take; pi-daemon/ui ({ version: 1 }) says it has session UI slots.
  • sessions.create { profile?, cwd?, title?, model?, thinking?, requestId }: create a session and answer its catalog entry. profile defaults to the only profile and cwd to the profile's first root. The session's id derives from requestId, so a retry answers the same session; a retry naming another profile or cwd is already_exists. It also takes the session's own configuration (systemPrompt, appendSystemPrompt, env, tools, mcpServers, resources; see A session's own configuration), and a retry carrying it replaces the session's. A client-generated display title may set titleIsExplicit: false: it still displays, but is not an explicit session name for modules. A supplied title is otherwise explicit.
  • conversation.context { sessionId, conversationId?, at?, cursor? }: the conversation's model context as a provider replays it (reasoning, signatures, images inline; a compaction's summary first when compacted), a page at a time (at most 500 messages and about 8 MiB): { messages, compacted, at?, next? }. at is the entry it was read at; pass it with next as cursor for the next page of the same context while the session moves on. pi-client's and /web's /clone @host read a conversation so and conversation.import it into a session on another host. host.info's home (the host's home directory) maps a directory to the same place under another host's.
  • profiles.list {}: the profiles sessions.create accepts, as { items: [{ name, model?, thinking?, cwdRoots }] } (empty on a host that does not create sessions). pi-client uses it to pick a profile for /new.
  • sessions.dispose { sessionId }: delete the session's store and catalog entry.
  • session.rename { sessionId, title }: answer the renamed catalog entry.
  • session.models { sessionId }: { items }, the session profile's models (provider, modelId, name, thinkingLevels, contextWindow), as AHP lists them.
  • session.completions { sessionId, text, cursor? }: { items } to replace text[rangeStart, rangeEnd) with insertText. While the text up to the cursor is /name, the session's / commands (source: builtin, extension for profile module commands, prompt or skill); after /name , that command's arguments (kind: "argument": a module command's getArgumentCompletions, /model's models, /thinking's levels; at most 100, none after 2 s); after an @, the files and directories under the session's cwd. Hidden entries appear only after @., and nothing outside the cwd is listed.
  • conversation.command { sessionId, conversationId?, requestId, text }: run a ! shell command or a built-in / command (/compact, /model, /thinking, /fork, /new), as an AHP command turn does. It is recorded with its output as a user entry, which conversation.watch shows; a retry with the same requestId runs nothing. A ! command runs whatever the conversation is doing (bd-c16a22): while a run or a compaction goes on, its entry waits for the next boundary (status: "queued") and the result carries its output. A built-in command needs the conversation idle.
  • session.usage { sessionId, conversationId? }: conversation (the conversation's committed pi.usage: tokens and cost per provider/modelId and per tool), session (every conversation's pi.usage summed) and context. context.state is measured with tokens (the latest completed response's total, as TUI Pi's footer shows), provider, model and, when known, the model's contextWindow; or compacted / reset once the context changed after it; or empty before any response. usageTotal() sums a pi.usage document.
  • conversation.watchEvents { sessionId, conversationId?, tail? }: Durable agent events, approval_requested when a tool call or result starts waiting for approval, and notice when a profile module command runs or notifies. With tail (as conversation.watch's), the first event's (the snapshot's) entries are the newest only, with olderEntries; conversation.history reads the rest.
  • conversation.watch { sessionId, conversationId? } and session.watchTaskGraph { sessionId }: the view's value, then Chord delta operations that keep a replica current.
  • Both watches send images by reference, so an image-heavy session stays within a frame: an image block of a committed entry is { type: "image", mimeType, size, uri }, whose bytes session.media reads; one not yet committed (a tool result in flight) keeps only its type and size. The model context is unchanged.
  • conversation.submit { sessionId, conversationId?, requestId, content, whenBusy?, wait? }: submit text or content blocks. whenBusy is steer, followUp (the default) or reject. The requestId is Durable's, so a retry is accepted once. With wait: true the call answers with the settled submission. Text naming a profile module command runs it instead and answers { command, status: "done", notices } (duplicate: true for a retry). Text naming a prompt template or skill is expanded as TUI Pi does. There is no separate input-origin argument; see Message origin and retries before adopting notification rendering or switching transports.
  • conversation.abort, conversation.compact { instructions?, wait? }, conversation.configure (model and thinking only), conversation.fork { at } and conversation.reset { handoff? }. An abort withdraws every queued input of the conversation, whoever sent it; the daemon keeps their text as the conversation's draft (below), oldest first and before what was drafted.
  • conversation.draft { conversationId? } → { draft }: the conversation's chat draft, the same one AHP's chat/draftChanged saves (an AHP user message, or null), so pi-client and Culture share it. conversation.setDraft { conversationId?, text } → { draft } saves its text (empty or null clears it; a draft's model, agent and attachments stay); conversation.watchDraft { conversationId? } follows it: { type: "value", value: { draft } } now and on every change, by any client or the daemon. host.info's _meta["pi-daemon/chatDrafts"] says the host keeps drafts.
  • session.questions, session.answerQuestion { questionId, answer } and session.cancelQuestion { questionId } for ask_user questions.
  • session.document { kind, version, scope, history?, fork?, owner?, key? } → { value }: one Durable document of the session, read-only (null when it does not exist), named as its plugin defines it: kind, version, scope (session, conversation or task) and the history and fork semantics it declares (Durable checks them), with its owner's id (a conversation or task; not for session scope) and family key. session.watchDocument (same params) follows it (value, then ops). A daemon plugin's client part reads its plugin's state this way, with no method of its own.
  • session.changes { conversationId? } → { files }: the files the conversation's write and edit tools changed that differ from before, each { path, created, deleted, added?, removed?, revertible }; session.diff { conversationId?, path? } → { text }: a unified diff of one (or all) against how it was; session.revert { conversationId?, paths? } → { reverted }: put them (or every revertible one) back. pi-client's and /web's /diff use them.
  • session.media { sessionId, uri }: a media item of the session (see Media) as { data, mimeType, size }, its bytes base64. /web shows images inline and plays audio with it.
  • unsubscribe { sub }.

A connection may have 64 calls in flight and 64 subscriptions open. ChordClient, exported by the package, is a Node client of all of it.

Media

Images and audio live with the session that holds them, so every client, even one that was not connected when they were shared, can show or play them later. Each item is named by a session-scoped content URI, ahp-session:/<session>/content/<id>, which AHP resourceRead (and /chord session.media) serves as base64 with its contentType:

  • an image in a committed message (a user's image, an image the read tool or an MCP tool returned) is e<entry>-<block>, read from the session's Durable store;
  • other media is stored in the session's media/ directory, beside its Durable store, under its SHA-256. Durable state references it by URI.

Media survives restarts and is deleted with its session. One item holds at most 2 MiB and a session at most 256 MiB in 4096 items; anything larger is refused with an error that says so.

Attachments in. An AHP chat/turnStarted or chat/pendingMessageSet message may carry up to 16 embeddedResource attachments (base64 data and a contentType) and resource attachments (a file: URI under the profiles' cwd roots, or one of the session's own content URIs). PNG, JPEG, GIF and WebP images become image content the model sees. Anything else (audio, PDF, other image types) is stored and referenced to the model as a line of text: [Attachment "clip.mp3" (audio/mpeg, 52311 bytes): ahp-session:/<session>/content/<sha256>]. The turn's history shows each as a resource attachment. A command (/ or !) cannot carry attachments. ACP image prompts are image content, and ACP audio is stored the same way.

Shared media out. Any tool, built-in, MCP or a profile module's, can share a file with the operator without putting it in the model's context by returning it in its result's details:

return {
  output: [{ type: "text", text: "Captured the screen" }],    // all the model sees
  details: {
    media: [
      { path: "shot.png", mimeType: "image/png", title: "Screen" },  // read relative to the session's cwd
      { data: base64Audio, mimeType: "audio/mpeg", title: "Reply", description: "Spoken summary" },
    ],
  },
};

pi-daemon stores each item (at most 16 per result), replaces it in the committed details with { uri, mimeType, size, title?, description? }, adds it to the AHP tool result as resource content, emits a contentRef response part so clients show or play it inline, and sends ACP a resource_link. An item that cannot be shared (too large, a full store, an unreadable path) makes the result an error whose text says why; the other items are still shared.

Instance YAML configuration

serve reads ~/.config/pi/daemon/INSTANCE/config.yaml unless --config or PI_DAEMON_CONFIG selects another file. The file must be a regular file owned by the current user or root and not group/world writable. Unknown fields, aliases and duplicate keys are rejected. Relative paths resolve against the file's directory, and ~ expands to the home directory.

instance: work
stateDir: ./state
agentDir: ~/.pi/agent
authSeedFile: ~/.pi/agent/auth.json
api:
  enabled: true
  bind: 127.0.0.1
  port: 7463
  tokenFile: /run/secrets/pi-daemon-work
  allowInsecureHttp: false

Individual CLI flags override YAML values. With no bearer source configured, first launch generates an owner-only token at STATE_DIR/api-token.

Profiles

A profile defines what a session gets. Clients pick a profile by name when they create a session; the session's cwd must be one of the profile's cwdRoots or beneath one (symlinks are resolved), otherwise creation is refused.

profiles:
  personal:
    agentDir: ~/.pi/agent          # auth/models/settings; default: top-level agentDir
    model: anthropic/claude-sonnet-4-5
    thinking: medium               # off|minimal|low|medium|high|xhigh|max
    cwdRoots: [~/src, ~/notes]     # required
    tools: [read, write, edit, bash]   # built-in tools; default: all four
    extensions: [./extensions/personal.mjs]  # trusted Durable modules
    mcpServers:
      files: { command: mcp-files, args: [--stdio] }
      remote: { url: https://mcp.example/mcp }
    envFiles: [./personal.env]     # KEY=value lines for tools and servers
    sessionEnv: [APP_AGENT_ID, "APP_*"]  # variables a client may set per session
    sessionEnvFiles: ["{cwd}/../env.sh"]  # each session's own KEY=value file
    concurrency: 4                 # turns run at once; default 16
    requireApproval: [bash]        # these calls wait for a client's approval
    requireResultApproval: []      # these results wait for a client's approval
  reader:
    cwdRoots: [~/src]
    tools: [read]                  # read-only: no write, edit or bash exist

A built-in tool a profile does not list is never registered for its sessions. The built-ins are Durable's coding tools. As in TUI Pi, read also reads images (jpg, png, gif, webp, bmp): the image is resized to provider limits (BMP is converted to PNG) and attached to the result for the model.

The profile's <agentDir>/settings.json drives its sessions the way it drives TUI Pi: compaction (enabled, reserveTokens, keepRecentTokens), retry (enabled, maxRetries, baseDelayMs, maxAgentDelayMs, and provider.timeoutMs|maxRetries|maxRetryDelayMs), steeringMode and followUpMode, with TUI Pi's defaults. Edits apply to the next turn without a restart. An invalid file or field falls back to its defaults and is reported as an invalid_settings error, without its contents. Project .pi/settings.json files are not read.

Each session's tools run in that session's cwd with the daemon's environment, then the profile's envFiles, then the session's env files, then the session's own values. A client may set only the variables the profile lists in sessionEnv, and only nonsecret values. The daemon never changes its own process environment, so concurrent sessions never see each other's values.

sessionEnvFiles give each session variables of its own, credentials included, without a client sending them. Each entry is a path from the session's cwd; {cwd} is replaced by that cwd, so {cwd}/../env.sh is the env.sh beside the session's directory. An application that keeps each agent's identity and token there gets them in that agent's bash, ! commands, terminals and MCP servers. The files are read in the profile's worker each time a tool, command or terminal starts (MCP servers read them when they start), so a rewritten file applies from its next use; a missing file is skipped. Their values never reach the server process, logs or clients.

Each open session connects its profile's mcpServers itself: a stdio server runs in the session's cwd (or its own cwd) with the profile's envFiles, the session's env files and values and the server's env, so a server such as caco bd mcp stdio sees that session's agent id. A url server is reached over streamable HTTP. A server's tools are named mcp__<server>__<tool>.

How the model reaches a server's tools is its exposure, as in TUI Pi's MCP configuration (bd-64c8fa):

exposure The model
direct is offered the tools from the start
deferred finds them with tool_search, which loads the best matches into its conversation from its next call on (codemode means this too: pi-daemon has no codemode)
hidden never sees them

toolExposure overrides it for single tools: keys are exact tool names, or patterns where * matches anything; an exact name wins, then the first pattern that matches.

mcpServers:
  ctx:
    command: ctx
    args: [mcp, stdio]
    exposure: deferred
    toolExposure: { ctx_get: direct, ctx_search: direct }

Tools with no exposure configured keep the default: all offered while a session has at most 32 of them, all through tool_search beyond that. A session's own MCP servers take the same settings. The session's MCP document lists each tool's effective exposure. A server that crashes or fails to start is restarted with backoff, and at once when one of its tools is called; a call it dies during is an error result. Opening a session waits up to 10 seconds for its servers; a slower one adds its tools when it is up. Servers stop when the session's store closes. AHP clients see each server's state and stop and start it (see AHP).

Long conversations are compacted automatically under the compaction settings: the older part is summarized and later turns continue from the summary. Clients can compact on demand with /chord conversation.compact { sessionId, instructions?, wait: true }, which also works with automatic compaction off. It returns how the compaction ended: compacted, skipped (nothing old enough to summarize), aborted, or failed with the summarizer's error. Each compaction_end agent event carries the same outcome, so a failed automatic compaction is reported rather than passing silently. A turn whose automatic compaction fails still runs on the full history, and the next turn tries again.

Every session gets TUI Pi's system prompt for its tools and directory: Pi's preamble (or SYSTEM.md), tool list and rules, APPEND_SYSTEM.md, the AGENTS.md/CLAUDE.md context files, skills and the cwd. They are read from the profile's agentDir and the session's directory exactly as TUI Pi reads them with extensions off and the project trusted. Skills from configured Pi packages are used only when already installed; the daemon never installs them. A directory's files are read once per worker, so the prompt stays the same between turns and provider prompt caching works; restart the worker to pick up edits.

Sessions use the profile's agentDir exactly as TUI Pi does: the same auth.json and models.json, through Pi's own model runtime. A model added to models.json is offered without a restart: the profile's worker watches the directory (one file watch, nothing runs until the file changes) and reads the file again when it changes, so sessions and model pickers have it from their next call (bd-a183e7). A /login in TUI Pi reaches running sessions, and an OAuth token a session refreshes is written back to auth.json under Pi's file lock, so TUI Pi and the daemon never refresh the same token twice.

An OAuth provider's request that fails on authentication before any output, such as GitHub Copilot's transient 401 "You do not have access to the organization tied to the API key", is sent again (bd-816e47). Before each retry the provider's token is refreshed, under auth.json's lock as any refresh, and the retries wait 2 s, 30 s, then 2 minutes. The agent sees none of it; the last failure passes through. pi-ai does not retry a 401, and Pi's providers only fetch a new token when the cached one expires, so such a blip used to fail the run.

A session's own configuration

An application that runs agents with different roles on one profile gives each session its own prompt, variables, tools, MCP servers and resources when it creates the session (bd-a171a8): in AHP createSession's config, or as /chord sessions.create params. The profile is the catalog of what sessions may use; each session composes its own from it.

{
  "cwd": "/home/me/agents/controller/checkout",
  "systemPrompt": "You are the cluster controller.",
  "appendSystemPrompt": "Report blockers to the operator.",
  "env": { "APP_AGENT_ID": "controller" },
  "tools": ["read", "bash"],
  "mcpServers": {
    "app": { "command": "myapp", "args": ["mcp", "stdio"], "env": { "APP_MODE": "agent" } }
  },
  "resources": {
    "extensions": ["myapp-controller"],
    "mcpServers": [],
    "skills": ["/home/me/agents/controller/skills"],
    "promptTemplates": ["/home/me/agents/controller/prompts"],
    "contextFiles": ["/home/me/agents/controller/ROLE.md"]
  }
}
Key Effect for this session
systemPrompt Replaces Pi's preamble, tool list, rules and docs, as SYSTEM.md does (at most 256 KiB)
appendSystemPrompt Replaces APPEND_SYSTEM.md (at most 256 KiB)
env The session's variables: names the profile's sessionEnv allows
tools Exactly these of the profile's built-in tools
mcpServers MCP servers the session defines itself, { command, args?, cwd?, env? } or { url } as in a profile (at most 32); one named like a profile server replaces it
resources.extensions Exactly these of the profile's extension modules, by module name (file name without extension) or configured path
resources.mcpServers Exactly these of the profile's MCP servers, by name
resources.skills Exactly these skill files or directories
resources.promptTemplates Exactly these prompt template files or directories
resources.contextFiles Exactly these context files, in order, in place of the AGENTS.md files

A list that is present means exactly those entries ([] means none); a missing one keeps the profile's. Code that runs in the worker stays the operator's: extension modules are always the profile's own, so a session can leave some out but never load another. A session's own MCP servers run as its own processes, in its cwd (or their cwd, an absolute path) with the profile's env files, the session's variables and the server's env, exactly as the profile's servers do; one that runs a command is allowed only for a session that has bash, since it runs what the session could run itself. Skills, templates and context files are existing absolute paths within the profile's cwdRoots or its agentDir. Anything else is refused (InvalidParams over AHP, invalid_session_profile or invalid_env over /chord; PermissionDenied or resource_outside_roots for a path outside those directories) and nothing is created or changed. Themes style a terminal UI, which the host does not have; clients keep their own.

sessionEnv lists names, or PREFIX* for every name with a prefix (* for any name). A pattern never matches PATH, HOME, USER, SHELL, PWD, LD_PRELOAD, LD_LIBRARY_PATH, DYLD_INSERT_LIBRARIES or NODE_OPTIONS; list those by name to allow them.

The configuration is kept in the catalog with the session, so it holds across restarts, and it changes nothing for other sessions. A create on an existing session (AHP: the same profile and cwd; /chord: the same requestId) that carries any of these keys replaces the session's whole configuration with what it carries. That applies to the open session from its next call: tools, prompt and modules at their next use, and MCP servers at once (servers it no longer names stop, new ones start, and all restart when its variables change). A create without any of them leaves the configuration as it is, so older clients behave as before. A session imported with conversation.import gets its configuration on its first turn. Clients never see a session's prompt or variables: AHP snapshots do not include them and /chord session records carry an empty env.

Hosts that take a session's own configuration advertise it as pi-daemon/sessionProfile in the AHP initialize result's and root state's _meta, and in /chord host.info's _meta, listing the config keys, resource kinds and MCP transports they take. Older hosts lack the key (AHP) or answer method_not_found (/chord), so a client can fail closed.

Extension modules

A profile's extensions are trusted Durable extension module files, loaded into its worker (never the server).

A module that does not load (one that imports a file that is not there, say) stops its profile's worker from starting:

  • The log: the daemon logs pi_daemon_worker_init_failed with the profile and the reason (profile cacophony unavailable: Cannot find module …). The reason names paths only: quoted file contents are dropped.
  • Sessions: creating a session on the profile (over /chord or AHP), or using one, fails with that reason at once.
  • Retries: the worker is retried after 1 s, doubling up to a minute, so an edit that fixes the module is picked up within a minute.
  • Other profiles are unaffected.

A module exports at least one of:

  • extension: a Durable extension (hooks, tools, sections, wraps, tasks), or a function returning one. It is installed under its name, which must not be another module's, builtin-tools or pi-prompt. Its tools return their result's text and images as output (pi-durable 1.2 renamed content); a tool that still returns content, and no output, has it read as its output.
  • observe(event, { sessionId, conversationId, state }): a read-only observer for side-effect-only work such as narration. It sees each conversation's agent events shaped like the coding agent's (agent_start, turn_start, message_end, tool_execution_end, agent_end, …) as frozen copies, and it has no handle that can steer the agent; anything that steers is a hook. It is awaited per event, and a slow observer skips the events Durable coalesces. state is the module's per-session state, the same its commands use. snapshot(doc) reads one of the session's Durable documents (a session-scoped defineDoc token, such as one the module's tools commit to), read-only; undefined if it was never written. compact(instructions?) is the one thing it may ask of the session: it starts Durable's background compaction of the conversation (the run goes on; the summary is placed at the next boundary) unless one is running, and resolves to whether one started. agent-utils' per-session context limit uses it.
  • commands: slash commands, by name, that run in the session's worker instead of a turn (below).
// ~/.config/pi/daemon/extensions/gate.mjs
export const extension = {
  name: "gate",
  hooks: [{ task: "pi.tool", handlers: { beforeTool: (call) =>
    call.name === "bash" ? { block: "bash is paused" } : undefined } }],
  sections: [{ key: "house-rules", render: () => "Keep answers short." }],
};
export function observe(event) {
  if (event.type === "agent_end") process.stderr.write("turn finished\n");
}

Editing a module file reloads it: the next model request uses the new version. A module that fails to load stops its profile's worker from starting; a reload that fails keeps the last good version and is reported as an invalid_extension error. A command name that is already taken (below) only keeps that one module from loading. Only the edited file is re-imported, not modules it imports, and each reload keeps the previous version in memory until the worker restarts.

Session names and titles in modules

From pi-daemon 1.14.0, ModuleCommandContext (both run and getArgumentCompletions) and ObservedEventInfo expose two read-only getters:

const explicitName = await ctx.getSessionName(); // string | undefined
const { name, title } = await ctx.getSessionMetadata(); // raw catalog labels

getSessionName() is the explicit human-facing name: the client-supplied creation title or latest title set by /name (AHP session/titleChanged or /chord session.rename), otherwise the optional unique catalog name. It never uses an automatic first-prompt title, an ID or an environment variable. An unnamed session returns undefined. Explicit-title provenance is stored in the catalog and survives restarts. Titles already present when upgrading from before 1.14.0 are ambiguous and are not treated as names; explicitly rename such a session to make its name available. Existing unique catalog names remain readable.

A client can mark a generated creation title with titleIsExplicit: false over /chord. pi-client and /web do so for unnamed clones and copied /cd titles; pi-client imports and TUI /migrate do so for generated import titles. An explicit /clone NAME, --name or later /name is still an explicit name.

Each call reads the current server-owned catalog over worker IPC. A retained context sees a rename on its next call, without a restart or another prompt. getSessionMetadata() returns a frozen SessionMetadata snapshot with two fields:

  • name: the catalog's optional unique name, or undefined when unset.
  • title: the catalog title, or undefined when empty. This includes titles automatically assigned from prompts; it is not necessarily human-chosen.

Neither raw metadata field falls back to an ID, environment variable or the other field. There is no setter. Both getters reject if the session no longer exists or the catalog cannot be reached; a failed read is not an unnamed session.

All conversations within a session (root, fork, side chat and subagent) read that containing session's labels, not a per-conversation title. A clone into a separate session reads the new session's catalog row, not a live link to its source. No name/title-changed observer event is emitted: modules can read the getter on ordinary observed events or when a command needs the labels. Hosts before 1.14.0 lack these methods, so modules supporting older hosts should feature-detect them.

Module commands and state

A module's commands export maps names to { description, run(args, ctx), getArgumentCompletions?(prefix, ctx) }:

// ~/.config/pi/daemon/extensions/tts.mjs
export const commands = {
  tts: {
    description: "Toggle spoken narration of agent output",
    async run(args, ctx) {            // args: the text after "/tts", trimmed ("" if none)
      await ctx.state.set("enabled", args === "on");
      ctx.notify(`Narration ${args === "on" ? "on" : "off"}`);
    },
    // Optional, as Pi's: completions of the text typed after "/tts " (each value replaces it).
    getArgumentCompletions: (prefix) => ["on", "off"].filter((v) => v.startsWith(prefix)).map((value) => ({ value })),
  },
};
export async function observe(event, ctx) {
  if (event.type === "message_end" && (await ctx.state.get("enabled"))) { /* speak */ }
}
  • Names match ^[a-z][a-z0-9-]*$. A name another module already provides, a built-in command's (compact, model, thinking, fork, new), skill, or a prompt template's in the profile's agentDir is a load error for that module, logged as invalid_extension; the other modules still load. A module command hides a project prompt template of the same name.
  • A prompt that is exactly /name or /name <args>, on any surface (an AHP turn or steering/queued message, an ACP prompt, a /web or /chord submission), runs the command instead of a turn, even while the chat is busy. The text is not sent to the model, and no entry or model turn is created. The run is recorded in the session's store before it starts, so a retry with the same request id (the newest 1024 per session are remembered) runs it at most once. An error the command throws is shown as a notice, /<name> failed: <message>.
  • ctx.sessionId and ctx.conversationId name where it ran. ctx.state.get(key) and ctx.state.set(key, value) read and write JSON values (undefined removes a key) in a Durable document of the session's own store, scoped by the module's name: its file name without the extension (tts.mjs is tts). It survives worker and daemon restarts and holds at most 64 KiB per module.
  • ctx.notify(text) shows text to the clients following the conversation: AHP sees a turn with the command as its message and the text as system notifications, completed at once (notices during a running turn join that turn); ACP sees an agent message during the prompt; /web and /chord conversation.watchEvents see a { type: "notice", texts, command? } event. Notices are never in the model's context or the transcript, and they do not change the session's working or unread status.
  • AHP completions for / and ACP available_commands_update (sent after session/new and session/load) list module commands with their descriptions, beside prompt templates and skills.

Running a command from module code

From pi-daemon 1.15, module code that acts later, such as a loop or a timer in a module's Durable task, can run a client's text in a conversation, as conversation.submit and conversation.command would: /compact, a module command, a prompt template or skill, a ! command, or else a prompt. It creates a pi-daemon.command task, whose name and version the worker publishes as globalThis[Symbol.for("pi-daemon.commandTask")] (absent on older hosts):

const spec = globalThis[Symbol.for("pi-daemon.commandTask")];
// Durable's createTask reads only the token's name, version and initial().
const token = { definition: { name: spec.name, version: spec.version, initial: () => ({ phase: "run" }) } };
await tx.createTask(token, { text: "/compact", requestId: `loop:${taskId}:${tick}` }, {
  ownership: { kind: "conversation" }, conversationId, background: true,
});
  • A ! command or a built-in / command runs as conversation.command runs it, recorded with its output; a built-in one waits for the conversation to be idle.
  • Anything else goes as conversation.submit with whenBusy: "followUp": a module command runs, a prompt template or skill is expanded, a prompt starts a turn (or follows the running one).
  • requestId goes to the command or submission, so a task phase run again after a crash runs it once. The task completes with the command's or submission's result.

Session UI slots

From pi-daemon 1.15, a module publishes UI that every client of the session shows, such as a backdrop behind the transcript, through ui beside state (ctx.ui in a command, info.ui in an observer). The design is agent-utils' docs/design/session-ui-slots.md.

const image = await ctx.ui.image(pngBytes, "image/png", { alt: "a pond at midnight" });
await ctx.ui.set("backdrop", {
  kind: "image-set",
  images: [image],
  current: 0,
  hints: { placement: "backdrop", opacity: 0.3, cycleMs: 18000, transition: "crossfade" },
});

The API

Call Does
ui.image(bytes, mimeType, { alt? }) Stores a PNG, JPEG or WebP image (at most 2 MiB) as session media. Returns { uri, mimeType, digest, width?, height?, alt? } to list in a slot.
ui.set(slot, value) Publishes a slot: a JSON object with a kind. The host stamps owner (the module's name), rev and updatedAt, and returns { rev }.
ui.clear(slot) Removes this module's slot.
ui.get(slot) Reads a slot as published, or undefined.
ui.release(image) Deletes an image this module stored with ui.image() (the image or its uri) that no slot lists, such as the oldest painting of a ring. Returns { released }: false when a slot still lists it, or it is not this module's image.

The limits

  • Slot names match ^[a-z][a-z0-9-]{0,31}$.
  • A slot belongs to the module that set it until that module clears it. Another module's write fails with not_owner.
  • A session holds at most 16 slots, a slot at most 16 KiB of JSON, and an image-set at most 32 images, each one this session's media.
  • Writes to one slot fail with rate_limited beyond 4 a second.
  • Images stored with ui.image() are the UI's, not the transcript's. After each image-set write or clear, the host deletes those no slot lists, keeping the newest 32 (stored but not yet published). A painting module therefore never fills the session's media store (256 MiB) (bd-8620a0). Media a tool or client shares is never deleted, even when its bytes are the same as a painting's.
  • Errors carry a code: invalid_argument, not_owner, limit, rate_limited or unavailable.

What clients see

  • The store. The slots live in one Durable document of the session, { kind: "pi.ui", version: 1, scope: "session" }, holding { slots, rev }. It survives restarts.
  • /chord. session.document and session.watchDocument read and follow it at that address. host.info advertises _meta["pi-daemon/ui"] = { version: 1 }.
  • AHP.
    • A followed session's state carries _meta["pi-daemon/ui"] = { version: 1, slots }, beside its other _meta keys.
    • A change is sent as session/metaChanged at most twice a second.
    • initialize advertises _meta["pi-daemon/ui"] = { version: 1 }.
    • The bridge's TUI Pi sessions have no slots: their plugins paint for themselves.
    • Images are read with resourceRead.

Home Manager service

The flake exports homeManagerModules.default and the equivalent homeManagerModules.pi-daemon. The normal installation is one per-user service with its profiles declared in Nix:

{
  imports = [ inputs.pi-daemon.homeManagerModules.default ];

  services.pi-daemon = {
    enable = true;
    profiles = {
      personal = {
        cwdRoots = [ "~/src" ];
        model = "anthropic/claude-sonnet-4-5";
        mcpServers.notes = { command = "/path/to/notes-mcp"; args = [ "stdio" ]; };
        envFiles = [ "~/.config/pi/daemon/personal.env" ]; # secrets stay out of the store
      };
      reader = { cwdRoots = [ "~/src" ]; tools = [ "read" ]; };
    };
  };
}

pi-daemon runs one instance per user. This installs pi-daemon.service (systemd), com.pi-daemon (launchd) or pi-daemon (supervisord, when a nix-on-droid supervisord option surface is present), listening on 127.0.0.1:7463 with a generated bearer at ~/.local/state/pi-daemon/api-token. The profiles are rendered into an immutable YAML file passed with --config; profile paths must be absolute or start with ~/. pi-daemon status reports whether it reached /readyz. An environment value reaches the service whole, with its spaces, quotes, backslashes and percent signs: a systemd Environment= entry that needs quoting is written as one quoted word. The home-manager-service flake check installs exactly this service and probes /readyz. These identities cannot collide with Cacophony's cacophony.service / com.cacophony.lifecycle identities.

Every option sits directly under services.pi-daemon: profiles (or a hand-written configFile instead), stateDir, agentDir, authSeedFile, environment, extraArgs, restartSec, stdoutLog, stderrLog, api.* and watchdog.*, next to enable, package and mutableRuntime:

{
  services.pi-daemon = {
    enable = true;
    stateDir = "${config.xdg.stateHome}/pi-daemon";   # the default
    agentDir = "${config.home.homeDirectory}/.pi-work";
    # Optional: otherwise a distinct agentDir seeds once from Pi's normal auth.
    authSeedFile = "${config.home.homeDirectory}/.pi/agent/auth.json";
    profiles.work.cwdRoots = [ "~/work" ];
    api = {
      bind = "127.0.0.1";
      port = 7463;
      # Optional: otherwise stateDir/api-token is generated on first launch.
      tokenFile = config.sops.secrets.pi-daemon-api-token.path;
    };
  };
}

The former services.pi-daemon.instances option is removed; setting it fails evaluation with a message to set the options directly under services.pi-daemon. A genuinely separate trust domain (another uid, or a test) runs pi-daemon serve --instance NAME with its own state directory and port outside the module.

home-manager switch installs the package, creates native-supervisor log parents, and enables the service:

systemctl --user status pi-daemon
launchctl print "gui/$UID/com.pi-daemon"
supervisorctl status pi-daemon

With the API enabled the module also runs pi-daemon-watchdog / com.pi-daemon.watchdog by default. This is a separate, small Node process, so it can detect a daemon event loop that still owns a PID and listening socket but emits no HTTP response. Every interval it expects the API root's exact unauthenticated 401.

The defaults probe every 30 seconds with a 5-second deadline. A semantic response over 2 seconds is degraded latency, not a restart trigger. Two consecutive hard failures trigger one recovery of only the pi-daemon service. The watchdog persists the attempt before invoking the supervisor in STATE_DIR/watchdog-v1.json; it will not try again while the component remains unhealthy. Any later semantic success clears that latch and starts a new failure epoch. This makes disk/load pressure visible without producing a restart storm.

A busy machine makes a live daemon slow, not dead, so the failures needed scale with the 1-minute load average per CPU core: at a load of 8 per core, two become sixteen. Recoveries are also at least 10 minutes apart. A recovery held back for either reason is logged as pi_daemon_watchdog_recovery_deferred with the reason, the failures so far, the threshold and the load. Probe lines are logged when a component's state changes; otherwise healthy probes once an hour and failing ones every five minutes.

Systemd uses its exact service name and the configured bounded 30-second TERM stop timeout. Supervisord receives the same rounded stopwaitsecs only when the imported typed program schema exposes that option; current Nix-on-Droid schemas without it evaluate successfully and retain supervisord's own default rather than failing module evaluation on an unknown key. Launchd first records the exact job PID, sends TERM to the exact gui/$UID/com.pi-daemon target, and polls both semantic health and that PID. An exited or changed PID proves graceful drain; the watchdog leaves a load-delayed replacement alone even if it has not bound yet. Only the same stuck PID may receive one recorded kickstart -k escalation. Probe and recovery records contain component, phase, latency, status/error code, supervisor, duration, and whether escalation occurred—never URLs, bearer values, response bodies, or private paths. Tune or disable it only when another external semantic supervisor owns the same duty:

services.pi-daemon.watchdog = {
  enable = true;
  intervalMs = 30000;
  probeTimeoutMs = 5000;
  degradedAfterMs = 2000;
  failureThreshold = 2;
  gracefulTimeoutMs = 30000;
};

Inspect the independent service and its persisted decision instead of inferring health from the daemon PID:

systemctl --user status pi-daemon-watchdog
launchctl print "gui/$UID/com.pi-daemon.watchdog"
supervisorctl status pi-daemon-watchdog

The service always receives --instance default; an optional configFile receives --config PATH, while module-managed identity/path/API values remain later CLI overrides. An optional external tokenFile contributes only its path to the Nix service definition, never its bearer bytes. A normal sops-nix secret.path is supported: Pi Daemon resolves that symlink chain, opens the canonical final target with no-follow protection, and validates the opened inode is a current-user-owned, owner-only, bounded regular file. This makes SOPS generation rotation safe across service restarts without admitting a second traversal or weakening final-file checks. Omit tokenFile only when the generated regular stateDir/api-token is sufficient. Never copy the bearer through argv, logs, chat, or a world-readable temporary path. extraArgs cannot override module-managed identity, path, or API arguments.

Closure availability and Nix-on-Droid bootstrap

Pi Daemon remains a Node service even though the interactive Pi CLI can be packaged as a Bun binary. Its pinned SDK dependencies are installed by npm at build time. npm prune reproducibly aborted with double free or corruption on Astra under Nix-on-Droid. Limiting UV_THREADPOOL_SIZE did not fix it; UV_USE_IO_URING=0 passed with Node 22.23.1 but still failed with 22.23.2, so neither environment workaround is relied on.

Instead, installation creates a fresh production tree directly in the package output with npm ci --omit=dev --ignore-scripts --offline, using the exact lockfile and the existing Nix npm cache. As in npmConfigHook, it patches shebangs before rebuilding dependency install scripts, then patches them again afterward. The build-time node_modules stays untouched; compiler and test dependencies are not shipped. Installing at the final path avoids a second recursive copy of every dependency without skipping production filtering, normal fixup, or installed-entry-point checks. Prebuilt aarch64-linux closures remain useful for speed, but are no longer required for this failure.

There is no repository-owned closure publisher. Every fleet system that builds this flake already pushes its result to the shared signed cache, so an extra credentialed CI publisher added maintenance and stale cache-name pinning without improving availability. Substituters and signing keys are host-declared fleet state and rotate independently of this repository.

Release staging may pre-materialize only the exact fixed-output npm cache with nix build .#npm-deps. The derivation retries at most three times, and only for reviewed transient transport classes such as curl 55/56 mid-transfer errors, curl 92 HTTP/2 framing/stream resets or timeout; integrity, identity, lock, and unknown failures remain immediate. Once the path is in the Nix store or a trusted substituter, nix build --offline .#npm-deps is the no-registry readiness proof before the full package build.

To make a device-ready closure deliberately, build the exact target on a host that can execute it and let that host's normal cache push publish it:

TARGET_SYSTEM=aarch64-linux
out=$(nix build --no-link --print-out-paths --option require-sigs true \
  "github:harryaskham/pi-daemon/REV#packages.$TARGET_SYSTEM.pi-daemon")
"$out/bin/pi-daemon" version

The aarch64-linux package builds, installs production dependencies, and runs the installed version check on-device and under emulation. Installation also checks that the SDK is present and the build-only TypeScript package is absent. Its full Node test suite remains intentionally gated on native Linux x86_64 and macOS: under QEMU, bounded subprocess tests exceed their real-hardware deadlines. To verify a local build without switching the device, run from a checkout containing the workaround:

nix build .#default --builders '' --no-link --print-out-paths -L

For proof of local execution, use --rebuild if the output is already cached. Add --offline when all build inputs are already in the store. A consumer pinned to an older revision must first adopt a revision containing this change. Network/cache failures and unrelated Nix crashes are separate from this npm workaround.

Shutdown

SIGTERM and SIGINT stop serve cleanly. Repeated stop signals during shutdown are absorbed rather than falling back to the platform default termination.