Skip to content

06. Host RPC Protocol ​

Bounded existing-history operations (#1868) ​

The Desktop uses additive Host methods for existing history: session.deleteMessage takes sessionId/messageId and returns ok/deleted/deletedCount; session.retractPendingPrompt takes sessionId/userMessageId and optional turnId, returning ok/retracted/removedCount and a refusal reason when needed. Host verifies canonical message/turn ownership and meaningful reply activity; a refused retract leaves history intact. Repeating an already completed deletion or retraction does not delete another prompt. No history array is required.

session.activateRevision accepts optional requireCanonicalPrefix: true to require a durable Host prefix; a missing family/anchor fails explicitly. Existing explicit-prefix activation, session.replaceMessages, session.saveRevision and their original request/response forms remain supported. Existing edit and regenerate continue using session.truncateFrom; branch archival and fork remain Host-owned. Oversized local requests report the method and UTF-8 byte count without payload content; the caller sees a local-transport explanation and the existing session Fork recovery path. No identical oversized payload is retried. The unchanged 64 MiB limit still applies to arbitrary incoming requests, including a genuinely oversized single tool result.

ID mutations use the same session operation lock, rewrite fence and runtime disposal boundary. Their outbox drain must confirm all target-session entries were written; failure preserves the queue and rejects the mutation. Smart-stop restores a draft only after Host confirmation, and exposes failures.

1. Goal ​

Define the local protocol between:

  • Electron main (orchestrator)
  • Rust host-core (privileged backend)
  • Node pi agent sidecar (tool requester / event source consumer via host)

MVP transport decision (D001):

stdio JSON-RPC over NDJSON

2. Transport ​

  • Process: Electron main spawns Rust host-core sidecar
  • Channel: child process stdin/stdout
  • Framing: one JSON object per LF-delimited line (NDJSON); CRLF is accepted. U+2028 and U+2029 inside JSON strings are payload, never frame delimiters. All Node stdio readers preserve UTF-8 characters across input chunks and release buffered fragments/listeners on transport close. A final unterminated frame is accepted at EOF for compatibility.
  • Invalid JSON frames produce a diagnostic containing only the byte length, never payload text, before being discarded. Later complete frames remain readable. Existing session text is not rewritten or migrated.
  • Encoding: UTF-8
  • Request/response: JSON-RPC 2.0 style

The control pipe is resource-isolated inside host-core. One dedicated OS thread reads stdin and one dedicated OS thread serializes stdout; request and tool tasks never perform Tokio stdio operations. This keeps temporary OS thread exhaustion from turning a pipe read/write into a Tokio blocking-pool panic. The threads retry interrupted and transient nonblocking errors while preserving one-message-per-line framing; an unrecoverable pipe error ends the host and is handled by the normal Electron supervision path.

2.1 Runtime admission and backpressure ​

Host-core does not create an unbounded task or subprocess for every request. The RPC dispatcher caps active requests at 32. tools.execute then enters a bounded execution budget:

  • 16 total tool executions
  • 4 concurrent Bash processes globally, 2 per session
  • 8 read/search tools globally
  • 2 mutating tools globally, 1 per session
  • 4 plugin tools globally
  • 4 tool executions per session
  • 64 queued tool executions globally

Permission prompts do not consume an execution slot. A full queue returns HOST_OVERLOADED with retryable semantics in the tool result instead of waiting indefinitely or spawning more work. The limits are host-owned so Electron and the sidecar cannot independently over-admit the same resources. Admission reserves total, tool-class, session, and session-mutation capacity atomically. A queued call holds no execution capacity. When capacity returns, the oldest runnable request is admitted; a request blocked by one class or session does not block unrelated runnable work. Calls wait at most 30 seconds. Dropping a waiting admission future or letting it time out removes its queue entry and releases any reservation made before the caller receives its permit. The health counters report only fully admitted reservations, including those awaiting delivery to the caller; queued counts only requests still waiting for capacity.

Electron's HostProcess treats an explicit HOST_OVERLOADED response as retryable backpressure for renderer-facing calls. It waits 50, 100, 200, and 400 ms between at most four retries, then returns the structured error to the caller. This retry applies only to an admission rejection; transport failure, timeout, and errors from an admitted request are never replayed.

Request ​

json
{
  "jsonrpc": "2.0",
  "id": "req_01H...",
  "method": "tools.execute",
  "params": {}
}

Response ​

json
{
  "jsonrpc": "2.0",
  "id": "req_01H...",
  "result": {}
}

Error ​

json
{
  "jsonrpc": "2.0",
  "id": "req_01H...",
  "error": {
    "code": 1003,
    "message": "PATH_OUTSIDE_WORKSPACE",
    "data": {
      "errorCode": "PATH_OUTSIDE_WORKSPACE",
      "details": {}
    }
  }
}

Notification (server → client, no id) ​

json
{
  "jsonrpc": "2.0",
  "method": "permissions.request",
  "params": {}
}

3. Handshake ​

On spawn, Electron must call:

app.handshake ​

Params:

ts
type HandshakeParams = {
  protocolVersion: 11
  client: "electron-main"
  clientVersion: string
  locale: string // default "en"
}

Result:

ts
type HandshakeResult = {
  protocolVersion: 11
  host: "rust-host-core"
  hostVersion: string
  features: string[]
  capabilities: string[] // does not include "a2a"
}

Rules:

  1. If protocol major version mismatches → abort boot
  2. Electron should exit with actionable error if handshake fails
  3. All subsequent calls require successful handshake
  4. Version 4 introduced the durable notification inbox and the notification-bearing session.endTurn result.
  5. Version 5 requires the host-owned session.fork snapshot operation; a version 4 host must be rejected before chat becomes interactive (ADR 0023).
  6. Version 6 adds durable model-context checkpoints through session.appendCompaction; a version 5 host must be rejected before the runtime claims automatic context protection (ADR 0030).
  7. Version 9 is the frozen ADR 0053/0054 contract: it covers the checkpoint Plan artifact/queue, active-turn Plan identity/CAS, explicit approval permission, shell catalog identity and dialect pin, streamed command output, and scheduled-task mode projection from config_json. A v7 or incompatible v8 host must be rejected before the UI becomes interactive.
  8. Version 10 added the A2A protocol stack (ADR 0147): the host-core A2A broker, the a2a.* method domain, and the a2a.task.event / a2a.push host→client notifications. The capabilities array advertised "a2a"; a v9 host that could not advertise a2a was rejected before the UI became interactive.
  9. Version 11 withdraws that stack (ADR 0165 / D326). The a2a.* methods and a2a.task.event / a2a.push notifications are gone. Handshake no longer advertises "a2a". A v10 host or client is rejected before the UI becomes interactive, so a mixed pair cannot call a missing domain.

Protocol v11 is paired with host-core storage schema v23. Schema v12 had added the A2A tables (a2a_tasks, a2a_messages, a2a_artifacts, a2a_push_configs) via migrate_v11_to_v12; migrate_v12_to_v13 drops those tables, and v14 adds the plugin-session ownership sidecar and soft-delete column. Schema v15 adds the Host-owned turn queue, and schema v16 adds the session collaboration ledger and its turn-queue binding. Schema v23 adds sessions.title_source to preserve default, manual, and plugin-generated title ownership. A fresh database creates neither A2A tables nor unowned plugin-session rows. The schema version is an internal persistence invariant, not an additional JSON-RPC field; the checkpoint architecture remains host-owned.

4. Method catalog (MVP) ​

App ​

  • app.handshake
  • app.health
  • app.getVersion
  • app.getOnboarding — inline onboarding checklist state (D031)

app.health returns a diagnostic toolBudget object:

ts
type ToolBudgetHealth = {
  active: number
  queued: number
  total: number
  shell: number
  reads: number
  mutations: number
  plugins: number
}

Workspace ​

  • workspace.get
  • workspace.set
  • workspace.clear

Review snapshots (ADR 0043) ​

  • review.rollback({sessionId, snapshotId}) — verify the current post-tool hash, restore the session-owned previous bytes, and return one of rolledBack, alreadyRolledBack, conflict, or unavailable.

Projects ​

  • projects.list — returns durable project records ordered pinned-first, then by last-opened time; includes records materialized by session imports
  • projects.create({ path }) — upserts a durable project record without changing the active workspace and returns the host-generated project id
  • projects.remove({ path }) — deletes one durable project row together with every session attached to it, removing those sessions' transcript, scratch, and review files and the project's durable memory, and never touching the project folder on disk. Idempotent: an unknown path returns { removed: false, sessionsRemoved: 0 }. If the path belongs to a stored project group, deletion detaches that root in the same flow; deleting the primary promotes the first remaining root, and deleting the last root also removes the group record. The call is refused (1008 / CONFLICT) while any attached session has a running turn, before changing group membership, so a live turn never loses the transcript it is writing and a rejected delete leaves the project group unchanged.
  • project.memory.get({ path }) — returns the durable memory for the canonical project path, or an empty record when no memory has been saved
  • project.memory.set({ path, entries }) — normalizes and stores visual memory entries, derives readable content, and validates the 32 KiB limit. The derived value is injected into that project's next runtime context as user-provided context. { path, content } remains supported for legacy callers and returns a memory record without structured entries.
  • project.groups.list — returns one host-owned logical group per named project. Existing path-only records are compatibility legacy groups.
  • project.group.create({ name, folders }) — validates the display name and local directories, stores the ordered roots, and returns the new group without changing the active workspace. A non-legacy root cannot belong to a second group.
  • project.group.rename({ groupId, name }) — persists the group display name.
  • project.group.update({ groupId, name, folders, primary? }) — edits the group name and ordered roots. The stored primary root must remain first; naming a primary promotes that selected root and reorders the roots around it, while omitting it keeps the stored primary and refuses dropping it. Duplicate roots are removed, a primary outside folders is rejected, roots owned by another non-legacy group are rejected, and a root with existing chats cannot be detached. Removed roots are retained as suppressed historical paths rather than reappearing as standalone legacy groups.
  • project.group.memory.get/set({ groupId, entries }) — reads or normalizes shared group memory using the existing 32 KiB entry limit.
  • project.group.instructions.get/set({ groupId, content }) — reads or stores shared group instructions using a bounded host-owned value.
  • project.group.context({ path }) — resolves the group containing a primary or member root and returns its shared instructions and memory for runtime launch. Legacy groups return no group context so the path-scoped compatibility APIs remain authoritative. Builtin tools default to the primary root; absolute paths under registered additional roots use the same canonical containment resolver and never become arbitrary external access.

Secrets ​

  • secrets.set
  • secrets.delete
  • secrets.has
  • secrets.getForRuntime — main/host only, never reachable from the renderer
  • // never secrets.get to renderer logs

A provider row has two independent refs — secret:provider:<id>:api_key and secret:provider:<id>:oauth (D237/D240). The generic methods above serve both, so a vendor-account credential needed no new host method. ProviderPublic therefore reports hasSecret (true for either credential), hasOauth, and the non-secret oauthAccountLabel; providers.create / providers.update accept oauthAccountLabel and optional headers (stored in config_json.headers, cleared with {}), and providers.delete clears both refs for exactly one row. Login orchestration and token refresh stay in Electron main and never reach this protocol — see 14-secrets-storage §10. An OAuth row's headers are edited after the account exists and apply to later refresh and inference; the vendor picker does not collect them.

Settings ​

  • settings.get
  • settings.set — optional networkProxy (system / direct / custom). Custom requires an http/https/socks5 URL. host-core uses the stored value for marketplace curl --proxy and does not put it on process env.

Sessions ​

  • session.list — returns summaries with host-authoritative messageCount alongside the existing session metadata

  • session.create — accepts optional thinkingLevel and optional inheritPermissionFromSessionId; when present, the host copies the existing session's persisted permission mode atomically, while omission preserves the existing inherit default. Missing/null thinking level defaults to off. Optional boolean fastMode (ADR 0334) stores the session /fast switch; missing/null means false and a non-boolean returns INVALID_PARAMS. Optional isolated: true (ADR 0329) also requires projectPath and gives the new session its own git worktree before it returns; if the worktree cannot be created the session is deleted again and the call fails with a WORKTREE_* error (§7).

  • session.fork — accepts sessionId, an optional caller-provided display title, and optional throughMessageId; creates one independent session from the source's current active canonical transcript, truncated inclusively at the selected message when supplied. The child inherits project/provider/model/mode/thinking and permission configuration, receives new message/tool-call ids, and starts without turns, revisions, notifications, artifacts, grants, or scratch data. Missing sources or anchors return NOT_FOUND. While a Desktop source runs, only a completed assistant prefix containing no indexed messages owned by a running turn is allowed. This check and publication share the host RPC lock. Whole-session, non-assistant, streaming/error, or live-turn anchors return CONFLICT, normalized by Electron to AGENT_BUSY. The source turn continues without sharing runtime state with the child.

  • session.get — accepts an optional renderer read window: messageBefore is the exclusive zero-based end offset, messageLimit is the positive page size, and contentLimit is the positive character budget for the derived display projection. The response includes messageStart and hasMoreBefore when a window is requested. Omitting all three options returns the complete lossless UI projection for sidecar and mutation callers. Window offsets are physical transcript message-line positions, clamped against the cached transcript layout rather than the deduplicated session index counter, and a window is served by seeking to its first selected line instead of scanning the history before it. Optional messageAround centers that window on a stable message ID resolved against the canonical file, requires messageLimit, and excludes messageBefore. Missing IDs return no session. The focused user/assistant message retains its complete text; neighboring text and tool fields stay capped. Bounded reads also return exclusive physical messageEnd and hasMoreAfter to support contiguous forward pages (ADR session-content-search). When the selected message has parentToolCallId, optional navigationParent contains the latest matching Task tool-call projection from the same canonical transcript. It is capped separately and does not widen the window or alter its cursors. Ordinary and uncapped reads omit this navigation-only field.

  • session.delete — for an isolated session, also removes its worktree and branch (discarding unmerged work)

  • session.getScratchPath — the session's scratch directory (D114), created on demand

  • visualizations.readFragment({ sessionId, fileName }) — private Main-to-Host reader for pi-genui: documents (D671). No renderer IPC or Plugin SDK method is added. Parameters contain only a validated session ID and direct .html basename; caller-supplied roots and extra fields are rejected. Host resolves against its current data directory and pins native directory capabilities: Unix uses relative openat with no-follow/nonblocking flags; Windows uses NtCreateFile with a pinned RootDirectory for every ancestor/owned child and refuses opened reparse entries. Share masks are an extra defense, not a substitute for relative capabilities. Returns { ok: true, base64 } for at most 2 MiB of file bytes, or { ok: false, code: "not-found" | "too-large" }. Main validates this payload, decodes bytes, rejects full HTML documents, and supplies the response sandbox. Filesystem work does not hold the state/database lock. Additive to v11; no storage schema change.

  • session.rename({ id, title }) trims and validates the title at the host boundary. It accepts 1–80 Unicode code points and returns { ok: boolean }; blank or overlong titles are INVALID_PARAMS. A successful rename changes title metadata and marks the title source manual; it does not update updated_at, transcript content, message count, or historical notification title snapshots.

  • session.deriveTitle({ id, title }) applies the deterministic first-prompt fallback and returns { updated: boolean }. It validates the title with the same 1–80 code-point rule, then writes only when the stored title is still a recognized placeholder whose title source is default. The write keeps that source so a title plugin can still replace the derived text, and it does not update updated_at, transcript content, or message count.

  • session.configure — atomically persists mode, providerId, modelId, and optional thinkingLevel (off|minimal|low|medium|high|xhigh|max|omit) for the next pi turn; omitting/null thinkingLevel preserves the current value; optional boolean fastMode (ADR 0334) follows the same rule, and a non-boolean is INVALID_PARAMS; invalid modes or levels return INVALID_PARAMS; mode is plan | goal | agent and changing any session configuration is allowed only while idle and without a pending/queued/running Plan or Goal record

  • session.moveProject({ sessionId, projectPath }) moves an idle session to a project and returns { session } carrying the canonical project path. It upserts the project row and updates only sessions.project_id and updated_at; transcript, revisions, artifacts, notifications, and scratch data stay with the session. Blank ids or paths are INVALID_PARAMS, an unknown session is NOT_FOUND, and a session with a running turn is CONFLICT so a live agent never switches instruction roots mid-turn. An isolated session is refused with WORKTREE_MOVE_UNSUPPORTED. Additive RPC; no protocol version bump.

  • session.appendMessage

  • session.saveInflightMessage — Electron-main-only checkpoint of the assistant reply currently streaming, including the finished message_end snapshot: { sessionId, turnId?, message } atomically replaces sessions/<id>.inflight.json (D299, D327, spec 04 §2.1). Returns { ok, saved }; saved is false for a message without visible text or for an id that is already indexed (the final row landed first), and in the latter case any leftover checkpoint is removed. Non-assistant roles are INVALID_PARAMS-class failures. A completed/error session.endTurn deletes the file only when that id is already indexed.

  • session.recoverInflightMessages — Electron-main-only post-drain sweep (D327). Promotes leftover checkpoints whose final row never landed, including completed turns as complete. Boot recovery skips completed leftovers so the outbox can append first. Returns { ok, count }.

  • session.appendCompaction — sidecar-only append of the newest typed model-context checkpoint. It requires non-empty checkpoint/summary/boundary ids and non-negative tokensBefore; it does not insert a message/search row or change the visible transcript projection

  • session.replaceMessages — atomic transcript rewrite (temp-file rename + one index transaction, D119) used by message delete and unanswered renderer smart-stop undo; it preserves the newest checkpoint only while both its boundary and optional first-kept id remain valid in the rewritten prefix, and it carries each surviving message's owning turn_id across the rewrite. It is only safe from a caller that owns the whole transcript for the duration of the call: any rewrite from a snapshot taken outside the RPC lock can delete a message appended in between. Regenerating and retrying use session.truncateFrom instead so the kept prefix never crosses the JSON-RPC pipe (ADR 0216)

  • session.truncateFrom — host-owned suffix cut for regenerate / retry / edit-resend: { sessionId, fromMessageId?, truncateBefore? }. Identity wins; an unknown fromMessageId is NOT_FOUND. Under the state lock it aborts a leftover running turn, archives the discarded regenerate tail (refreshing the stamped variant, or minting an inactive one), rewrites the kept prefix, and drops the in-flight checkpoint. Returns { ok, keptCount, discardedCount, abortedTurnId, revision } where revision is the pager stamp for the upcoming user prompt, or null when the discarded tail has no user root. No transcript snapshot is in the request or the result. Additive on protocol v11 (ADR 0216)

  • session.saveRevision — archive a regenerate branch under (sessionId, rootUserId). With revisionIndex, refresh that existing variant's payload in place (the branch grew since it was archived) instead of minting a new index; the DB row keeps its identity and active flag and only message_count changes

  • session.saveActiveRevision — archive the branch of the newest revision-bearing user root as its active revision and stamp that root's pager metadata, all under the RPC lock. The stamp rewrites one transcript line instead of the file, so a concurrent session.appendMessage survives. Returns { saved: null } when the session owns no regenerate history. An already-archived active variant is refreshed, not skipped. Turn-completion callers use this instead of session.get + session.replaceMessages

  • session.listRevisions — list linear variants for a root user family

  • session.activateRevision — replace live transcript with prefix + branch and stamp root pager metadata. Before the switch it re-archives the live branch of that family from the durable transcript (refreshing the variant the live root's activeRevision stamp names, or storing a stamped but never-archived branch as its own variant), so nothing appended since the last archive is lost. When the family is present in the durable transcript, the prefix in front of the restored branch is taken from there rather than from the caller. Surviving messages keep their owning turn_id

  • session.beginTurn({ sessionId, providerId?, modelId?, sessionMessageId? }) — starts one durable turn. When sessionMessageId is present, host-core atomically verifies that the queued collaboration delivery targets this session, rechecks its permission ceiling, claims the delivery, and binds the new turn to its message id. A collaboration turn cannot be started from caller-supplied replacement text.

  • session.queuePush / session.queueList / session.queueRemove / session.queuePrioritize / session.queueReorder — the Host-owned turn queue (D386 / ADR 0213 / ADR 0265, schema v18); push is idempotent per principal and key, bounded at eight entries per session. queuePrioritize appends an entry to the end of its session's priority block (priority = MAX + 1) and refuses an already promoted entry with CONFLICT; queueReorder swaps one non-promoted entry with its adjacent non-promoted neighbour and reports { moved }. Listing and delivery order is priority ASC for promoted entries followed by position ASC for the rest

  • session.endTurn — atomically moves a running turn to its terminal state and conditionally returns the newly created notification for completed/error; returns no notification when createNotification=false, for aborted, or for an already-terminal turn. It also settles the session's in-flight reply checkpoint (D299): completed/error remove it; recoverInflight: true (sent when the sidecar is gone and no final row can follow) promotes a checkpoint whose final row never landed into the transcript as an aborted assistant message and returns it as recovered; a plain aborted (user Stop) leaves the checkpoint for the arriving final row to supersede

  • session.import — atomically imports one converted session; a non-empty project path is normalized and upserted into projects before the session references it; returns { imported, skipped }

Plugin-owned session methods are additive to protocol v11 and are called only by Electron main after plugin permission and manifest-source checks:

  • plugin.session.import — import one host-owned session with an idempotency key (pluginId, source, externalId); the host generates ids and binds to a project only when the caller supplies an existing projectId created by projects.create; the historical projectPath remains metadata
  • plugin.session.importBatch — bounded skip or all-or-nothing fail batch
  • plugin.session.list / plugin.session.get / plugin.session.listMessages — read only the calling plugin's active imported sessions
  • plugin.session.autoTitleContext — under Electron's session.autoTitle permission, return only the first user prompt (≤1,000 characters) and first assistant reply (≤500 characters) for an active default-titled session
  • plugin.session.setAutoTitle — validate a title and update only when the expected title and default source still match; returns { updated }
  • plugin.session.rename — rename an owned active imported session
  • plugin.session.delete — trash hides and retains the transcript; purge removes it and permits re-import
  • plugin.usage.listTurns — keyset page of completed-turn facts (identifiers and token counters, never a message body) for non-deleted sessions. Gated in Electron main by usage.read. Additive; no protocol version bump.
  • Successful plugin session mutations cause Electron main to emit one sessionsChanged renderer event; the renderer refreshes the session list, and plugins do not emit this UI synchronization event.

Host-internal session collaboration methods are additive to protocol v11 and are called by Electron main only after the reviewed plugin gateway has checked the plugin permission and active Agent tool invocation. They are not renderer or general MCP operations:

  • session.collaboration.spawn — create a bounded Agent worker session that inherits the source project's, thinking, and permission configuration, create its first task delivery, and return the real target sessionId plus the message record. Worker creation is limited per parent and plugin; a worker cannot create another worker.
  • session.collaboration.send — enqueue a task or message delivery to an existing Agent session. The host binds sourceSessionId and sourceTurnId to the current plugin tool invocation, enforces idempotency, a target inbox bound, the source permission ceiling, and a bounded autonomous hop count.
  • session.collaboration.message — read one durable delivery by message id for Electron's dispatch and provenance paths.
  • session.collaboration.status / session.collaboration.result — return a bounded status/result projection without loading a complete worker transcript. result may select a delivery by messageId or turnId.
  • session.collaboration.pending — list queued completion callbacks for the Electron drain; fail records a dispatch failure and creates the requested failure callback once; settle derives the result from the persisted turn and creates at most one completion callback.
  • session.collaboration.cancel — cancel queued deliveries or interrupt their exact currently-bound turns while retaining the target session and history.

The ledger is durable across a host restart. A queued entry with its turn_queue.session_message_id remains held for a new Agent Host controller; an unclaimed or running delivery is marked interrupted by the startup fence and is never replayed automatically. Transcript provenance is host-derived and cannot be forged or removed by session.appendMessage or transcript replacement. Steering (UiMessage.steering) into a claimed delivery turn is additional human input in that session: it does not receive the delivery origin, and a client-supplied session_message is stripped (D597).

The host rejects unknown roles, non-RFC3339 or non-monotonic timestamps, and oversized/deep payloads. Tool values are sanitized for host-reserved keys. The per-plugin rolling limits are 10 single imports, 5 batch imports, and 20 deletes per 60 seconds. P2/P3 methods are not present in protocol v11.

Session Todo checklist ​

  • todos.get({ sessionId }) returns the committed checklist snapshot for a live Desktop session: { sessionId, todos, revision, updatedAt }. Unknown or soft-deleted sessions return NOT_FOUND; native Pi sessions and blank ids are rejected as INVALID_ARGUMENT.
  • tools.execute with toolName: "TodoWrite" accepts only { todos } and replaces the complete ordered checklist. The host trims content, defaults priority to medium, truncates overlong Unicode content at 500 characters with a warning, demotes later in_progress items to pending, and rejects more than 50 items or malformed values. The owner session and running turn come from the trusted transport fields, never from tool arguments.
  • TodoWrite is allowed only for an Agent session's own running turn. Plan/Goal, delegated, plugin, and MCP calls receive a tool result error and do not mutate storage. A successful replacement advances the session revision even when todos is empty and emits todos.changed after the transaction commits. The event payload is the same complete snapshot returned by todos.get.
  • SQLite ownership is host-core only. The renderer receives snapshots through Electron Main IPC, keeps them by session id, and ignores revisions older than or equal to the cached revision. Remote RACP sessions are local-only for this vertical slice because RACP v1 has no Todo snapshot operation.

Stats ​

  • stats.getTokenUsageHistory — roll up completed turns token columns and usage_json cache/reasoning fields into local-calendar day / week / month buckets. Additive RPC; no protocol version bump. Default range is bounded (53 weeks / 52 weeks / 24 months). week uses ISO week year. Empty buckets in range are returned as zero rows so a calendar consumer sees a complete window. session.endTurn.usage is the durable turn total: parent assistant messages plus settled subagent usage, not a rewrite of message.usage. The user-facing dashboard is plugin pi.token-insights (D335 / ADR 0173), not a Settings destination.

Plan and Goal state and approvals ​

Both contract kinds share these methods; the optional kind (plan | goal, default plan so a pre-D198 sidecar still works) selects which contract is being negotiated.

  • plans.enter — accepts only the active Agent turn's sessionId, turnId, and toolCallId plus the kind; host-core performs the mode transition to that kind's mode with a compare-and-swap update and emits plans.changed carrying the kind. An unrecognized kind fails with INVALID_PARAMS
  • plans.submit — writes the host-owned artifact under the kind's directory and creates a pending proposal whose kind is persisted on the row
  • plans.pending — returns only pending approval rows, the session planning state, and the kind of the contract being negotiated (the pending row's kind, falling back to the session's own contract mode); renderer reload does not extend the absolute deadline while the host remains alive and does not restore terminal cards
  • plans.resolve — validates one matching approve/reject response and, for approval, commits the selected permission mode and execution_state = queued
  • plans.queuedExecutions / plans.claimExecution / plans.finishExecution — consume and transition execution fields on the same approval row; the claimed execution reports its kind so the sidecar can select the matching execution instruction
  • plans.abort — marks pending approval work interrupted; it never replays or changes an already-approved session back to its contract mode

Scheduled tasks ​

  • scheduled.list / scheduled.create / scheduled.update / scheduled.delete
  • scheduled.import — imports task records and normalizes their persisted mode
  • scheduled.run / scheduled.finishRun / scheduled.listRuns

The wire ScheduledTask.mode is a normalized projection of the durable config_json.mode; create, update, and import map legacy chat to plan and default missing values to agent. scheduled.run reads the selected task's persisted mode; a plan or goal task fails with PLAN_REQUIRES_INTERACTIVE_SESSION before creating a session or run. It never uses settings.defaultMode as the task mode.

Canonical thinking levels at the host boundary are:

text
off | minimal | low | medium | high | xhigh | max

Session summaries/details always return thinkingLevel. Assistant messages may return thinking; host storage maps it to a canonical content block rather than appending it to answer content.

Session worktrees (ADR 0329) ​

Additive worktree.* methods; no protocol version bump. Failures use code 1025 with a WORKTREE_* string code.

  • worktree.probe({ projectPath }) → { available, uncommittedChanges }, or { available: false, errorCode, message } when the project cannot host isolated sessions (not git, no commits, detached HEAD, git missing or older than 2.29, a remote-host project — WORKTREE_REMOTE_UNSUPPORTED, …). session.create refuses isolated: true for a remote-host project with the same code before any session is created.
  • worktree.preview({ sessionId }) → { preview } with the commits and files that would land and a previewToken. The token covers the session HEAD, the project branch tip, the conflict state, and the full content of every uncommitted and untracked file.
  • worktree.merge({ sessionId, previewToken }) → { outcome }. A token that no longer matches (the session or the project branch changed) is WORKTREE_CHANGED_SINCE_PREVIEW; a dirty project is WORKTREE_PROJECT_DIRTY; a conflict is WORKTREE_CONFLICT and leaves the worktree conflicted and the project untouched. The project checkout must still be on the session's owner branch and the worktree on its own branch, checked again before every write (WORKTREE_OWNER_BRANCH_CHANGED, WORKTREE_SESSION_BRANCH_CHANGED); the fast-forward is bound to the exact commit that was checked and verified afterwards. While it runs, host-core holds the session's tools: it waits up to five seconds for a running tool, then fails with WORKTREE_BUSY, and queues new ones until it is done.
  • worktree.behind({ sessionId }) → { commits }, project-branch commits the session does not have yet.
  • worktree.behindAll() → { counts, statusesChanged }: the same count for every isolated session keyed by session id (sessions whose count cannot be read are omitted), computed a few sessions at a time. Conflicted sessions are checked for a resolution first; statusesChanged asks the renderer to refresh its session list. The renderer reads it in the background for the sidebar marker.
  • worktree.sync({ sessionId }) → { outcome } (synced with commits, or upToDate): "Sync from project" merges the project branch into the session worktree after committing pending session work, under the same tool hold and own-branch check as a merge. The project checkout is not touched; a conflict is WORKTREE_CONFLICT, stays in the worktree, and marks the session like a merge conflict.
  • worktree.pendingWork({ sessionId }) → { pending }, the unmerged work a delete would discard.
  • worktree.setupLog({ sessionId }) → { setupStatus, log }.
  • worktree.location({ sessionId }) → { path }; Electron Main uses it for "Reveal in folder" and never forwards it to the renderer.
  • worktree.setupCommand.get({ projectPath }) → { command }; worktree.setupCommand.set({ projectPath, command }) → { ok } (unknown project is NOT_FOUND). The command runs once in each new worktree.
  • worktree.delegate.create({ sessionId, delegationId }) → { worktree } and worktree.delegate.settle({ sessionId, delegationId, merge }) → { settlement } — agent-runtime only, for Write/Edit delegates of an isolated session (03-tools-and-permissions §10.2). Both use a 10-minute transport deadline (WORKTREE_DELEGATE_RPC_TIMEOUT_MS) because they may wait for the session's tools before committing and merging.

Tools ​

  • tools.list
  • tools.execute
  • tools.abort
  • tools.output notifications for ordered stdout/stderr chunks

tools.abort accepts sessionId, toolCallId, and the optional toolName. Current runtimes include the name so Host cancellation is scoped to the exact tool while permission and admission are pending. Older callers may omit it; that form retains the legacy Bash cancellation behavior and can still stop an active typed cancellation by session and call id.

Background tasks ​

  • backgroundTasks.list
  • backgroundTasks.output
  • backgroundTasks.stop
  • backgroundTasks.changed notification carrying { version }, emitted when a task starts, exits, or is stopped

A tools.execute call with runInBackground: true starts a Bash command the host owns instead of the turn: the call resolves immediately with a backgroundTaskId, the task never joins the per-tool-call cancellation registry so tools.abort / agent.abort cannot reach it, and its output is kept in bounded per-stream rings. The registry lives in the host process, so every task stops during host shutdown and an application exit never leaves an orphan process.

Output chunks do not emit backgroundTasks.changed; the event remains a lifecycle invalidation, not a high-volume log transport. Clients fetch only the selected output preview. A terminal snapshot includes the final drained output, and shutdown signals every registered task even during a concurrent output read.

backgroundTasks.list stays session-agnostic: it returns every task in the registry, each carrying its sessionId, and the renderer filters to the session on screen. Filtering is presentation, so the wire contract does not grow a session parameter and no consumer loses visibility of other sessions' work. Deleting a session signals its running tasks host-side; the deletion path does not wait for them to settle and does not fail when one cannot be killed. Removing a row in the UI is purely a view action and issues no RPC, so a removed task's output remains readable by id until the host's own retention drops it.

Shells ​

  • commandShells.list
  • settings.set with a partial settings object; omitted fields are preserved, and a changed effective defaultCommandShell is accepted only when every session has no active turn and no pending/queued/running Plan/Goal work

Tool execution starts only after admission. Shell spawn retries transient resource exhaustion (EAGAIN / WouldBlock) with bounded backoff, never retries a command after it has started, and reaps timed-out children before releasing the execution slot. Admitted Read, Glob, Grep, Write, and Edit calls run their synchronous filesystem work, including the rg child wait, on Tokio's blocking pool rather than on an async worker, so a long traversal cannot delay unrelated RPCs. The read and mutation class limits above also bound those blocking threads. Results and error codes are unchanged; a blocking task that panics returns INTERNAL instead of dropping the response.

session.appendMessage is idempotent by message id. An id already indexed in another session is remapped to {sessionId}:{id} before the JSONL write, and a later replay of the original id is a no-op (D444). Electron main may keep message appends in its application-owned outbox while host-core is restarting; the outbox flushes in order after a successful handshake and treats UNIQUE constraint failed: messages.id as an ack rather than pausing the queue. A PERMISSION_DENIED: append is dropped the same way so a poison head cannot stall the FIFO (D597). An append the outbox cannot classify is retried on a bounded backoff and then quarantined to an application-owned dead-letter file instead of holding that head, so one refused append cannot stall every session's writes (D656). A missing sessions row is restored from the live JSONL (or created as a stub under the same id when the file is gone) so a queued outbox can drain (D318). session.delete drops that session's outbox entries. In-flight checkpoints never go through the outbox: a checkpoint is only meaningful against a live host, and replaying one after the final row would be wrong.

Permissions ​

  • permissions.evaluate
  • permissions.resolve
  • permissions.pending (D374: open requests as Host state)
  • permissions.listSessionGrants
  • permissions.clearSessionGrants

Plugins ​

  • plugins.list
  • plugins.loadDev
  • plugins.installFromPath
  • plugins.installFromPackage — install a .piplug archive after checksum verification
  • plugins.enable
  • plugins.disable
  • plugins.uninstall
  • plugins.getPermissions
  • plugins.grantPermissions / plugins.revokePermissions — change the granted set; the runtime enforces the intersection of declared and granted
  • plugins.setAutoUpdate
  • plugins.setScope — activation scope (ADR 0056)
  • plugins.resolveExecution — resolve which plugin tools/skills/MCP servers are active for a session's project before a turn starts

Marketplace ​

  • market.refresh — fetch and cache the catalog from the configured URL
  • market.search / market.getDetail
  • market.install — download, verify (PLUGIN_INTEGRITY, PLUGIN_MARKET_*), and install one catalog release
  • market.checkUpdates / market.applyUpdates

Providers and models ​

  • providers.list / providers.get / providers.create / providers.update / providers.delete
  • a plugin-owned row (ownerPluginId) is refreshed from its manifest on every load, so providers.update / providers.delete refuse it with a PROVIDER_OWNED_BY_PLUGIN error; only the owning plugin's lifecycle changes or removes it (ADR 0259)
  • providers.setSecret({ id, secretValue }) — stores or clears one provider's API key (secret:provider:<id>:api_key and the row's secret_ref). It is the write a plugin-owned row accepts: only the credential the declaration asks for changes, never a field the manifest owns. An empty or omitted secretValue deletes the stored key. Returns { provider }, or null for an unknown id
  • providers.getSecret — main/host only, never reachable from the renderer
  • providers.listModels / providers.cacheModels — discovered model rows and their host-side cache (ADR 0027 / ADR 0134). cacheModels accepts optional complete (default false). Model rows expose optional discoveryState (published / unlisted); omission is unknown. Only complete, nonempty snapshots reconcile missing discovered rows, without changing configuration. See provider-config schema for the conservative refresh and consumer rules.
  • providers.testConnection

Agent capabilities (skills, subagents, MCP servers) ​

  • skills.list / skills.active / skills.read / skills.create / skills.update / skills.remove / skills.import / skills.setEnabled / skills.setScope / skills.transfer — user skill documents (SKILL_INVALID on validation failure)
  • agents.list / agents.active / agents.read / agents.create / agents.update / agents.remove / agents.setEnabled / agents.setScope — user subagent documents (SUBAGENT_INVALID)
  • mcp.list / mcp.active / mcp.upsert / mcp.remove / mcp.setEnabled / mcp.setScope / mcp.transfer — user MCP server definitions (MCP_INVALID)

skills.transfer and mcp.transfer take { id, from, to }, each end a { level, projectPath? } target (projectPath is required for the project level), and return { skill } / { server } for the document where it landed. A transfer moves the document between the two levels rather than copying it, so the source level stops listing the entry. A destination that already owns the same id gives the arriving document a -2/-3 id suffix; one that owns the same display name / label, compared case-insensitively, gives it a matching (2)/ (3) display suffix. The existing entry stays untouched. Enablement travels with the document: the source level drops every state entry for the old id, including its project overrides, and the destination stores the value the source was showing (a project target keeps that project's state, a global target the global default). Naming the source's own directory as the destination is a no-op.

*.active returns the entries that apply to the given project after activation-scope filtering (CAPABILITY_INVALID for an unknown scope).

Search, artifacts, keyboard ​

  • search.query — legacy indexed-message hits; existing response and limit remain compatible
  • search.sessions({ query, offset? }) -> { hits, nextOffset } — global session discovery with title/project metadata and indexed user/assistant text. Trimmed literal queries have a 500-character limit (INVALID_ARGUMENT above it). Each 30-session page includes session, projectName, metadataMatch, the full matching messageCount, and at most two matches containing messageId, role, createdAt, and a snippet containing the matching sentence or line. Long sentences are capped to a match-centered 180-character window, extended when needed to preserve the complete literal query. nextOffset: null marks the last page. Sort by updated time descending and session ID ascending; exclude soft-deleted sessions. Empty queries return no hits because the renderer owns its recent-session presentation.
  • search.context({ sessionId, messageId, query, direction? }) — resolve a stable message ID in the owning, non-deleted session's JSONL layout. Default direction: "around" returns up to 21 nearby message text projections; "before" / "after" returns up to 20 messages excluding the anchor. Return messages, hasMoreBefore, hasMoreAfter, previousMatchId, and nextMatchId. Adjacent matching IDs follow transcript sequence order and have no 100-message cutoff. Context comes from canonical JSONL and is capped at 64 Ki characters per message; the target is centered on the query. Tool bodies, thinking, and attachments are omitted. Missing/deleted targets return NOT_FOUND; invalid directions or identifiers return INVALID_ARGUMENT. See ADR session-content-search.
  • artifacts.list — Plan/Goal checkpoint artifacts for a session
  • keyboard.setGlobalShortcut — host-owned native fallback for the plugin launcher chord where Electron cannot register it

Audit ​

  • audit.append

Notification (D117) ​

  • notification.list
  • notification.markRead
  • notification.markAllRead
  • notification.clear

4a. Notification contracts (protocol v4) ​

ts
type AppNotification = {
  id: string;
  kind: "task.completed" | "task.failed";
  sessionId: string;
  sessionTitle: string;
  turnId: string;
  errorCode?: string;
  createdAt: string; // ISO-8601 UTC
  readAt?: string | null;
};

type SessionEndTurnParams = {
  turnId: string;
  status: "completed" | "error" | "aborted";
  errorCode?: string;
  usage?: unknown;
  createNotification?: boolean; // default true; Electron supplies visibility decision
};

type SessionEndTurnResult = {
  ok: boolean; // false when the turn was missing/already terminal
  notification?: AppNotification; // omitted when no row was inserted
};

type NotificationListParams = {
  unreadOnly?: boolean; // default false
  limit?: number;       // default/max 200
};

type NotificationListResult = {
  notifications: AppNotification[]; // newest first
  unreadCount: number;               // global count, independent of filter
};
  • notification.markRead({ id }) -> { ok } is idempotent. ok=false means the id does not exist; an already-read row remains successful.
  • notification.markAllRead({}) -> { ok: true } updates every unread row in one transaction.
  • notification.clear({}) -> { ok: true } deletes inbox rows only.
  • id is the stable exactly-once key for renderer and native delivery. A client must discard duplicate or delayed records for an id it has already acknowledged/cleared; clearing the inbox never makes an old terminal turn eligible for insertion again. A later terminal turn receives a new id.
  • No notification.created JSON-RPC server notification is emitted. Electron receives the inserted record directly from session.endTurn, avoiding a second ordering channel between terminal turn persistence and UI refresh.
  • createNotification=false suppresses only inbox insertion; the running turn still reaches its requested terminal state in the same transaction. Missing or non-boolean values default to true so unknown/stale UI state cannot lose a notification.
  • sessionTitle is the stable session-name snapshot stored with the row. Localized event title/body prose is derived by Electron/renderer and never crosses host RPC.

5. Tool execute contract ​

tools.execute params ​

ts
type ToolsExecuteParams = {
  sessionId: string
  turnId?: string
  toolCallId: string
  toolName: string
  args: unknown
  /** Diagnostic/request context only; never used for authorization. */
  requestedMode?: "plan" | "goal" | "agent"
  expectedCommandShellId?: CommandShellId
  /** Bash only: dialect pinned by the same runtime turn. */
  expectedCommandShellDialect?: "powershell" | "cmd" | "posix"
  /** Bash only: host default 60000; accepted override 1000..21600000. */
  timeoutMs?: number
  /** Isolated `Write`/`Edit` delegate chain id; re-roots the call to that
   * delegate's worktree (ADR 0329). Omitted for every other call. */
  delegationId?: string
}

Authoritative mode and workspace resolution are session-scoped:

  1. Host loads sessionId and resolves its persisted project_id/path.
  2. Host reads the persisted sessions.mode and validates it as plan | agent. A conflicting requestedMode is ignored for authorization and recorded only as diagnostic data.
  3. That path becomes the tool sandbox root for permission preview, execution, artifact paths, and audit context. A tool's explicit path may name an outside location only after the host applies the outside-path permission rule; successful external results retain an absolute canonical path.
  4. The mutable workspace.get selection is not consulted for a valid durable session, so switching a retained project tab cannot redirect a background call.
  5. A durable path-less session resolves no root and receives WORKSPACE_REQUIRED where the tool requires one. A selected project is not inherited.
  6. Legacy calls whose session does not exist may temporarily fall back to the selected workspace; new renderer flows must always provide a valid sessionId.
  7. A database/session-resolution error returns INTERNAL and fails closed; only a confirmed missing session may use the legacy fallback.

For Read/Glob/Grep/Write/Edit, the host classifies an explicit path outside the workspace and scratch roots before the low-risk auto-allow rule. auto executes it, while ask and accept-edits emit permissions.request; denial or cancellation returns TOOL_DENIED without executing the operation. Relative .. and symlink escapes use the same classification. Bash's working directory and implicit recursive walks do not inherit this exception.

Before generic permission evaluation, host-core applies the mode policy:

  • Plan and Goal allow Read, Glob, Grep, BrowserPreview, Bash, and the kind's submit tool (SubmitPlan / SubmitGoal) as applicable to the live planning state.
  • Plan and Goal deny Write, Edit, every plugin tool, and unknown tools under all permission modes and grants. The host reads the session's durable mode for this check, so a sidecar claiming agent in tools.execute cannot widen it, and the *_IN_PLAN error codes are shared by both kinds.
  • Plan and Goal Bash follows the resolved permission mode: ask and accept-edits emit permissions.request; auto executes without confirmation and may mutate. The host re-resolves the effective shell ID/dialect and requires the exact expectedCommandShellId and expectedCommandShellDialect before permission evaluation and again before spawn; it streams stdout/stderr separately. A configured shell may fall back to the first available platform shell before the turn pin is created, but execution never changes shell after the pin.
  • Agent applies the normal registered-tool and permission policy.

The visible tool list is not the security boundary; a forged RPC call is authorized by this host-side matrix.

result ​

ts
type ToolsExecuteResult = {
  toolCallId: string
  ok: boolean
  isError?: boolean
  content: unknown
  durationMs: number
  denied?: boolean
  errorCode?: string
  // Workspace Write/Edit results may include content.details.review. The
  // record is persisted with the tool message and is independent of Git.
  // Bash command failures preserve content.exitCode/stdout/stderr while
  // setting ok=false, isError=true, and errorCode=TOOL_FAILED.
  // The agent runtime forwards isError into the tool transcript without
  // dropping the structured content/details needed for recovery.
}

5.1 Plan and Goal submission and approval contracts ​

SubmitPlan and SubmitGoal are handled as host transitions before generic tool execution. The host preserves the exact Markdown bytes in a new unique artifact under the kind's directory before publishing the proposal.

ts
// Identical shape for both kinds; the tool name selects the kind.
type SubmitPlanParams = {
  title: string;
  markdown: string;
  question: string;
};

type ProposalKind = "plan" | "goal";

type PlanningState = "inactive" | "planning" | "awaiting_approval";

type GlobalPermissionMode = "ask" | "accept-edits" | "auto";

type PlanApprovalAction = "approve" | "reject";

type PlanProposalStatus =
  | "pending" | "approved" | "rejected"
  | "expired" | "interrupted";

type PlanExecutionState =
  | "queued" | "running" | "completed" | "interrupted";

type PlanArtifact = {
  relativePath: string; // `.pi/plan/<unique-name>.md` or `.pi/goal/<unique-name>.md`
  sha256: string;
  sizeBytes: number;
};

type PlanProposal = {
  id: string;
  sessionId: string;
  turnId: string;
  toolCallId: string;
  // Which contract this approval carries; rows written before the
  // discriminator existed read back as `plan`.
  kind: ProposalKind;
  plan: string;
  markdown: string;
  title: string;
  question: string;
  status: PlanProposalStatus;
  createdAt: string;
  updatedAt: string;
  expiresAt?: string;
  resolvedAt?: string;
  action?: PlanApprovalAction;
  targetPermissionMode?: GlobalPermissionMode;
  errorCode?: string;
  artifact?: PlanArtifact;
  version: number;
  executionId?: string;
  executionState?: PlanExecutionState;
};

type PlanExecution = {
  id: string;
  proposalId: string;
  sessionId: string;
  // Which contract was approved; selects the sidecar's execution instruction.
  kind: ProposalKind;
  plan: string;
  title: string;
  question: string;
  artifact: PlanArtifact;
  targetPermissionMode: GlobalPermissionMode;
  state: PlanExecutionState;
};

type PlansPendingResult = {
  plans: PlanProposal[];
  state?: PlanningState;
  // The contract being negotiated: the pending row's kind, else the session's
  // own contract mode. Absent when nothing is being negotiated.
  kind?: ProposalKind;
};

type PlanResolveIdentity = {
  proposalId: string;
  sessionId: string;
  turnId: string;
  toolCallId: string;
  version?: number;
};

type PlanResolveRequest =
  | (PlanResolveIdentity & {
      action: "approve";
      targetPermissionMode: GlobalPermissionMode;
    })
  | (PlanResolveIdentity & { action: "reject" });

type PlanResolutionResult = {
  ok: boolean;
  proposal: PlanProposal;
  state: PlanningState;
  action?: PlanApprovalAction;
  targetPermissionMode?: GlobalPermissionMode;
  execution?: PlanExecution;
};

Host notifications:

text
method: "plans.changed"
params: {
  sessionId: string
  state: PlanningState
  kind?: ProposalKind
  proposalId?: string
  proposal?: PlanProposal
  action?: PlanApprovalAction
  targetPermissionMode?: GlobalPermissionMode | null
  execution?: PlanExecution | null
}

type ToolsOutputParams = {
  sessionId: string
  toolCallId: string
  commandShellId: CommandShellId
  stream: CommandShellOutputStream
  chunk: string
}

method: "tools.output"
params: ToolsOutputParams

plans.changed is emitted for Plan or Goal entry, submission, resolution, execution claim/finish, and abort. Its top-level params are exactly the fields shown; fields not applicable to a transition are omitted, and kind names the contract so the renderer can pick the right mode chip and approval copy without inspecting the projected state. For plans.resolve, the host emits targetPermissionMode and execution as JSON null when no value exists. Electron forwards this notification unchanged through the shared IPC.event.plansChanged renderer channel.

plans.resolve accepts only an authenticated, still-pending request whose proposal, session, turn, tool-call, and version match. approve requires an explicit permission mode and atomically commits the plan_approvals row to status = approved, assigns execution_id, sets execution_state = queued, sets sessions.mode = agent, and stores the selected sessions.permission_mode; that selection is not written into app settings as the next approval default. Ask remains the product default. The same Agent then receives a new provider request with Agent tools.

reject records rejected and leaves the session in its contract mode (Plan or Goal). The absolute 30-minute deadline records expired with PLAN_APPROVAL_TIMEOUT. Abort, host restart, sidecar restart, or persistence failure records interrupted. Before serving RPC after startup, the host transactionally interrupts prior pending approvals and queued/running execution states. Pending, queued, and running work is never replayed; queued/running interruption after approval leaves the session in Agent. The process epoch is internal and is not a wire or database field.

5.2 Shell catalog ​

ts
type CommandShellId =
  | "windows-powershell"
  | "windows-pwsh"
  | "cmd"
  | "git-bash"
  | "bash";

type CommandShellOption = {
  id: CommandShellId;
  label: string;
  dialect: "powershell" | "cmd" | "posix";
  available: boolean;
  isDefault: boolean;
};

type CommandShellCatalog = {
  configuredId: CommandShellId | null;
  effective: CommandShellOption | null;
  fallback: boolean;
  choices: CommandShellOption[];
};

type CommandShellOutputStream = "stdout" | "stderr";

commandShells.list returns the host discovery result. Settings writes store only a catalog ID and reject unknown, unavailable, or wrong-platform IDs with COMMAND_SHELL_INVALID. If a persisted ID later becomes unavailable, the catalog selects the first available platform shell and sets fallback: true. A Bash request includes the pinned effective ID and dialect from the same turn; host-core rejects a changed ID or dialect with COMMAND_SHELL_CHANGED before permission evaluation and before spawn. Identity is not an executable path hash.

6. Permission request notification ​

Host may emit:

ts
method: "permissions.request"
params: {
  requestId: string
  sessionId: string
  toolCallId: string
  toolName: string
  risk: "low" | "medium" | "high"
  argsPreview: unknown
  reason: string
}

Electron/UI resolves via:

ts
method: "permissions.resolve"
params: {
  requestId: string
  decision: "allow-once" | "allow-session" | "deny"
}

Local permission behavior (D636 / ADR 0310): an unresolved request remains pending until an explicit decision, cancellation, or host/process shutdown. The transport does not apply a deadline to tools.execute; tool-specific execution budgets still apply after approval.

permissions.pending returns the open requests as Host state (D374/D375): { requests: PendingPermission[] }, oldest first, optionally scoped by sessionId. Each entry carries the same fields as the permissions.request notification plus createdAt. Requests remain listed until settled. A client that attaches after the notification was emitted reads this list and answers through the unchanged permissions.resolve; the notification path itself does not change.

7. Error codes ​

JSON-RPC errors carry a numeric code plus data.errorCode, the stable string from 08-error-codes. Several string codes share a numeric slot; the string is the contract, the number is transport detail.

codeerrorCodemeaning
1000INTERNALunexpected host failure
1001UNAUTHORIZEDmissing/invalid handshake or capability
1001HOST_SHUTTING_DOWNthe host is draining after EOF and refused the call
1002INVALID_PARAMSschema validation failed
1002MODEL_ALIAS_TOO_LONGprovider row alias exceeds 60 code points
1002MODEL_BINDINGS_DEGRADEDstored model bindings are unreadable; explicit model-array replacement is refused
1003NOT_FOUNDentity missing (legacy slot, kept for old callers)
1006RATE_LIMITEDa per-caller budget window was exhausted
1007NOT_FOUNDentity missing
1007SESSION_NOT_FOUNDthe named session does not exist; tool requests never fall back to the global workspace
1008CONFLICTbusy/conflict state
1008AGENT_BUSYthe session has a running turn
1009PLUGIN_INVALIDmanifest/validation failure
1010PLUGIN_LOAD_FAILEDenable/load failure
1011PROTOCOL_MISMATCHapp.handshake protocol version mismatch
1012PLUGIN_INTEGRITYpackage checksum/signature mismatch
1013PLUGIN_PERMISSION_DENIEDplugin lacks the permission the call needs
1014PLUGIN_NETWORKmarketplace download/catalog fetch failed
1015MCP_INVALIDuser MCP server definition failed validation
1015PLAN_*every Plan/Goal checkpoint failure (PLAN_APPROVAL_TIMEOUT, PLAN_APPROVAL_STALE, PLAN_APPROVAL_INTERRUPTED, PLAN_SESSION_NOT_FOUND, PLAN_WORKSPACE_REQUIRED, …) shares this slot; the string code distinguishes them
1016SKILL_INVALIDuser skill document failed validation
1017SUBAGENT_INVALIDuser subagent document failed validation
1018CAPABILITY_INVALIDagent capability root/scope setting failed validation
1019PLUGIN_CANCELLEDthe user cancelled a marketplace install while it was downloading
1020PLUGIN_MARKET_NOT_PUBLISHEDthe platform has the version and is not offering it yet
1021PLUGIN_MARKET_ARCHIVEDthe plugin was withdrawn from the platform
1022PLUGIN_MARKET_NOT_FOUNDthe platform does not have that plugin or version
1023PLUGIN_MARKET_RATE_LIMITEDthe download endpoint asked the client to wait
1024PLUGIN_MARKET_NO_SOURCEno distribution target can serve the package
1025WORKTREE_*every isolated-session worktree failure (WORKTREE_NOT_GIT, WORKTREE_CONFLICT, WORKTREE_PROJECT_DIRTY, WORKTREE_MISSING, …) shares this slot; the string code distinguishes them
-32029HOST_OVERLOADEDRPC dispatcher capacity exhausted
-32601—unknown method
-32700—unparseable request line
1002LIMIT_EXCEEDEDan NDJSON request line over 64 MiB; Electron rejects the write before it reaches the pipe; if the host still sees it, the remainder of the line is drained, the reply keeps the request id when it can be peeked from the prefix, and the stdin reader keeps running

Tool outcomes (TOOL_DENIED, TOOL_TIMEOUT, PATH_OUTSIDE_WORKSPACE, WORKSPACE_PATH_DENIED, WRITE_DISABLED_IN_PLAN, SHELL_NOT_FOUND, COMMAND_SHELL_CHANGED, …) are not JSON-RPC errors: tools.execute returns ok: false with errorCode in the result (§5).

8. Concurrency / ordering ​

  1. Requests may be concurrent within the dispatcher cap. Read/search tools may run in parallel; Write/Edit are bounded and FIFO-ordered per session, with at most one mutation in flight for a session.
  2. Different sessions may continue concurrently across retained project tabs; each resolves its own project root and grants
  3. Notifications may arrive anytime after handshake
  4. tools.output preserves stdout/stderr separation and notification order; it is scoped to its session/tool call and has no turn or ordering fields; final results remain bounded
  5. Abort is idempotent and shuts down the complete Bash process tree
  6. Plan and Goal approval requests are proposal/session/turn/tool-call/version scoped; only one pending approval and one queued/running execution exists per session, and resolution is serialized by host-core
  7. Startup transactionally interrupts pending approvals and queued/running execution states before RPC service. Late renderer responses fail closed; pending interruption keeps the session's contract mode and an already-approved queued/running interruption keeps Agent.
  8. A session fork is one host-owned snapshot operation. The source transcript is never rewritten, and a handled child write/index failure leaves no visible session or orphan transcript file. A process crash follows D119's existing orphan-transcript recovery policy.
  9. A message-scoped fork is identical except that the canonical snapshot ends inclusively at throughMessageId. It still remaps message/tool-call ids and creates no runtime or revision state, so later child reseed/cache state is isolated by the new session id.

9. Logging rules ​

  • Never log API keys/secrets
  • Tool args may be redacted in audit previews
  • Every tools.execute gets trace id = toolCallId

10. Acceptance ​

  1. Electron spawns host and completes handshake
  2. health method returns ok
  3. denied tool path returns TOOL_DENIED
  4. an unresolved permission remains pending until an explicit decision or cancellation
  5. switching the selected workspace from A to B does not change the tool root of a call issued by session A
  6. Protocol v4 session.endTurn creates/returns exactly one notification for unseen completed/failed turns and none for visible-current, aborted, or repeated terminal updates
  7. Notification list/unread/read-all/clear round-trip through host-core and remain bounded to the newest 200 durable rows
  8. Forking an idle session produces an independently mutable child with the same active transcript and durable execution configuration while leaving the source and its regenerate revisions unchanged
  9. Forking through a message excludes every later source row and rejects an unknown message without creating a child
  10. A forged requestedMode cannot authorize a tool against the durable mode; Plan and Goal deny Write/Edit/plugin/unknown tools and apply permission prompts to Bash according to ask/accept-edits/auto
  11. SubmitPlan and SubmitGoal write exact Markdown bytes to a unique .pi/plan/*.md or .pi/goal/*.md file with hash/size and structured title/question fields; only matching approve/reject responses can resolve the live plan_approvals row, and a submit tool run against the other kind fails with PLAN_KIND_MISMATCH without writing an artifact
  12. Plan and Goal expiry, abort, crash, scheduled rejection, and stale responses produce the documented durable statuses and events
  13. Bash validates the pinned shell ID/dialect, streams stdout/stderr, enforces the 60s default/bounded override, and shuts down the complete process tree

Scheduled automation tools ​

Agent mode advertises on-demand ScheduledTaskList, ScheduledTaskCreate, ScheduledTaskUpdate and ScheduledTaskDelete tools. They run through tools.execute, including existing permissions and audit records, and reuse the scheduled RPC domain handlers. List is low risk; mutations require normal approval in Ask/Accept Edits. Plan/Goal deny all four even under Auto.

The Host rechecks durable session mode and derives project scope from the calling session, never the foreground workspace or model-supplied paths. Create binds that scope; list filters it; update/delete require matching scope. Unknown fields, invalid cadence, empty title/prompt, invalid time and invalid weekday selections are rejected before mutation. Delete refuses active runs. Create requires title, prompt and cadence; automatic daily/weekly tasks require a schedule. Update takes an existing ID and partial fields, preserving all unspecified configuration. Exact local times remain supported despite the UI's four period presets. No new DB schema or transport is introduced.

Scheduled tasks: task-owned execution settings ​

Desktop create/update may save workspacePath, permissionMode and a paired providerId/modelId on one task. Run now and automatic execution use those values when present. Missing fields preserve legacy project capture, app-default model resolution and permission behavior. Invalid permission values and partial model pairs are rejected before mutation. Conversation tools do not expose these fields and remain bound to the calling session's project. See ADR 0305.

Tasks also persist optional thinkingLevel using the existing session values (including off and omit). The full Composer model/reasoning picker and controller are reused with a task-draft configuration callback. Both manual and automatic runs apply the saved level. Missing or cleared levels retain the legacy off behavior; no database migration is required.

Scheduled tasks: independent task dispatch ​

The Electron runner admits independent due tasks without awaiting another task's prompt setup. Local in-flight ownership is keyed by task ID and Host instance until setup settles; Host remains authoritative for enabled, due and overlap checks. A replaced Host's completion cannot clear its successor's local ownership. Stop prevents new polls; admitted work keeps the existing execution/failure lifecycle. Failures remain observable and the 90-second late policy is unchanged.

Scheduled tasks: project removal and automations ​

Removing a project pauses its bound scheduled tasks without deleting their definitions, schedule, workspace binding or run history. Session references in history may become null when the project's conversations are removed. Already admitted task runs block removal even before a conversation turn starts. Tasks belonging to other projects and unbound legacy tasks are unaffected. Explicit resume or Run now may recreate a project from the preserved path; automatic polling cannot do so while paused.

Scheduled tasks: legacy task maintenance ​

Agent tools allow title, prompt and pause updates on legacy automatic tasks without a schedule, including an echoed unchanged cadence. These edits do not arm the task or capture the foreground workspace. Explicit enabling, a cadence change or a supplied schedule still follows schedule validation. Resume requires an explicit valid schedule; Manual-to-Hourly retains its existing default interval behavior.

Scheduled tasks: calendar intent ​

The optional config_json.calendarConfigured boolean distinguishes an explicitly configured Daily/Weekly calendar from Hourly's internal schedule placeholder. Without the key, legacy Daily/Weekly schedules are treated as configured; legacy Hourly schedules retain their values but require an explicit schedule when converting to Daily/Weekly. Known calendar intent survives Hourly and restart, including midnight. Clearing or replacing the calendar with a different non-calendar placeholder clears intent. This additive extension needs no table/schema migration; older versions ignore it and cannot enforce the new conversion guard. Metadata-only edits and Manual-to-Hourly remain unchanged.

Scheduled tasks: workspace identity ​

Stored workspace bindings use the existing project canonicalization contract on both write and read. On Windows, slash direction, case, trailing separators and extended path prefixes do not hide a task from its own project's conversation. The distinction between missing legacy bindings and explicit null remains unchanged. Foreign-project tools cannot list or mutate bound tasks.

Cloud configuration sync ​

The Host exposes the configSync.getState, configSync.test, configSync.configure, configSync.syncNow, configSync.pause, configSync.unlock, configSync.approve, configSync.reject, configSync.mapProject, configSync.listHistory, configSync.restore, configSync.changePassword, and configSync.disconnect methods through the existing Electron Host bridge. These methods operate on the Host-owned encrypted S3 vault and explicit portable-domain adapters. Connection input uses endpoint, region, bucket, prefix, accessKeyId, optional secretAccessKey and sessionToken, pathStyle, and mode: "sync" | "backup". The former WebDAV fields are rejected. Omitted credential fields mean reuse for the exact connection; an empty token clears it. Responses contain no secrets or vault keys.

configSync.test proves reliable conditional writes with a random temporary object and verifies cleanup. It returns { ok: true, conditionalWrites: true } or an error, never an append-only capability result. configure performs the same probe and creates or unlocks the vault without applying remote settings.

configSync.syncNow runs the saved mode. Sync reconciles shared, observed and remote state before conditional publication and local application. Backup publishes only a local snapshot to its separate head. Publication precondition failures restart from the latest head, never an unconditional overwrite.

configSync.syncNow has no aggregate request deadline: the Host owns the multi-step operation, and renderer route navigation does not cancel it. A reopened Settings page reads the Host's current syncing state. Individual S3 requests remain bounded; Host shutdown is still the lifecycle boundary.

configSync.listHistory returns at most 30 indexed entries for the saved mode. It returns metadata only, and is not broadcast as a configSync.changed state. Sync restore requires acknowledgePropagation: true and publishes a new shared revision. Backup restore is local only and leaves remote heads intact. Both save a local recovery point and keep sensitive activation pending. changePassword CAS-rewraps the same key; it does not revoke unlocked devices.

State responses and configSync.changed include mode and S3 connection metadata. Approval/rejection remain digest-bound. Only the new S3 format is supported; there is no old-data detection or migration. Disconnect removes only current S3 credentials, keys and local sync metadata/staging; unrelated files and remote objects remain intact. See 22-config-sync.md.

Local-first · Model-agnostic · Plugin-powered. nib.am