Skip to content

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 Bash processes globally, 2 per session
  • 8 read/search tools globally
  • 2 mutating tools globally, 1 per session
  • 4 plugin tools globally
  • 4 tool executions per session
  • 64 queued tool executions globally

Permission prompts do not consume an execution slot. A full queue returns HOST_OVERLOADED with retryable semantics in the tool result instead of waiting indefinitely or spawning more work. The limits are host-owned so Electron and the sidecar cannot independently over-admit the same resources. 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 ​

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

Response ​

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

Error ​

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

Notification (server → client, no id) ​

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

3. Handshake ​

On spawn, Electron must call:

app.handshake ​

Params:

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

Result:

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

Rules:

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

Protocol v11 is paired with host-core storage schema 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.handshake
  • app.health
  • app.getVersion
  • app.getOnboarding — inline onboarding checklist state (D031)

app.health returns a diagnostic toolBudget object:

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

Workspace ​

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

Review snapshots (ADR 0043) ​

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

Projects ​

  • projects.list — returns durable project records ordered pinned-first, then by last-opened time; includes records materialized by session imports
  • projects.create({ path }) — upserts a durable project record without changing the active workspace and returns the host-generated project id
  • projects.remove({ path }) — deletes one durable project row together with every session attached to it, removing those sessions' transcript, scratch, and review files and the project's durable memory, and never touching the project folder on disk. Idempotent: an unknown path returns { removed: false, sessionsRemoved: 0 }. 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 saved
  • project.memory.set({ path, entries }) — normalizes and stores visual memory entries, derives readable content, and validates the 32 KiB limit. The derived value is injected into that project's next runtime context as user-provided context. { path, content } remains supported for legacy callers and returns a memory record without structured entries.
  • project.groups.list — returns one host-owned logical group per named project. Existing path-only records are compatibility legacy groups.
  • project.group.create({ name, folders }) — validates the display name and local directories, stores the ordered roots, and returns the new group without changing the active workspace. A non-legacy root cannot belong to a second group.
  • project.group.rename({ groupId, name }) — persists the group display name.
  • project.group.update({ groupId, name, folders }) — 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.set
  • secrets.delete
  • secrets.has
  • secrets.getForRuntime — main/host only, never reachable from the renderer
  • // never secrets.get to renderer logs

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

Settings ​

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

Sessions ​

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

  • session.create — accepts optional thinkingLevel and optional inheritPermissionFromSessionId; when present, the host copies the existing session's persisted permission mode atomically, while omission preserves the existing inherit default. Missing/null thinking level defaults to off.

  • session.fork — accepts sessionId, an optional caller-provided display title, and optional throughMessageId; creates one independent session from the source's current active canonical transcript, truncated inclusively at the selected message when supplied. The child inherits project/provider/model/mode/thinking and permission configuration, receives new message/tool-call ids, and starts without turns, revisions, notifications, artifacts, grants, or scratch data. Missing sources return NOT_FOUND; Electron rejects active sources with AGENT_BUSY before forwarding and normalizes the host's persisted running-turn CONFLICT fallback to AGENT_BUSY; an unknown source or throughMessageId returns NOT_FOUND

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

  • session.delete

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

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

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

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

  • session.appendMessage

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Stats ​

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

Plan and Goal state and approvals ​

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

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

Scheduled tasks ​

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

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

Canonical thinking levels at the host boundary are:

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

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

Tools ​

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

Shells ​

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

Tool execution starts only after admission. Shell spawn retries transient resource exhaustion (EAGAIN / WouldBlock) with bounded backoff, never retries a command after it has started, and reaps timed-out children before releasing the execution slot.

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.evaluate
  • permissions.resolve
  • permissions.pending (D374: open requests as Host state)
  • permissions.listSessionGrants
  • permissions.clearSessionGrants

Plugins ​

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

Marketplace ​

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

Providers and models ​

  • providers.list / providers.get / providers.create / providers.update / providers.delete
  • a plugin-owned row (ownerPluginId) is refreshed from its manifest on every load, so providers.update / providers.delete refuse it with a PROVIDER_OWNED_BY_PLUGIN error; only the owning plugin's lifecycle changes or removes it (ADR 0259)
  • providers.setSecret({ id, secretValue }) — stores or clears one provider's API key (secret:provider:<id>:api_key and the row's secret_ref). It is the write a plugin-owned row accepts: only the credential the declaration asks for changes, never a field the manifest owns. An empty or omitted secretValue deletes the stored key. Returns { provider }, or null for an unknown id
  • providers.getSecret — main/host only, never reachable from the renderer
  • providers.listModels / providers.cacheModels — discovered model rows and their host-side cache (ADR 0027 / ADR 0134)
  • providers.testConnection

Agent capabilities (skills, subagents, MCP servers) ​

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

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

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

Search, artifacts, keyboard ​

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

Audit ​

  • audit.append

Notification (D117) ​

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

4a. Notification contracts (protocol v4) ​

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

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

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

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

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

5. Tool execute contract ​

tools.execute params ​

ts
type ToolsExecuteParams = {
  sessionId: string
  turnId?: string
  toolCallId: string
  toolName: string
  args: unknown
  /** Diagnostic/request context only; never used for authorization. */
  requestedMode?: "plan" | "goal" | "agent"
  expectedCommandShellId?: CommandShellId
  /** Bash only: dialect pinned by the same runtime turn. */
  expectedCommandShellDialect?: "powershell" | "cmd" | "posix"
  /** Bash only: host default 60000; accepted override 1000..21600000. */
  timeoutMs?: number
}

Authoritative mode and workspace resolution are session-scoped:

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

For Read/Glob/Grep/Write/Edit, the host classifies an explicit path outside the workspace and scratch roots before the low-risk auto-allow rule. auto executes it, while ask and accept-edits emit permissions.request; denial, 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 claiming agent in tools.execute cannot widen it, and the *_IN_PLAN error codes are shared by both kinds.
  • Plan and Goal Bash follows the resolved permission mode: ask and accept-edits emit permissions.request; auto executes without confirmation and may mutate. The host re-resolves the effective shell ID/dialect and requires the exact expectedCommandShellId and expectedCommandShellDialect before permission evaluation and again before spawn; it streams stdout/stderr separately. A configured shell may fall back to the first available platform shell before the turn pin is created, but execution never changes shell after the pin.
  • Agent applies the normal registered-tool and permission policy.

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

result ​

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

5.1 Plan and Goal submission and approval contracts ​

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

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

type ProposalKind = "plan" | "goal";

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

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

type PlanApprovalAction = "approve" | "reject";

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

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

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

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

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

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

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

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

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

Host notifications:

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

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

method: "tools.output"
params: ToolsOutputParams

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

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

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

5.2 Shell catalog ​

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

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

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

type CommandShellOutputStream = "stdout" | "stderr";

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

6. Permission request notification ​

Host may emit:

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

Electron/UI resolves via:

ts
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.

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

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

8. Concurrency / ordering ​

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

9. Logging rules ​

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

10. Acceptance ​

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

Scheduled automation tools ​

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

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

Scheduled tasks: task-owned execution settings ​

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

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

Scheduled tasks: independent task dispatch ​

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

Scheduled tasks: project removal and automations ​

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

Scheduled tasks: legacy task maintenance ​

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

Scheduled tasks: calendar intent ​

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

Scheduled tasks: workspace identity ​

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

Cloud configuration sync ​

The Host exposes the configSync.getState, configSync.test, configSync.configure, configSync.syncNow, configSync.pause, configSync.unlock, configSync.approve, configSync.reject, configSync.mapProject, configSync.listHistory, configSync.restore, configSync.changePassword, and configSync.disconnect methods through the existing Electron Host bridge. These methods operate on the Host-owned encrypted 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.

Local-first · Model-agnostic · Plugin-powered. AIUO.NET