06. Host RPC Protocol
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. The per-session mutation permit is acquired before the global mutation slot; queued Write/Edit calls therefore do not hold global capacity while waiting for an earlier mutation in the same session.
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 v16. 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. 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 }. A path that is a root of a stored multi-folder project group is refused so the group keeps a valid primary root, and the call is refused (1008 /CONFLICT) while any attached session has a running turn, so a live turn never loses the transcript it is writing.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 })— edits the group name and ordered roots. The primary root must remain first; duplicate roots are removed, 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.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 returnNOT_FOUND; Electron rejects active sources withAGENT_BUSYbefore forwarding and normalizes the host's persisted running-turnCONFLICTfallback toAGENT_BUSY; an unknown source orthroughMessageIdreturnsNOT_FOUNDsession.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.deletesession.getScratchPath— the session's scratch directory (D114), created on demandsession.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 only session metadata and does not updateupdated_at, transcript content, message count, or historical notification title snapshots.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; 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. 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.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.
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.
Tools
tools.listtools.executetools.aborttools.outputnotifications for orderedstdout/stderrchunks
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.
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). 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)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.- 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
}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, timeout, 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
timeoutMs: 120000
}Electron/UI resolves via:
method: "permissions.resolve"
params: {
requestId: string
decision: "allow-once" | "allow-session" | "deny"
}Timeout behavior (D005): after 120s unresolved → deny.
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, expiresAt, and remainingMs. Requests past the timeout are omitted. 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 |
| -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 - timeout path returns deny decision after 120s
- 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 vault and the explicit portable-domain adapter registry. They do not expose raw secrets or local filesystem bindings to the Renderer.
configSync.test performs a capability probe against a temporary remote object and reports whether reliable strong conditional writes are available. configSync.syncNow and the five-minute automatic poll serialize per vault, reconcile against the last acknowledged base, publish immutable encrypted objects followed by a conditional head update, and retain unresolved conflicts/security-sensitive imports as pending state. A failed head precondition restarts from the newly read head; it never overwrites blindly.
configSync.listHistory returns revision IDs, creation timestamps, parent IDs, and counts without decrypting data in the Renderer. configSync.restore requires an explicit propagation acknowledgement, writes an encrypted local recovery point, publishes a new head with a CAS, and keeps executable/security-sensitive entities pending until local approval. configSync.changePassword updates the wrapped-key header with a strong-ETag CAS; it does not revoke copied old vault keys.
The public result is a redacted state snapshot. configSync.changed is a Host notification carrying that same snapshot. Approval and rejection require the current entity digest, so a security-relevant edit cannot reuse an older local decision. Disconnect deletes only local credentials, vault keys, metadata and staging files; remote objects remain intact.