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
Bashprocesses 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
{
"jsonrpc": "2.0",
"id": "req_01H...",
"method": "tools.execute",
"params": {}
}Response
{
"jsonrpc": "2.0",
"id": "req_01H...",
"result": {}
}Error
{
"jsonrpc": "2.0",
"id": "req_01H...",
"error": {
"code": 1003,
"message": "PATH_OUTSIDE_WORKSPACE",
"data": {
"errorCode": "PATH_OUTSIDE_WORKSPACE",
"details": {}
}
}
}Notification (server → client, no id)
{
"jsonrpc": "2.0",
"method": "permissions.request",
"params": {}
}3. Handshake
On spawn, Electron must call:
app.handshake
Params:
type HandshakeParams = {
protocolVersion: 11
client: "electron-main"
clientVersion: string
locale: string // default "en"
}Result:
type HandshakeResult = {
protocolVersion: 11
host: "rust-host-core"
hostVersion: string
features: string[]
capabilities: string[] // does not include "a2a"
}Rules:
- If protocol major version mismatches → abort boot
- Electron should exit with actionable error if handshake fails
- All subsequent calls require successful handshake
- Version 4 introduced the durable notification inbox and the notification-bearing
session.endTurnresult. - Version 5 requires the host-owned
session.forksnapshot operation; a version 4 host must be rejected before chat becomes interactive (ADR 0023). - 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). - 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. - Version 10 added the A2A protocol stack (ADR 0147): the host-core A2A broker, the
a2a.*method domain, and thea2a.task.event/a2a.pushhost→client notifications. Thecapabilitiesarray advertised"a2a"; a v9 host that could not advertisea2awas rejected before the UI became interactive. - Version 11 withdraws that stack (ADR 0165 / D326). The
a2a.*methods anda2a.task.event/a2a.pushnotifications 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.handshakeapp.healthapp.getVersionapp.getOnboarding— inline onboarding checklist state (D031)
app.health returns a diagnostic toolBudget object:
type ToolBudgetHealth = {
active: number
queued: number
total: number
shell: number
reads: number
mutations: number
plugins: number
}Workspace
workspace.getworkspace.setworkspace.clear
Review snapshots (ADR 0043)
review.rollback({sessionId, snapshotId})— verify the current post-tool hash, restore the session-owned previous bytes, and return one ofrolledBack,alreadyRolledBack,conflict, orunavailable.
Projects
projects.list— returns durable project records ordered pinned-first, then by last-opened time; includes records materialized by session importsprojects.create({ path })— upserts a durable project record without changing the active workspace and returns the host-generated project idprojects.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 savedproject.memory.set({ path, entries })— normalizes and stores visual memory entries, derives readablecontent, 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 compatibilitylegacygroups.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 aprimarypromotes that selected root and reorders the roots around it, while omitting it keeps the stored primary and refuses dropping it. Duplicate roots are removed, aprimaryoutsidefoldersis 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.setsecrets.deletesecrets.hassecrets.getForRuntime— main/host only, never reachable from the renderer- // never
secrets.getto 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.getsettings.set— optionalnetworkProxy(system/direct/custom). Custom requires anhttp/https/socks5URL. host-core uses the stored value for marketplacecurl --proxyand does not put it on process env.
Sessions
session.list— returns summaries with host-authoritativemessageCountalongside the existing session metadatasession.create— accepts optionalthinkingLeveland optionalinheritPermissionFromSessionId; when present, the host copies the existing session's persisted permission mode atomically, while omission preserves the existinginheritdefault. Missing/null thinking level defaults tooff. Optional booleanfastMode(ADR 0334) stores the session/fastswitch; missing/null meansfalseand a non-boolean returnsINVALID_PARAMS. Optionalisolated: true(ADR 0329) also requiresprojectPathand 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 aWORKTREE_*error (§7).session.fork— acceptssessionId, an optional caller-provided displaytitle, and optionalthroughMessageId; 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 returnNOT_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 returnCONFLICT, normalized by Electron toAGENT_BUSY. The source turn continues without sharing runtime state with the child.session.get— accepts an optional renderer read window:messageBeforeis the exclusive zero-based end offset,messageLimitis the positive page size, andcontentLimitis the positive character budget for the derived display projection. The response includesmessageStartandhasMoreBeforewhen 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. OptionalmessageAroundcenters that window on a stable message ID resolved against the canonical file, requiresmessageLimit, and excludesmessageBefore. 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 physicalmessageEndandhasMoreAfterto support contiguous forward pages (ADR session-content-search). When the selected message hasparentToolCallId, optionalnavigationParentcontains 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 demandvisualizations.readFragment({ sessionId, fileName })— private Main-to-Host reader forpi-genui:documents (D671). No renderer IPC or Plugin SDK method is added. Parameters contain only a validated session ID and direct.htmlbasename; caller-supplied roots and extra fields are rejected. Host resolves against its current data directory and pins native directory capabilities: Unix uses relativeopenatwith no-follow/nonblocking flags; Windows usesNtCreateFilewith a pinnedRootDirectoryfor 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 areINVALID_PARAMS. A successful rename changes title metadata and marks the title sourcemanual; it does not updateupdated_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 isdefault. The write keeps that source so a title plugin can still replace the derived text, and it does not updateupdated_at, transcript content, or message count.session.configure— atomically persistsmode,providerId,modelId, and optionalthinkingLevel(off|minimal|low|medium|high|xhigh|max|omit) for the next pi turn; omitting/nullthinkingLevelpreserves the current value; optional booleanfastMode(ADR 0334) follows the same rule, and a non-boolean isINVALID_PARAMS; invalid modes or levels returnINVALID_PARAMS; mode isplan | goal | agentand changing any session configuration is allowed only while idle and without a pending/queued/running Plan or Goal recordsession.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 onlysessions.project_idandupdated_at; transcript, revisions, artifacts, notifications, and scratch data stay with the session. Blank ids or paths areINVALID_PARAMS, an unknown session isNOT_FOUND, and a session with a running turn isCONFLICTso a live agent never switches instruction roots mid-turn. An isolated session is refused withWORKTREE_MOVE_UNSUPPORTED. Additive RPC; no protocol version bump.session.appendMessagesession.saveInflightMessage— Electron-main-only checkpoint of the assistant reply currently streaming, including the finishedmessage_endsnapshot:{ sessionId, turnId?, message }atomically replacessessions/<id>.inflight.json(D299, D327, spec 04 §2.1). Returns{ ok, saved };savedis 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 areINVALID_PARAMS-class failures. Acompleted/errorsession.endTurndeletes 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, includingcompletedturns ascomplete. 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-negativetokensBefore; it does not insert a message/search row or change the visible transcript projectionsession.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 owningturn_idacross 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 usesession.truncateFrominstead 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 unknownfromMessageIdisNOT_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 }whererevisionis 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). WithrevisionIndex, 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 onlymessage_countchangessession.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 concurrentsession.appendMessagesurvives. 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 ofsession.get+session.replaceMessagessession.listRevisions— list linear variants for a root user familysession.activateRevision— replace live transcript withprefix + branchand 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'sactiveRevisionstamp 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 owningturn_idsession.beginTurn({ sessionId, providerId?, modelId?, sessionMessageId? })— starts one durable turn. WhensessionMessageIdis 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.queuePrioritizeappends an entry to the end of its session's priority block (priority = MAX + 1) and refuses an already promoted entry withCONFLICT;queueReorderswaps one non-promoted entry with its adjacent non-promoted neighbour and reports{ moved }. Listing and delivery order ispriority ASCfor promoted entries followed byposition ASCfor the restsession.endTurn— atomically moves a running turn to its terminal state and conditionally returns the newly created notification forcompleted/error; returns no notification whencreateNotification=false, foraborted, or for an already-terminal turn. It also settles the session's in-flight reply checkpoint (D299):completed/errorremove 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 anabortedassistant message and returns it asrecovered; a plainaborted(user Stop) leaves the checkpoint for the arriving final row to supersedesession.import— atomically imports one converted session; a non-empty project path is normalized and upserted intoprojectsbefore 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 existingprojectIdcreated byprojects.create; the historicalprojectPathremains metadataplugin.session.importBatch— boundedskipor all-or-nothingfailbatchplugin.session.list/plugin.session.get/plugin.session.listMessages— read only the calling plugin's active imported sessionsplugin.session.autoTitleContext— under Electron'ssession.autoTitlepermission, return only the first user prompt (≤1,000 characters) and first assistant reply (≤500 characters) for an activedefault-titled sessionplugin.session.setAutoTitle— validate a title and update only when the expected title anddefaultsource still match; returns{ updated }plugin.session.rename— rename an owned active imported sessionplugin.session.delete—trashhides and retains the transcript;purgeremoves it and permits re-importplugin.usage.listTurns— keyset page of completed-turn facts (identifiers and token counters, never a message body) for non-deleted sessions. Gated in Electron main byusage.read. Additive; no protocol version bump.- Successful plugin session mutations cause Electron main to emit one
sessionsChangedrenderer 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 firsttaskdelivery, and return the real targetsessionIdplus the message record. Worker creation is limited per parent and plugin; a worker cannot create another worker.session.collaboration.send— enqueue ataskormessagedelivery to an existing Agent session. The host bindssourceSessionIdandsourceTurnIdto 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.resultmay select a delivery bymessageIdorturnId.session.collaboration.pending— list queued completion callbacks for the Electron drain;failrecords a dispatch failure and creates the requested failure callback once;settlederives 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 returnNOT_FOUND; native Pi sessions and blank ids are rejected asINVALID_ARGUMENT.tools.executewithtoolName: "TodoWrite"accepts only{ todos }and replaces the complete ordered checklist. The host trims content, defaults priority tomedium, truncates overlong Unicode content at 500 characters with a warning, demotes laterin_progressitems topending, 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
todosis empty and emitstodos.changedafter the transaction commits. The event payload is the same complete snapshot returned bytodos.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 completedturnstoken columns andusage_jsoncache/reasoning fields into local-calendarday/week/monthbuckets. Additive RPC; no protocol version bump. Default range is bounded (53 weeks / 52 weeks / 24 months).weekuses ISO week year. Empty buckets in range are returned as zero rows so a calendar consumer sees a complete window.session.endTurn.usageis the durable turn total: parent assistant messages plus settled subagent usage, not a rewrite ofmessage.usage. The user-facing dashboard is pluginpi.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'ssessionId,turnId, andtoolCallIdplus thekind; host-core performs the mode transition to that kind's mode with a compare-and-swap update and emitsplans.changedcarrying thekind. An unrecognizedkindfails withINVALID_PARAMSplans.submit— writes the host-owned artifact under the kind's directory and creates a pending proposal whosekindis persisted on the rowplans.pending— returns only pending approval rows, the session planning state, and thekindof 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 cardsplans.resolve— validates one matching approve/reject response and, for approval, commits the selected permission mode andexecution_state = queuedplans.queuedExecutions/plans.claimExecution/plans.finishExecution— consume and transition execution fields on the same approval row; the claimed execution reports itskindso the sidecar can select the matching execution instructionplans.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.deletescheduled.import— imports task records and normalizes their persisted modescheduled.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:
off | minimal | low | medium | high | xhigh | maxSession 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, detachedHEAD, git missing or older than 2.29, a remote-host project —WORKTREE_REMOTE_UNSUPPORTED, …).session.createrefusesisolated: truefor 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 apreviewToken. The token covers the sessionHEAD, 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) isWORKTREE_CHANGED_SINCE_PREVIEW; a dirty project isWORKTREE_PROJECT_DIRTY; a conflict isWORKTREE_CONFLICTand 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 withWORKTREE_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;statusesChangedasks the renderer to refresh its session list. The renderer reads it in the background for the sidebar marker.worktree.sync({ sessionId })→{ outcome }(syncedwithcommits, orupToDate): "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 isWORKTREE_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 isNOT_FOUND). The command runs once in each new worktree.worktree.delegate.create({ sessionId, delegationId })→{ worktree }andworktree.delegate.settle({ sessionId, delegationId, merge })→{ settlement }— agent-runtime only, forWrite/Editdelegates 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.listtools.executetools.aborttools.outputnotifications for orderedstdout/stderrchunks
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.listbackgroundTasks.outputbackgroundTasks.stopbackgroundTasks.changednotification 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.listsettings.setwith a partial settings object; omitted fields are preserved, and a changed effectivedefaultCommandShellis 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.evaluatepermissions.resolvepermissions.pending(D374: open requests as Host state)permissions.listSessionGrantspermissions.clearSessionGrants
Plugins
plugins.listplugins.loadDevplugins.installFromPathplugins.installFromPackage— install a.piplugarchive after checksum verificationplugins.enableplugins.disableplugins.uninstallplugins.getPermissionsplugins.grantPermissions/plugins.revokePermissions— change the granted set; the runtime enforces the intersection of declared and grantedplugins.setAutoUpdateplugins.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 URLmarket.search/market.getDetailmarket.install— download, verify (PLUGIN_INTEGRITY,PLUGIN_MARKET_*), and install one catalog releasemarket.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, soproviders.update/providers.deleterefuse it with aPROVIDER_OWNED_BY_PLUGINerror; 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_keyand the row'ssecret_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 omittedsecretValuedeletes the stored key. Returns{ provider }, ornullfor an unknown idproviders.getSecret— main/host only, never reachable from the rendererproviders.listModels/providers.cacheModels— discovered model rows and their host-side cache (ADR 0027 / ADR 0134).cacheModelsaccepts optionalcomplete(default false). Model rows expose optionaldiscoveryState(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_INVALIDon 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 compatiblesearch.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_ARGUMENTabove it). Each 30-session page includessession,projectName,metadataMatch, the full matchingmessageCount, and at most twomatchescontainingmessageId,role,createdAt, and asnippetcontaining 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: nullmarks 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. Defaultdirection: "around"returns up to 21 nearby message text projections;"before"/"after"returns up to 20 messages excluding the anchor. Returnmessages,hasMoreBefore,hasMoreAfter,previousMatchId, andnextMatchId. 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 returnNOT_FOUND; invalid directions or identifiers returnINVALID_ARGUMENT. See ADR session-content-search.artifacts.list— Plan/Goal checkpoint artifacts for a sessionkeyboard.setGlobalShortcut— host-owned native fallback for the plugin launcher chord where Electron cannot register it
Audit
audit.append
Notification (D117)
notification.listnotification.markReadnotification.markAllReadnotification.clear
4a. Notification contracts (protocol v4)
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=falsemeans 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.idis 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.createdJSON-RPC server notification is emitted. Electron receives the inserted record directly fromsession.endTurn, avoiding a second ordering channel between terminal turn persistence and UI refresh. createNotification=falsesuppresses 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.sessionTitleis 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
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:
- Host loads
sessionIdand resolves its persistedproject_id/path. - Host reads the persisted
sessions.modeand validates it asplan | agent. A conflictingrequestedModeis ignored for authorization and recorded only as diagnostic data. - That path becomes the tool sandbox root for permission preview, execution, artifact paths, and audit context. A tool's explicit
pathmay name an outside location only after the host applies the outside-path permission rule; successful external results retain an absolute canonical path. - The mutable
workspace.getselection is not consulted for a valid durable session, so switching a retained project tab cannot redirect a background call. - A durable path-less session resolves no root and receives
WORKSPACE_REQUIREDwhere the tool requires one. A selected project is not inherited. - Legacy calls whose session does not exist may temporarily fall back to the selected workspace; new renderer flows must always provide a valid
sessionId. - A database/session-resolution error returns
INTERNALand 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 claimingagentintools.executecannot widen it, and the*_IN_PLANerror codes are shared by both kinds. - Plan and Goal
Bashfollows the resolved permission mode:askandaccept-editsemitpermissions.request;autoexecutes without confirmation and may mutate. The host re-resolves the effective shell ID/dialect and requires the exactexpectedCommandShellIdandexpectedCommandShellDialectbefore 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
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.
// 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:
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: ToolsOutputParamsplans.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
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:
method: "permissions.request"
params: {
requestId: string
sessionId: string
toolCallId: string
toolName: string
risk: "low" | "medium" | "high"
argsPreview: unknown
reason: string
}Electron/UI resolves via:
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.
| code | errorCode | meaning |
|---|---|---|
| 1000 | INTERNAL | unexpected host failure |
| 1001 | UNAUTHORIZED | missing/invalid handshake or capability |
| 1001 | HOST_SHUTTING_DOWN | the host is draining after EOF and refused the call |
| 1002 | INVALID_PARAMS | schema validation failed |
| 1002 | MODEL_ALIAS_TOO_LONG | provider row alias exceeds 60 code points |
| 1002 | MODEL_BINDINGS_DEGRADED | stored model bindings are unreadable; explicit model-array replacement is refused |
| 1003 | NOT_FOUND | entity missing (legacy slot, kept for old callers) |
| 1006 | RATE_LIMITED | a per-caller budget window was exhausted |
| 1007 | NOT_FOUND | entity missing |
| 1007 | SESSION_NOT_FOUND | the named session does not exist; tool requests never fall back to the global workspace |
| 1008 | CONFLICT | busy/conflict state |
| 1008 | AGENT_BUSY | the session has a running turn |
| 1009 | PLUGIN_INVALID | manifest/validation failure |
| 1010 | PLUGIN_LOAD_FAILED | enable/load failure |
| 1011 | PROTOCOL_MISMATCH | app.handshake protocol version mismatch |
| 1012 | PLUGIN_INTEGRITY | package checksum/signature mismatch |
| 1013 | PLUGIN_PERMISSION_DENIED | plugin lacks the permission the call needs |
| 1014 | PLUGIN_NETWORK | marketplace download/catalog fetch failed |
| 1015 | MCP_INVALID | user MCP server definition failed validation |
| 1015 | PLAN_* | 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 |
| 1016 | SKILL_INVALID | user skill document failed validation |
| 1017 | SUBAGENT_INVALID | user subagent document failed validation |
| 1018 | CAPABILITY_INVALID | agent capability root/scope setting failed validation |
| 1019 | PLUGIN_CANCELLED | the user cancelled a marketplace install while it was downloading |
| 1020 | PLUGIN_MARKET_NOT_PUBLISHED | the platform has the version and is not offering it yet |
| 1021 | PLUGIN_MARKET_ARCHIVED | the plugin was withdrawn from the platform |
| 1022 | PLUGIN_MARKET_NOT_FOUND | the platform does not have that plugin or version |
| 1023 | PLUGIN_MARKET_RATE_LIMITED | the download endpoint asked the client to wait |
| 1024 | PLUGIN_MARKET_NO_SOURCE | no distribution target can serve the package |
| 1025 | WORKTREE_* | every isolated-session worktree failure (WORKTREE_NOT_GIT, WORKTREE_CONFLICT, WORKTREE_PROJECT_DIRTY, WORKTREE_MISSING, …) shares this slot; the string code distinguishes them |
| -32029 | HOST_OVERLOADED | RPC dispatcher capacity exhausted |
| -32601 | — | unknown method |
| -32700 | — | unparseable request line |
| 1002 | LIMIT_EXCEEDED | an 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
- Requests may be concurrent within the dispatcher cap. Read/search tools may run in parallel;
Write/Editare bounded and FIFO-ordered per session, with at most one mutation in flight for a session. - Different sessions may continue concurrently across retained project tabs; each resolves its own project root and grants
- Notifications may arrive anytime after handshake
tools.outputpreserves 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- Abort is idempotent and shuts down the complete Bash process tree
- 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
- 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.
- 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.
- 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
- Electron spawns host and completes handshake
- health method returns ok
- denied tool path returns
TOOL_DENIED - an unresolved permission remains pending until an explicit decision or cancellation
- switching the selected workspace from A to B does not change the tool root of a call issued by session A
- Protocol v4
session.endTurncreates/returns exactly one notification for unseen completed/failed turns and none for visible-current, aborted, or repeated terminal updates - Notification list/unread/read-all/clear round-trip through host-core and remain bounded to the newest 200 durable rows
- 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
- Forking through a message excludes every later source row and rejects an unknown message without creating a child
- A forged
requestedModecannot authorize a tool against the durable mode; Plan and Goal deny Write/Edit/plugin/unknown tools and apply permission prompts to Bash according toask/accept-edits/auto - SubmitPlan and SubmitGoal write exact Markdown bytes to a unique
.pi/plan/*.mdor.pi/goal/*.mdfile with hash/size and structured title/question fields; only matching approve/reject responses can resolve the liveplan_approvalsrow, and a submit tool run against the other kind fails withPLAN_KIND_MISMATCHwithout writing an artifact - Plan and Goal expiry, abort, crash, scheduled rejection, and stale responses produce the documented durable statuses and events
- 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.
