@ademu/adc-client
v0.1.1
Published
TypeScript session client for the ADC daemon's device-session protocol (PROTOCOL.md, Part I — shipped in this tarball) — the mind-side library.
Readme
@ademu/adc-client
The TypeScript session client for the ADC daemon's device-session protocol — the mind-side
library. PROTOCOL.md (Part I; shipped alongside this README in the tarball, adc/PROTOCOL.md
in the repository) is the contract; this library is one consumer of it and cites it, never
substitutes for it. It is conformance-tested against the same golden fixture
that pins the daemon (adc/adc-proto/tests/fixtures/ipc_v1.ndjson), so it structurally cannot
drift.
Zero runtime dependencies (node stdlib only), ESM, strict TypeScript, engines.node >= 20.
Install
npm i @ademu/adc-clientPublished on npm from a private monorepo, so the tarball is self-sufficient: dist/ (ESM +
.d.ts + source maps), src/ (the TypeScript the maps point at), PROTOCOL.md (the contract
this README cites — Part I is the session protocol), README.md, LICENSE-MIT + LICENSE-APACHE.
Releases are cut by tag (clients-v<semver>) and published by CI via npm Trusted Publishing — no
stored or long-lived npm token exists anywhere. Verify a tarball's contents with npm pack @ademu/adc-client && tar tzf
ademu-adc-client-*.tgz. Release procedure for maintainers: docs/RELEASING_NPM.md.
Quickstart
import { connect } from '@ademu/adc-client';
const client = await connect({ token: process.env.ADC_TOKEN! });
console.log(client.hello.device_id, client.hello.last_acked_seq);
for await (const ev of client.events()) {
if (ev.known && ev.event === 'message_received') {
await client.sendText({ group_id: ev.group_id, body: `echo: ${ev.body}` });
client.ackThrough(ev); // ONLY after your handling durably succeeded
} else {
client.ackThrough(ev);
}
}examples/echo.ts is the runnable version (npm run build:examples, then
ADC_TOKEN='adc1_…' node examples-dist/examples/echo.js).
Acks carry read-receipt weight — there is no auto-ack
An ack {seq} cumulatively marks every durable event ≤ seq as handed off, and handed-off
fires read receipts to the humans in the conversation (PROTOCOL.md §The seq/ack contract,
rider R3 — agents have no read-receipt opt-out). That is why this library never acks on its own
and never will: call ack(seq) / ackThrough(event) yourself, after YOUR handling durably
succeeded — not on receipt. The cursor lives daemon-side (hello.last_acked_seq); the client
persists nothing.
Requests, and the ambiguous-outcome discipline
request(op, params?, {timeoutMs}) correlates replies strictly by id (pipelining is legal);
the twelve typed wrappers (sendText … searchMessages) are TRANSPARENT — each only names the
op, types the params/reply, and delegates, enforced by test. request() stays public as the
forward-compatibility escape hatch: the surface is additive-forever and this client is a
snapshot — a newer daemon's ops are reachable via request + a client.capabilities.has(op)
check without waiting for a package update. Wrappers do NOT pre-check capabilities (no hidden
logic).
A rejected command promise does not mean the command failed. A command can become durable
before its reply is written, so DetachedError and RequestTimeoutError are OUTCOME-UNKNOWN:
reconcile via queries (getMessages, getMessageStatus) before retrying side-effecting ops, and
never blind-auto-retry commands. The daemon silently drops malformed/unknown requests (no error
reply — PROTOCOL.md §Commands and queries), so a timeout's likeliest causes are an op this daemon
doesn't serve (check capabilities) or a malformed request shape.
Reconnect semantics
reconnect: 'auto' (the default) survives daemon restarts: on post-seat EOF the client backs off
(full jitter, 200 ms base, 5 s cap), re-resolves the socket path (an explicit socketPath stays
pinned verbatim), re-authenticates, and the daemon replays un-acked durable events into the SAME
events() stream. ECONNREFUSED and ENOENT are both transient (the restart window —
PROTOCOL.md §Reconnect & liveness). Terminal: invalid_token, device_not_ready, and
already_attached (the last after a bounded grace of 3 fixed-delay retries, because a fast
re-attach can race the daemon's EOF processing of your old channel).
Takeover is one-shot. takeover: true applies to the initial connect() only; reconnects
always re-auth with takeover: false — a displaced client must not evict the newer legitimate
holder (and two auto-reconnecting takeover clients would ping-pong forever). The wedged-holder
recipe (RUNBOOK §Token lifecycle): start a NEW client with takeover: true; the wedged holder
sees plain EOF.
No heartbeat exists and none should be added — liveness is "read until EOF". The client reads eagerly and buffers events in memory without bound: the daemon auto-detaches a mind whose socket stays blocked for 5 s, so pacing socket reads by consumer pull would be strictly worse; pace your HANDLING (and thus your acks), not the reads.
Socket path resolution
The daemon's hardened ladder, ported exactly (PROTOCOL.md §Resolving the session socket path):
$ADC_SESSION_SOCKET_PATH → gated $XDG_RUNTIME_DIR (never on macOS) → $ADC_DATA_DIR verbatim
→ the XDG data chain. Never probes candidates; joins verbatim. Deliberately omitted: the two
config-file rungs ([daemon] session_socket_path and [daemon] data_dir) — this library is
zero-dependency and parses no TOML. If your daemon's socket moved via config, pass socketPath
or set ADC_SESSION_SOCKET_PATH; a reachable daemon's daemon_info.session_socket_path is
always the authority (adc doctor prints it).
@ademu/adc-client/internal — for sibling packages only
The ./internal subpath export carries the NDJSON framing layer (LineDecoder, encodeFrame,
the two byte caps) for sibling @ademu packages — today @ademu/adc-control. It is internal
and semver-exempt: anything there may change or vanish in any release without a major bump.
Applications import from the root export only. The framing errors (LineTooLongError,
ProtocolViolationError) stay on the root export so instanceof identity is shared everywhere.
Error taxonomy
| Error | When | Terminal for auto-reconnect? |
| --- | --- | --- |
| InvalidTokenError | reject invalid_token (deliberately undifferentiated — no oracle) | yes |
| DeviceNotReadyError | reject device_not_ready: the device is not enrolled. Tokens can exist for a not-yet-enrolled device — if enrollment is mid-flight, retry with a fresh connect() after adc agent add completes. | yes (fail-fast by design) |
| AlreadyAttachedError | reject already_attached with takeover:false | initial: immediately; reconnect: after the bounded grace |
| HandshakeClosedError | EOF before the hello, no reject line (timeout/oversize/internal — ambiguous BY DESIGN) | transient |
| HandshakeTimeoutError | no hello within 10 s. Attachment outcome UNKNOWN: the daemon may still process the queued attach late; a fresh connect may briefly see already_attached. | transient |
| DetachedError | the session ended (reason: 'eof' | 'closed' | 'reconnecting'); carries {op, id} for in-flight requests — outcome unknown | n/a |
| RequestError | an ok:false reply; branch on .code. The daemon's debug text is a deliberate read via non-enumerable .detail — it never reaches err.message, JSON.stringify, or util.inspect | n/a |
| RequestTimeoutError | no reply within the deadline (default 30 s) — outcome unknown | n/a |
| LineTooLongError | outbound: refused pre-write (the daemon reads ≤ 1 MiB/line); inbound: over the 64 MiB library safety guard | inbound: yes |
| ProtocolViolationError | the peer sent what a correct daemon never sends (malformed JSON, illegal root, invalid UTF-8) | yes — it may not be the daemon at all |
Testing
npm test = strict compile + compile-time type fixtures (test/types/) + example type-check +
node:test suites, including the golden-fixture conformance suite (test/fixture.test.mjs) —
every mind-facing fixture line, decode and reproduce classes, unknown-tolerance legs. CI:
.github/workflows/js.yml (node 20 + current LTS). The manual live-daemon lane is
integration/live-daemon.sh (deliberately outside test/).
