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.
- To keep a way back, copy
- Profile modules' documents need a
checkpointWhenpolicy 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.
() => falsekeeps changes as deltas, as before.
- agent-utils has it from
- The crash-loop guard stays off
(
restartLimit: Infinity), unless a module'sharnessOptionssettings 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.
- To keep a way back, copy
- 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 itsthinkingLevel, a view'spi.livekeepsrunFailureafter a run that ended without an answer, and a compaction's status there names itstrigger(events and snapshots keepreason). - 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.
- agent-utils reads both from
- A module's
afterToolhook 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
[31mand 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
retrysettings, 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.mjscompares 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 /healthzreturns 200 while the process is serving.GET /readyzreturns 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, requiresAuthorization: Bearer <token>with the instance's bearer (by default the owner-onlySTATE_DIR/api-token). Missing or wrong bearers get 401 before any route runs. /ahp,/acpand/chordrun sessions in their profiles' workers. Other authenticated paths return 404./webis 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=Strictcookie (Secureover TLS). The cookie admits only same-origin/chordWebSocket upgrades, lasts 12 hours and is held in memory. The page remembers the bearer in the browser'slocalStorage, 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, else0.8.0) andping;the
ahp-root://channel (subscribe,unsubscribe,initialSubscriptions), whose state is the profiles asagentsand the session count asactiveSessions;listSessions, paged most recently active first, kept live byroot/sessionAdded,root/sessionRemovedandroot/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 asahp-chat:/<id>/<conversation>, each with itsorigin:forkorsideChatwith the source chat and turn,toolwith the chat and tool call that ran a subagent (whose chat isread-only), oruserfor an empty chat;chat channels, whose snapshot holds the newest turns (50 unless
view.turnsasks for another number, fewer if they would not fit in one message) and aturnsNextCursor.fetchTurnswith that cursor sends the subscriber achat/turnsLoadedaction 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_metacarries 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) andcontextState(measured, orcompacted/resetonce the context changed after that response, whencontextTokensis 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'spi.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'sactiveTurn;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 reportedmissingso the client subscribes again. After a daemon restart the new incarnation'sserverSeqstarts above any earlier one, so reconnecting clients always get snapshots;resolveSessionConfig, whose schema asks forcwd(default: the profile's first cwd root, or the givenworkingDirectory),model(provider/model),thinkingand an optionaltitle, with the profile's defaults filled in;createSessionwith a profile (provider, optional when only one profile is configured), a cwd under the profile's roots (config.cwdor the firstworkingDirectoriesentry) and the resolvedconfig. The session id is theahp-session:/<id>channel. Retrying with the same profile and cwd succeeds without creating anything; a different session at that URI isSessionAlreadyExists. A model or thinking level inconfigis applied to the session's root conversation, and atitlenames the session. Theconfigmay also carry the session's own configuration (see A session's own configuration), which hosts that take it advertise in theinitializeresult's and root state's_metaunderpi-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/titleChangedto 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 assession/titleChangedand to the root's asroot/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 idahp:<clientId>:<turnId>, and once it is in the chat every subscriber gets thechat/turnStartedecho with the client'sorigin, then the answer as it streams:chat/responsePart,chat/deltaandchat/reasoning, tool calls (chat/toolCallStart,chat/toolCallDeltaas the arguments stream,chat/toolCallReadywith the input,chat/toolCallContentChangedwith the running output so far, andchat/toolCallCompletewith the result),chat/usage, andchat/turnComplete,chat/turnCancelledorchat/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 arejectionReason. So is a turn sent while another runs, any other client action exceptchat/inputCompleted, the tool confirmations, the queue actions,session/configChanged,session/titleChangedand 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'sactiveTurn, 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
embeddedResourceandresourceattachments reach the model, a tool result's images and shared media areresourcecontent, shared media is also acontentRefresponse part, andresourceReadserves all of it under the session'sahp-session:/<id>/content/<id>URIs;/commands and!shell commands, on a host that runs workers.initializeadvertisescompletionTriggerCharacters: ["/"]andterminalCommandPrefix: "!".completionson 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.sessionConfigCompletionscompletescwdwith directories under the profile's roots;input requests for agents'
ask_userquestions, when the host is given the sessions' workers. A pending question of one of the session's chats is listed in the session'sinputNeeded(statusInputNeeded) and is aninputRequestpart of the chat's active turn after theask_usertool 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 saysfreeText: truetakes words too: its select allows free-form input (allowFreeformInput), andsession.questionsreportsfreeText(bd-dab67b). A client answers by dispatchingchat/inputCompletedto the chat (acceptwith a submitted text or selected answer toanswer;decline,cancelor a skipped answer cancels it). The first response wins: it is echoed to the chat's subscribers, andsession/inputNeededRemovedgoes 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 arejectionReason. While a session works, its questions are read twice a second: a new one goes to the session's subscribers assession/inputNeededSet(and to a chat followed live aschat/inputRequested), and the session list shows the session asInputNeededuntil it is answered;tool call approvals, for the tools a profile lists in
requireApproval(calls) andrequireResultApproval(results). AbeforeToolhook holds such a call before it runs, and the chat shows itpending-confirmation(achat/toolCallReadywithoutconfirmed). A client approves or denies it by dispatchingchat/toolCallConfirmed; an approved call runs, a denied one is cancelled and the model is told it was blocked (with the client'sreasonMessage, if any). AnafterToolhook likewise holds a listed tool's result, shown as achat/toolCallCompletewithrequiresResultConfirmation, until a client dispatcheschat/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 arejectionReason. 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/pendingMessageSetsubmits the message to Durable as a steer (steering) or follow-up (queued) with the request idahp:<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 achat/turnStartedwith the message's id asturnIdandqueuedMessageId. Sent while the chat is idle, it starts at once. A newer steering message replaces a waiting one.chat/pendingMessageRemovedwithdraws a waiting message.chat/turnCancelledon the turn in progress aborts the run; the turn ends cancelled and the queued messages are dropped. Snapshots show waiting messages assteeringMessageandqueuedMessages. 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/queuedMessagesReorderedputs 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
configwith twosessionMutableproperties,model(<provider>/<model>) andthinking(one of the thinking levels), holding the root conversation's current values. A client changes either or both by dispatchingsession/configChangedto the session (a merge, notreplace); 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 arejectionReason;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), andcustomizations: each of the profile'smcpServersas anmcpServerentry (idmcp:<name>, URIpi-daemon-mcp:/<name>, so its command, URL and env are never shown) with its state (starting,ready,errorwhile it fails to start and is retried, orstopped), adirectoryfor each directory the session's skills and prompt templates come from, with them as children, and apluginfor each of the profile's extension modules. While a session's store is open, its subscribers getsession/mcpServerStateChangedwhenever a server's state changes andsession/serverToolsChangedwhenever its tools do. A client stops a server by dispatchingsession/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 dispatchessession/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 arejectionReason. 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 advertisecapabilities.multipleChatswithforkandsideChat. The client chooses the chat'sahp-chat:/URI (notahp-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.sideChatforks 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, andsource.selectionis kept in its origin. Without a source the chat is an empty conversation with the session's agent. AninitialMessagestarts the chat's first turn. Subscribers of the session getsession/chatAdded, and asubagenttool result names its chat withsubagentcontent. Retrying a creation that succeeded succeeds again without creating anything.disposeChatstops 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; ifcreateSessioncould not set the requested model or thinking, it iscreationFailedwith acreationError(andsession/creationFailedis sent) until a retriedcreateSessionsucceeds (session/ready). Clients dispatchsession/isReadChangedandsession/isArchivedChanged; both are kept in the catalog and show as theIsReadandIsArchivedstatus bits in the session list. A run that ends marks its session unread again (session/isReadChanged). A session's status isInProgresswhile it works andInputNeededwhile it waits for an answer;default chat.
session/defaultChatChangedto 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/draftChangedwith a user message (at most 64 KiB) saves the chat'sdraftin the catalog for every client of the chat; without a draft it clears it./chordclients read and save the same draft (conversation.draft,setDraft,watchDraft), and their changes reach AHP clients aschat/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
activeClientswithsession/activeClientSet(its ownclientId, at most 128 tools) and leaves withsession/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/activityChangedsays what it is doing (Thinking,Responding,Running <tool>, then nothing), the session's subscribers getsession/chatUpdatedwith the chat's activity and, for chats other than the root, its status, andsession/activityChangedfollows the default chat. The root chat's status follows the session's. A session's_metaholds its catalog name ({"pi-daemon": {"name": ...}}), and a rename sendssession/metaChanged;refused actions.
chat/truncated(Durable history is append-only; fork the chat instead) and thesession/workingDirectory*andchat/workingDirectory*actions (a session works in its one cwd) come back to the client with arejectionReason;file resources, on a host with profiles:
resourceRead,resourceWrite(truncate,appendandinsertmodes,createOnly,ifMatchagainstresourceResolve'setag),resourceList,resourceResolve,resourceMkdir,resourceCopy,resourceMove,resourceDeleteandresourceRequestforfile: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 withPermissionDenied, 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
writeoreditcall 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 changesetahp-changeset:/<id>/<conversation>lists its tracked files (at most 256) whose content now differs from that record, each withbeforeandaftercontent references (read withresourceRead) and its added and removed lines. The session'schangesetscatalogue lists the chats with changes, described with their counts.invokeChangesetOperationrunsdiff, whose follow-up is the unified diff of one file or the whole changeset, andrevert, 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 getsession/changesetsChangedand the changeset's subscriberschangeset/contentChangedwhen they changed. Review marks and range operations are not supported;resource watches:
createResourceWatchon a file or directory within the roots returns anahp-resource-watch:/channel. Its subscribers getresourceWatch/changedbatches (added,updated,deleted), filtered byexcludesandincludes(**,*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:
createTerminalon a client-chosenahp-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 asessionclaim names, or, for aclientclaim (which must name the creating client), the most recently active session whose working directory iscwd; else the latest session of a profile whose roots holdcwd(the shell starts incwd); without acwd, the most recently active session (as Culture opens terminals).cwddefaults to the session's and must lie under its profile's roots;colsandrowsdefault to 80 × 24. The root state'sterminalslists every terminal, androot/terminalsChangedfollows creation, renames, claims, exits anddisposeTerminal. A terminal's subscribers get its output asterminal/data, its title and working directory as the shell reports them (OSC 0/2 and 7) asterminal/titleChangedandterminal/cwdChanged, andterminal/exitedwith the exit code. The client holding the claim dispatchesterminal/input,terminal/resized,terminal/titleChanged,terminal/clearedandterminal/claimed(which a session-claimed terminal also accepts from any client); each applies in order and is echoed, and anything else comes back with arejectionReason. 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
scriptandsttyutilities (util-linux on Linux, the BSD tools on macOS).- Saved: each terminal's title, cwd, size, claim,
exit and newest output go to an owner-only file under the state
directory's
agent terminals: a profile module exporting
{ TerminalsExtension as extension }from pi-daemon'sdist/index.js(asask_user'sInputExtension) gives its agents these tools:terminal_open(an optionalcommand,title,cwd,cols,rows: 120 × 40 by default);terminal_send(text, then namedkeys: Enter, Tab, Escape, arrows, C-c, M-x, F1-F12, …);terminal_read(thescreenas a person sees it, rendered by a headless terminal emulator, or thetailwith scrollback; optionally first waiting forwaitFor, a regular expression, oridleMsof quiet output, up to two minutes);terminal_list,terminal_resizeandterminal_close.
The shells run in the session's worker, cwd and env, as clients' terminals do. AHP lists them with a
sessionclaim naming the session and the tool call (toolCallId), so every client sees them. A client takes one over withterminal/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
rejectionReasonnaming the origin conflict; native calls getturn_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 andahp-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, advertisingloadSessionand 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/newcreates a catalog session in the request'scwd, 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/loadreplays the session's committed root transcript assession/updates (user, agent and thought chunks, tool calls and their results; at most the newest 1024 turns), then sendsavailable_commands_updateand answers.session/newsends the same update after it answers. The commands are the session's prompt templates, skills and its profile's module commands.session/promptsubmits to the session's root conversation and streams its Durable agent events assession/updates: text and thinking deltas as agent message and thought chunks, tool executions astool_callandtool_call_update. A tool result's images areimagecontent (aresource_linkonce they pass 2 MiB per update), and media a tool shares is aresource_linkin its result and in an agent message chunk. It answersend_turnonce the turn settles, orcancelled. A text prompt naming a prompt template or skill is expanded, and one naming a module command runs it, answeringend_turnwith 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/cancelaborts the running turn.- An
ask_userquestion during a prompt becomessession/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'stitle,profile,cwd,liveWork(true while it has unfinished work) andactiveAt(its last activity). WithwithModel: true(pages of at most 100) each also has its root conversation'smodel(provider/modelId) andthinkingLevel, read from its worker. Session entries carry an emptyenv: a session's variables are never sent to clients.host.info {}:{ name, version, _meta }, the host and, underpi-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.profiledefaults to the only profile andcwdto the profile's first root. The session's id derives fromrequestId, so a retry answers the same session; a retry naming another profile or cwd isalready_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 settitleIsExplicit: 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 whencompacted), a page at a time (at most 500 messages and about 8 MiB):{ messages, compacted, at?, next? }.atis the entry it was read at; pass it withnextascursorfor the next page of the same context while the session moves on. pi-client's and/web's/clone @hostread a conversation so andconversation.importit into a session on another host.host.info'shome(the host's home directory) maps a directory to the same place under another host's.profiles.list {}: the profilessessions.createaccepts, 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 replacetext[rangeStart, rangeEnd)withinsertText. While the text up to the cursor is/name, the session's/commands (source:builtin,extensionfor profile module commands,promptorskill); after/name, that command's arguments (kind: "argument": a module command'sgetArgumentCompletions,/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, whichconversation.watchshows; a retry with the samerequestIdruns 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 itsoutput. A built-in command needs the conversation idle.session.usage { sessionId, conversationId? }:conversation(the conversation's committedpi.usage: tokens and cost perprovider/modelIdand per tool),session(every conversation'spi.usagesummed) andcontext.context.stateismeasuredwithtokens(the latest completed response's total, as TUI Pi's footer shows),provider,modeland, when known, the model'scontextWindow; orcompacted/resetonce the context changed after it; oremptybefore any response.usageTotal()sums api.usagedocument.conversation.watchEvents { sessionId, conversationId?, tail? }: Durable agent events,approval_requestedwhen a tool call or result starts waiting for approval, andnoticewhen a profile module command runs or notifies. Withtail(asconversation.watch's), the first event's (the snapshot's) entries are the newest only, witholderEntries;conversation.historyreads the rest.conversation.watch { sessionId, conversationId? }andsession.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 bytessession.mediareads; 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.whenBusyissteer,followUp(the default) orreject. TherequestIdis Durable's, so a retry is accepted once. Withwait: truethe call answers with the settled submission. Text naming a profile module command runs it instead and answers{ command, status: "done", notices }(duplicate: truefor 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 }andconversation.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'schat/draftChangedsaves (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 }andsession.cancelQuestion { questionId }forask_userquestions.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,conversationortask) 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, thenops). 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'swriteandedittools 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/diffuse them.session.media { sessionId, uri }: a media item of the session (see Media) as{ data, mimeType, size }, its bytes base64./webshows 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
readtool or an MCP tool returned) ise<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_failedwith 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
/chordor 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 itsname, which must not be another module's,builtin-toolsorpi-prompt. Its tools return their result's text and images asoutput(pi-durable 1.2 renamedcontent); a tool that still returnscontent, and nooutput, has it read as itsoutput.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.stateis the module's per-session state, the same its commands use.snapshot(doc)reads one of the session's Durable documents (a session-scopeddefineDoctoken, 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, orundefinedwhen unset.title: the catalog title, orundefinedwhen 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 asinvalid_extension; the other modules still load. A module command hides a project prompt template of the same name. - A prompt that is exactly
/nameor/name <args>, on any surface (an AHP turn or steering/queued message, an ACP prompt, a/webor/chordsubmission), 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.sessionIdandctx.conversationIdname where it ran.ctx.state.get(key)andctx.state.set(key, value)read and write JSON values (undefinedremoves 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.mjsistts). 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;/weband/chordconversation.watchEventssee 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
completionsfor/and ACPavailable_commands_update(sent aftersession/newandsession/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 asconversation.commandruns it, recorded with its output; a built-in one waits for the conversation to be idle. - Anything else goes as
conversation.submitwithwhenBusy: "followUp": a module command runs, a prompt template or skill is expanded, a prompt starts a turn (or follows the running one). requestIdgoes 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-setat most 32 images, each one this session's media. - Writes to one slot fail with
rate_limitedbeyond 4 a second. - Images stored with
ui.image()are the UI's, not the transcript's. After eachimage-setwrite orclear, 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_limitedorunavailable.
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.documentandsession.watchDocumentread and follow it at that address.host.infoadvertises_meta["pi-daemon/ui"] = { version: 1 }.- AHP.
- A followed session's state carries
_meta["pi-daemon/ui"] = { version: 1, slots }, beside its other_metakeys. - A change is sent as
session/metaChangedat most twice a second. initializeadvertises_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.
- A followed session's state carries
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.