@acpjs/core
v0.5.0
Published
acpjs AcpHost: agent process lifecycle, ACP connection, event normalization/numbering, fs/terminal defaults, permission routing, restart and StorageAdapter scheduling.
Downloads
838
Readme
@acpjs/core
The AcpHost runtime for acpjs (Node / Electron). It spawns arbitrary ACP agent
subprocesses, establishes the official SDK connection, normalizes protocol
notifications into numbered @acpjs/protocol events with a replayable log, and
provides catch-up subscriptions, permission routing, reverse fs/terminal
capabilities, crash recovery with restart policy, and StorageAdapter
scheduling.
Install
pnpm add @acpjs/coreESM-only. Requires node >= 24. Runtime dependencies: @acpjs/protocol,
@agentclientprotocol/sdk, and zod (an SDK peer).
Quick start
End to end: spawn an agent, create a session, subscribe, and send a prompt.
import { createAcpHost } from '@acpjs/core'
const host = createAcpHost({
restart: 'on-crash',
})
const agent = await host.spawnAgent({
id: 'my-agent',
command: 'npx',
args: ['some-acp-agent'],
})
const created = await host.createSession(agent.agentId, {
cwd: process.cwd(),
mcpServers: [],
additionalDirectories: [],
})
const { sessionId } = created
// Subscribe from seq 0 to receive the full backlog plus live events.
const unsubscribe = host.subscribe(sessionId, 0, (event) => {
console.log(event.seq, event.type)
})
const result = await host.prompt(sessionId, [{ type: 'text', text: 'hello' }])
// result.stopReason is e.g. 'end_turn'
unsubscribe()
await host.closeSession(sessionId)
await host.dispose()createSession resolves to the created SessionSnapshotWire. Agent-side
JSON-RPC errors, including authentication-related errors, are propagated to the
caller; acpjs does not model login state.
Public API
createAcpHost(options?) returns an AcpHost. The AcpHost class is also
exported directly.
- Agents:
spawnAgent(definition),getAgent(agentId),getAgents(),disposeAgent(agentId) - Sessions:
createSession(agentId, { cwd, mcpServers, additionalDirectories }),prompt(sessionId, ContentBlock[]),cancel(sessionId),closeSession(sessionId),listSessions(agentId, { cursor?, cwd? }),resumeSession(agentId, sessionId, { cwd, mcpServers?, additionalDirectories }),deleteSession(agentId, sessionId),loadSession(agentId, sessionId, { cwd, mcpServers, additionalDirectories })(also reopens aclosedsession — it re-loads it from the agent and replays history; adeletedsession stays permanently rejected),setMode(sessionId, modeId),setConfigOption(sessionId, configId, value),getSession(sessionId),getSessions() - Events:
subscribe(sessionId | undefined, fromSeq, callback). Passundefinedto subscribe to the host stream (agent/session/permission projections and diagnostics). - Permissions:
respondPermission(requestId, outcome)whereoutcomeis the protocolRequestPermissionOutcome. - Persistence:
restoreSessions()rebuildsdisconnectedsessions from storage after a host restart and returns their snapshots. disposeAgent(agentId): gracefully tear down a single agent — the per-agent counterpart ofdispose(). Idempotent (a no-op for an unknown or already-gone id). The agent's sessions transition todisconnected(chat history is preserved, not closed or deleted), the agent is then removed fromgetAgents(), and anagent-removedhost event (payload{ agentId }) is emitted.dispose().
Configuration pipeline (exported for inspection/pre-validation):
resolveHostOptions, resolveAgentDefinition. Validation failures throw
AcpError with code acpjs/config-invalid synchronously; the resolved product
is frozen.
Storage adapters: createMemoryStorage() (the default) and
createJsonlStorage(file).
Default fs handler: createDefaultFsHandler(). Terminal support is opt-in:
inject a complete handler, such as createDefaultTerminalHandler(), through
HostOptions.terminal. Capability derivation:
deriveClientCapabilities(fs, terminal) (INV-6) reports to the agent only the
methods a handler actually implements.
Normalization: normalizeSessionUpdate(update) maps the 13 SessionUpdate
variants to event type / payload / extensions; unmodeled variants
degrade to unrecognized-update (INV-4).
Errors: AcpError carries a code drawn from the closed ACP_ERROR_CODES
namespace in @acpjs/protocol (acpjs/config-invalid,
acpjs/prompt-in-flight, acpjs/already-answered, acpjs/session-closed,
acpjs/agent-exited, acpjs/capability-unsupported,
acpjs/agent-error, acpjs/transport-closed).
Envelope adapter: createHostEndpoint(host) returns an EnvelopeEndpoint
(Transport contract shape). RPC method names come from ACPJS_HOST_RPC_METHODS in
@acpjs/protocol (agents/spawn|list|dispose,
sessions/create|load|list|resume|delete|prompt|cancel|close|setMode|setConfigOption|getAll|restore)
and map to the same-named host methods. Missing required parameters are
rejected at the envelope boundary with acpjs/config-invalid. Event
subscriptions pass through to host.subscribe. Permission requests are pushed
back as InboundRequest (kind permission) and answered through
respondInbound. The @acpjs/client in-process transport connects to this
endpoint with zero direct dependency on core.
All public method parameters and return values are structured-clone
serializable (events are @acpjs/protocol events), so they can be carried over
the Transport contract directly.
HostOptions
| Field | Default | Notes |
| ---------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| restart | 'never' | With 'on-crash', only a crashed exit triggers a restart. |
| restartLimit | 3 | Max consecutive restarts; any ready resets the counter. |
| restartBackoff | { initialMs: 1000, factor: 2, maxMs: 30000 } | Exponential backoff. |
| storage | in-memory | StorageAdapter. |
| fs | built-in Node implementation | Replaced wholesale when injected; the injected surface drives the initialize capability report (INV-6). |
| terminal | disabled | No terminal capability unless a complete handler with cleanupSession is injected. |
| killTimeoutMs | 5000 | dispose graceful-shutdown timeout; SIGKILL after it elapses. |
HostOptions is immutable (frozen) once constructed; rebuild the host to change
it.
Snapshots
getAgent / getAgents return AgentSnapshotWire:
{ agentId, status, restartCount, reason?, exit?, capabilities?, authMethods? }.
authMethods is the agent's advertised auth methods captured from the
initialize response (AuthMethod[], re-exported from @acpjs/protocol),
surfaced verbatim and omitted until the handshake completes. acpjs still runs no
authenticate flow; this is the data integrators read to drive out-of-band login.
getSession / getSessions return SessionSnapshotWire:
{ sessionId, status, agentId?, cwd, mcpServers?, additionalDirectories, agentDefinitionId?, title?, updatedAt? }.
Host stream and diagnostics
The host stream (subscribed with subscribe(undefined, fromSeq, cb)) carries:
agent-updated— fullAgentSnapshotWireprojection.agent-removed— payload{ agentId }; emitted whendisposeAgenttears down an agent and removes it fromgetAgents().session-updated— fullSessionSnapshotWireprojection.permission-updated— host-level permission pending/answered/superseded projection.diagnosticevents with the followingcodevalues:agent/spawn,agent/spawn-failed,agent/initialized,agent/initialize-failed,agent/exit(with code/signal),agent/process-error,agent/stderr,agent/restart-scheduled,agent/restart-suppressed,agent/restart-exhausted,agent/kill,session/recovery-skipped,session/load-failed,storage/write-failed,event/unserializable,subscriber/error.
Diagnostics flow on the host stream and never participate in SessionState
reduction. The agent/spawn diagnostic records only env key names, never values
(INV-7).
Implementation-defined decisions
- agentId / requestId format:
agent-<n>andperm-<n>— monotonic per host lifetime, never reused. - cwd default:
AgentDefinition.cwddefaults to the host process cwd; all cwds are absolutized withpath.resolvebefore reaching the protocol. - kill timeout: defaults to 5s (
killTimeoutMsis injectable); dispose ends stdin first (graceful), then sendsSIGKILLon timeout. - auth errors: acpjs runs no authenticate flow and exposes no login APIs or
auth state; it only surfaces the agent's advertised
authMethods(see Snapshots) for integrators to act on. Agent-side authentication failures are propagated as agent JSON-RPC errors; callers configure/login the agent outside acpjs and retry. - prompt protocol-error event shape: the
prompt-finishedevent (and thepromptreturn value) usesstopReason: 'end_turn'as a placeholder and carrieserror: { code, message, data? }.promptdoes not reject, except on agent crash, which rejects withacpjs/agent-exited. - normalization key-omission rule: payload keys whose value is
nullfor an OPTIONAL field are omitted, except keys whose explicitnullis preserved:session_info_update'stitle/updatedAt(clear semantics) andtool_call(_update)'srawInput/rawOutput(any value passed through). A top-level_metalands inextensions._meta; other unknown top-level fields land inextensions.<key>. Theunrecognized-updatepayload preserves the whole update verbatim (including thesessionUpdatediscriminator). - load/resume lifecycle: unknown load/resume uses a staging session that is
invisible to
getSessions()and host/client projections until the agent RPC succeeds. Existing load/resume publishes aresumingprojection but does not clear log/config or write success metadata before the RPC commits.loadbuffers replayedsession/updatenotifications, then emitssession-reset, replay, config, and active status on success.resumerejects replayed history and only updates config/status. - pre-ready failure inside a restart cycle: during a restart cycle
(
restartCount > 0) spawn/initialize failures keep consuming restart budget and retry, makingrestart-exhaustedreachable; the first (non-cyclic) spawn/initialize failure is not retried. - capability gating:
session/list|resumechecksessionCapabilities.<x> != null;loadSessionchecks the top-level boolean;set_mode/set_config_optioncheck whether the session has ever seen modes / configOptions (in a new/load/resume response). Local close/delete lifecycle is always available and remote close/delete is best-effort when the agent declares support. - storage semantics: event writes are queued and write failures emit
storage/write-faileddiagnostics, which are not recursively persisted. Lifecycle tombstones for close/delete are strict commits: if they cannot be written, the API rejects and the closed/deleted success state is not published. JSONL storage skips malformed lines during restore and rewrites via a temporary file followed by rename.restoreSessionsskips closed/deleted metadata and stored events that are not structured-clone safe; restored sessions are markeddisconnected. - dispose semantics: all agents are marked
disposed(terminal reasondisposed), their sessions broadcastdisconnected, and pending permissions aresuperseded. - disposeAgent semantics:
disposeAgent(agentId)is the per-agent counterpart ofdispose()— it gracefully tears down exactly one agent. It is idempotent: an unknown or already-gone id is a no-op. The agent's sessions broadcastdisconnected(history preserved, not closed/deleted) and pending permissions aresuperseded; the agent is then removed fromgetAgents()and anagent-removedhost event (payload{ agentId }) is emitted on the host stream. This is distinct from an involuntaryexitedtombstone, which stays ingetAgents()carrying its exit reason and may restart under the restart policy — see docs/design-philosophy.md "Agent lifecycle". - clientInfo:
{ name: '@acpjs/core', version: '0.0.0' }(version updated by the release pipeline). - subscription shape:
subscribe(sessionId?, fromSeq, callback); replay (seq > fromSeq) and live delivery are stitched in one synchronous critical section (no duplicates, gaps, or reordering — INV-2). Subscriber callback exceptions are isolated and produce asubscriber/errordiagnostic (a second exception while dispatching that diagnostic is swallowed silently, to prevent recursion). Host-stream replay continues by index; host events produced during replay (including asubscriber/errordiagnostic raised by this subscriber) are all delivered before joining the live set; exceptions while delivering asubscriber/errordiagnostic do not produce new diagnostics (matching the live path, to prevent an infinite replay loop). - unserializable-payload rejection: session and host events that do not pass
structured clone are rejected and produce an
event/unserializablediagnostic (the host-side diagnostic strips the original payload, keeping only type and agentId; anevent/unserializablediagnostic that is itself rejected is dropped silently, to prevent recursion). - initialize-failure exit backfill: an initialize failure such as a protocol
version mismatch first broadcasts
exited(initialize-failed)(without exit); after the process actually exits,{ code?, signal? }is backfilled into the AgentRecord (visible viagetAgent) and anagent/exitdiagnostic is emitted. - AgentDefinition.meta: validated to be an object and shallow-copied + frozen into the resolved definition (no deep validation / deep freeze).
- protocol version negotiation: if the
initializeresponse'sprotocolVersion !== PROTOCOL_VERSION, the process is killed and judgedinitialize-failed(no downgrade negotiation). - storage write-failure retries: none (best-effort side channel, INV-5).
- envelope endpoint (
createHostEndpoint): error mapping — anAcpErroris enveloped as-is; an agent-side JSON-RPC error maps toacpjs/agent-error(original{ code, message, data? }placed indata); unknown methods and missing required parameters are both rejected withacpjs/config-invalid. Exceptions thrown by an inbound handler are isolated and reported as asubscriber/errordiagnostic event, without interrupting dispatch to the other handlers. Permission push-back subscribes to host-levelpermission-updatedprojections, not per-session event streams. Pending requests are forwarded asInboundRequestwithid === requestId; answered/superseded projections clear outstanding entries.respondInboundisrespondPermission, and a second answer is rejected withacpjs/already-answered. - terminal capability boundary: host default terminal support is disabled.
A terminal handler must implement create/output/wait/kill/release plus
cleanupSessionbeforeterminal: trueis declared to the agent. close/delete callcleanupSession(sessionId). The exportedcreateDefaultTerminalHandlercan be injected by applications that want Node child-process terminals. - terminal↔session ownership: the host records which
sessionIdcreated eachterminalId(from thecreateTerminalresponse) and rejects anyterminalOutput/waitForTerminalExit/killTerminal/releaseTerminalthat references a terminal owned by a different session withacpjs/invalid-params. This boundary is enforced before the handler runs, so a customTerminalHandlercannot accidentally leak terminals across the agent's sessions — the same trust-boundary guarantee the host already applies to session↔agent ownership. - SessionMeta persistence: after successful
createSessionorresumeSession,storage.appendMetawrites protocol config metadata (sessionId,agentDefinitionId?,cwd,mcpServers?,additionalDirectories,title?,updatedAt?).restoreSessionsrebuilds disconnected sessions from meta and event logs. Meta records MUST NOT be returned byloadEventsas events and do not participate in event replay. DestructiveloadSessionbuilds a replacement session history in memory and callsstorage.replaceSession(sessionId, meta, events)as a strict commit before publishing the replacement events to live subscribers.host.dispose()waits for queued event and metadata writes.
