@aptivus/connector
The Aptivus agent for Claude Code. Binary: aptivus. Runs on the developer's
machine and is the only Aptivus component that ever sees unredacted text.
npx @aptivus/connector login # authorize this machine in the browser
npx @aptivus/connector install # add the hooks; prints exactly what it wrote
Platform support — read this before installing
Linux is supported and verified end to end.
macOS is supported; verified by DIR on a real machine on 2026-10-23.
Windows is supported through Git for Windows, and is the one platform that has not yet been run by a person: it was built against the documented behaviour of Git Bash and is unit-tested by injection on Linux (D-052). See Windows below for what it needs and how to check it.
Any other platform is declined by aptivus install rather than given hook
entries that would never fire; --force overrides that for whoever wants to
find out. (D-046, D-052.)
Windows
The hook is still the same POSIX sh script; on Windows it runs under the
bash that Git for Windows ships, which Claude Code on Windows requires anyway.
aptivus install finds that bash.exe and registers each hook entry as
"C:/Program Files/Git/bin/bash.exe" "C:/Users/<you>/.aptivus/bin/aptivus-hook" prompt
Double quotes, because cmd.exe cannot read single ones and bash reads both.
Forward slashes, because every party involved — cmd.exe, bash, Node — accepts
them, and a backslash is an escape character inside the script. The paths the
script holds (C:/Users/<you>/.aptivus) name exactly the directories the Node
side reaches as C:\Users\<you>\.aptivus; ~/.aptivus/current and the
per-profile spool work unchanged.
Where install looks for bash.exe, in order — it takes the first that exists:
CLAUDE_CODE_GIT_BASH_PATH, the variable Claude Code itself honourswhere bash.exe, keeping only a Git for Windows bash (C:\Windows\System32\bash.exeis WSL's launcher and is skipped)git --exec-path, walking up to the install'sbin\bash.exe%ProgramFiles%\Git\bin\bash.exe%ProgramFiles(x86)%\Git\bin\bash.exe%LOCALAPPDATA%\Programs\Git\bin\bash.exe(a per-user install)
If none is found, install refuses, says how to install Git for Windows
(winget install --id Git.Git -e --source winget), and writes nothing. If Git
lives somewhere unusual, set CLAUDE_CODE_GIT_BASH_PATH to the full path of
bash.exe and re-run. aptivus status and aptivus doctor print the platform
and the bash the hook runs through; config.json records it as bash_path.
File modes on Windows. NTFS has no 0700/0600. The modes in the table
below are written and ignored; the secrecy of credentials.json and of the
spool rests on the user profile's ACL, which keeps C:\Users\<you> to that
user, administrators and SYSTEM. doctor reports those entries as "user
profile ACL" rather than failing a check that cannot pass.
What the hook needs from Git Bash: tr, cut, date, cat, mv,
mkdir, nohup and an emulated /dev/null — all shipped. Git Bash has no
setsid, so the flusher is started with nohup … &, the script's existing
fallback. WSL, Cygwin and a bare MSYS2 are not used even when present.
Checklist for the first run on a Windows machine (DIR, pending):
- Install Git for Windows (
winget install --id Git.Git -e --source winget), then open a new PowerShell. npx @aptivus/connector@latest install— it must print the"…bash.exe" "…aptivus-hook"entries it wrote, thennpx @aptivus/connector login.- Open Claude Code, send one prompt, run
aptivus status: the spool inbox count goes to 1 and, after the response,aptivus flush(or the detached flusher) sends it — the session appears in the app.
What it installs, and where
aptivus install prints this list; it is reproduced here so you can read it
before running anything.
Everything below profiles/<name>/ belongs to one account. <name> is the
active profile — see Several accounts on one machine.
| Path | What | Mode |
|---|---|---|
~/.aptivus/bin/aptivus-hook | A POSIX sh script. Reads current to find the active profile, appends the hook payload to that profile's spool, exits 0. No Node, no network | 0755 |
~/.aptivus/current | The active profile's name | 0600 |
~/.aptivus/unattributed/ | Events the hook could not attribute to any profile. Never sent on their own | 0700 |
~/.aptivus/profiles/<name>/spool/inbox/ | One small file per hook event, written by the script | 0700 |
~/.aptivus/profiles/<name>/spool/outbox/ | Redacted batches waiting to be sent | 0700 |
~/.aptivus/profiles/<name>/spool/quarantine/ | Batches the server rejected as invalid, and inbox files the flusher could not read. Both are redacted before they land here (an inbox file that cannot be redacted is deleted, never kept in the clear); never retried, kept for inspection | 0700 |
~/.aptivus/profiles/<name>/spool/state.json | Sequence counters, transcript cursors, the account those cursors belong to, and sha256 hashes of prompt text (for joining hook and transcript prompts). No text | 0600 |
~/.aptivus/profiles/<name>/credentials.json | That account's device token | 0600 |
~/.aptivus/profiles/<name>/config.json | URLs, org, capture mode. No secrets | 0600 |
~/.aptivus/profiles/<name>/device.json | That profile's install_id | 0600 |
~/.aptivus/profiles/<name>/logs/connector.log | Counts, rule ids, errors. Never prompt text, tokens or keys | 0600 |
~/.claude/settings.json | Five hook entries, listed below. Everything else in the file is untouched; a timestamped backup is written first. On Windows each entry names Git Bash explicitly (see Windows) | — |
Hook entries added (each runs the same script with a different argument):
| Event | Argument | Timeout | What the script does |
|---|---|---|---|
UserPromptSubmit | prompt | 5s | spool the payload |
SessionStart | start | 10s | spool the payload |
Stop | stop | 10s | spool the payload, start the flusher detached |
SubagentStop | stop | 10s | same as Stop: a subagent finishing is when its transcript is worth reading |
SessionEnd | end | 10s | spool the payload, start the flusher detached |
The script prints nothing (a UserPromptSubmit hook's stdout would be injected
into the model's context) and exits 0 on every path, including a full disk or a
missing spool directory. Measured: ~7ms per invocation, p95 8ms, including the
one extra read that resolves the active profile; the budget is 50ms and the
test suite asserts it.
aptivus uninstall removes exactly those five entries and nothing else.
aptivus install --managed prints the fragment for an org-wide
managed-settings.json instead of writing anything.
Removing the connector tells the organization
aptivus uninstall and aptivus logout both say, before they do anything,
that the organization's owners and admins will learn that capture stopped on
this machine, and when. They ask you to confirm and offer you a reason, which
the organization sees as you typed it. Then one small message goes to the
ingest server — which command ran, the reason, the connector version, the time
— and only after that are the hooks or the token removed.
$ aptivus uninstall
Capture stops on this machine once the hooks are removed.
Acme's owners and admins will be told that it stopped, when, and the reason you give below.
Continue with uninstall? [y/N] y
Reason (optional, shown to the organization): laptop handed back
Acme has been told.
Removed UserPromptSubmit, SessionStart, Stop, SessionEnd entries from ~/.claude/settings.json
--yes skips the question and --reason "<text>" supplies the reason, for
scripts. Without a terminal and without --yes the command refuses rather than
guesses. If the server cannot be reached, the command says so and continues:
this is your machine, and nothing here waits on a server to let you leave. A
personal workspace notifies nobody — its only owner is you.
What the connector will never do: keep running after you removed it, put itself
back, or hide. If you delete the package or the hook entries by hand, nothing
is sent, because nothing is left to send it; the organization then sees the
machine go quiet (three working days without a batch while you keep signing
in to the web app) on its Settings → Agent page, which is the honest version of
the same fact. Organizations that need removal to be impossible get that from
Claude Code's managed settings, not from this package — see
docs/MANAGED-SETTINGS.md.
APTIVUS_HOME overrides ~/.aptivus. CLAUDE_CONFIG_DIR overrides ~/.claude.
APTIVUS_PROFILE overrides the active profile for one command.
Several accounts on one machine
A device belongs to one person: the server refuses to let a second account claim
an install_id that already belongs to someone else, and that guard is not
negotiable. So a second account gets its own profile, with its own device
identity, token, settings and spool (D-039).
aptivus login --profile work # sign in, creating the profile
aptivus profile list # every account on this machine
aptivus profile use personal # switch
aptivus profile # which one is active
Your first aptivus login needs none of this: it creates a profile named after
the organization you signed into.
A profile keeps its account for life, and --profile is how a second one
arrives. Plain aptivus login renews the token for the account the profile
already holds; sign a different account in and it is refused, with the command
to run instead. Picking your other organization at the approve screen counts as
a different account too, because a device row exists per organization.
This is enforced rather than advised because the alternative is invisible.
state.json records how much of each session has already been sent to that
organization. Reuse the directory for another account and a session already in
progress splits: the first organization keeps what it had, the second receives
the rest under the same session id and start time, and nothing marks either half
as partial. The counts agree at every layer because each layer counts its own
half. Measured before the guard existed: 1843 events in one organization and 1
in the other, for one conversation (D-049).
The spool also records the account it belongs to, so the flusher stops rather
than sending from another account's cursors — aptivus status and aptivus doctor both report it, and nothing is read or drained while it holds.
What you type under one profile can only ever be sent to that account's
organization. The spool lives inside the profile, so a flusher running while
personal is active cannot see — let alone send — anything captured under
work. Switching profiles leaves the other profile's queue where it is;
aptivus status tells you it is waiting, and it is sent the next time that
profile is active. Sharing one spool between accounts would have been simpler to
build and is exactly the leak this design exists to prevent.
If the hook runs when no profile can be resolved — during an upgrade, say — the
event goes to ~/.aptivus/unattributed/ rather than being guessed into a
profile. Nothing is lost, and nothing is attributed to the wrong organization.
Those events are adopted automatically when exactly one profile exists, because
then there is no ambiguity; with two or more, aptivus status reports them and
you decide.
Upgrading from a version before profiles needs no action: the first command, or the first flush, moves your existing setup into a profile named after your organization, token and queued spool included.
Each profile is a separate device row in the dashboard, which is honest — they are separate identities. Grouping them under one machine is a later refinement.
What leaves the machine
Only what @aptivus/schema defines, and only after redaction on this machine.
Per session: the working directory, the normalised git remote
(host/owner/repo, credentials stripped) and branch, the Claude Code version.
Per prompt: the redacted text and its original length. Per model response: the
redacted visible text, its length, the model, token usage, the names of tools
called. Per tool call: the redacted, size-capped input and the length of the
result. Never, by default: thinking content or tool results — lengths only.
Never, in metadata mode (aptivus config capture_mode metadata): anything
beyond lengths and counts.
Tool results and thinking are opt-in per organization (connector 0.1.6,
migration 0034). An owner or admin turns each on under Settings →
Organization; the connector learns it the same way it learns image capture
(GET /v1/policy, cached ten minutes, shown by aptivus status) and only
then asks the transcript parser to keep the text. Both go through the same
redaction as prompts on this machine. A tool result over 64 KB or a message's
thinking over 32 KB is sent as its length only, never truncated. Ingest
re-checks the organization's switch on every batch and stores nothing the
organization has not allowed, whatever a connector sends. Metadata mode
overrides both switches.
Agent runs are part of the session (connector 0.1.7, migration 0035,
D-059). When Claude Code spins up a subagent — a fork, an Explore agent — it
writes that agent's conversation to its own file next to the session's
(<session>/subagents/agent-<id>.jsonl). The flusher parses those files too,
from where it left off each time, and every event from one carries an agent
reference: the run's id, the Agent call in the parent that spawned it, its
type and description. What is captured is exactly what is captured for the
main conversation — the brief it was given as its prompt, its replies and
token usage, its tool calls with inputs, and tool results or thinking only
under the same organization switches. An agent's brief is not counted as one
of the developer's prompts; its turns and tool calls are counted as work.
Every event records which redaction rules fired, never what they matched.
How the pipeline runs
hook ──▶ spool/inbox ──▶ flusher: ──▶ spool/outbox ──▶ POST /v1/ingest
· read inbox, assign seq per session
· parse the transcript (version-aware)
· join prompts to responses (prompt id, then text)
· redact
· write batches, then state, then delete inbox
· send with backoff; quarantine 4xx
A hook-started flusher waits up to 60s (APTIVUS_FLUSH_LOCK_WAIT_MS) for a
running one to finish, then runs; after each pass it re-scans the inbox
and runs again while new files exist. So the SessionEnd that lands one
second after the Stop hook's flusher started is never left for the next
session. `aptivus flush` waits 5s and says if another flusher is running.
The flusher is started detached by the stop and end hooks so Claude Code
never waits for it. It takes a lock, so two never run at once. Everything up to
"send" works offline; a developer on a plane loses nothing, and a retry re-sends
the identical bytes under the same client_batch_id so the server collapses it.
aptivus flush runs it by hand. aptivus doctor checks the installation.
Correlation
A prompt arrives twice: from the hook (immediately, with the text) and from the
transcript (later, with Claude Code's promptId). The flusher joins them by
prompt id when the hook provides one, else by text. A response is attributed to
its prompt through the transcript's parent chain; correlation on the event
says how sure we are (exact, inferred, unmatched). A prompt the hook never
saw — the hook was installed mid-session, say — is recovered from the
transcript instead of lost.
Compatibility matrix
The hook contract is documented and stable. The transcript format is internal
to Claude Code and drifts; the parser dispatches on the version recorded in the
transcript and keeps raw lines it cannot parse (parse_failure events) so they
can be reprocessed later. See src/transcript/ and its fixtures.
| Claude Code | Status | Notes |
|---|---|---|
| 2.1.278 | verified | canary fixture in test/fixtures/transcripts/ |
| 2.1.x (other) | expected to work | same parser; add a fixture when verifying |
| < 2.1 | unsupported | no fixture; data is kept as parse_failure events |
A new release follows the runbook in docs/AGENT-ROLES.md §Version compatibility.
Shell coverage. The hook is written for POSIX sh. The suite here runs it
under whatever sh the machine has (bash on this one; dash is not installed).
Before the first external release, CI must run test/hook-script.test.ts under
dash (Debian/Ubuntu /bin/sh), macOS /bin/sh and Git for Windows' bash,
including the hostile payload set. "Reads as POSIX" is not a test. The Windows
pieces that can be tested without Windows — bash discovery, the settings
entry, path spelling — are in test/windows.test.ts.
Auth server contract
aptivus login is OAuth 2.0 Authorization Code + PKCE with a loopback
redirect. The dashboard (apps/web) must implement:
GET <auth_url>/cli/authorize — a page. Query parameters:
response_type=code, client_id=aptivus-connector, code_challenge,
code_challenge_method=S256, redirect_uri (always
http://127.0.0.1:<port>/callback), state, install_id, device_name,
connector_version. The page authenticates the user, lets them pick an org,
records the device, and redirects to redirect_uri?code=…&state=… (or
?error=access_denied&state=…).
POST <auth_url>/api/cli/token — JSON body
{ grant_type: "authorization_code", client_id, code, code_verifier, redirect_uri, install_id }. The server verifies S256(code_verifier) equals
the stored challenge, mints a device-scoped token, stores only its hash
(agent_tokens), and returns:
{
"token": "apt_live_<32 base64url chars>",
"token_hint": "apt_live_3f9a2b…",
"org": { "id": "uuid", "slug": "acme", "name": "Acme" },
"user": { "email": "[email protected]" },
"ingest_url": "https://ingest.aptivus.dev",
"expires_at": null
}
Errors use the IngestError shape from @aptivus/schema so the CLI can show
the request_id. The CLI prints the authorize URL in every case, because SSH
sessions have no browser to open.
Token storage
Phase 1 stores the token in ~/.aptivus/profiles/<name>/credentials.json with mode 0600. The
OS keychain backends sit behind the same CredentialStore interface and are
not implemented yet, for two concrete reasons: macOS security add-generic-password -w
puts the secret on the process argv, visible in ps for the life of the
call, and Linux secret-tool blocks on a keyring-unlock prompt that a
detached background flusher can never answer. Both need a design, not an
afternoon. Tracked as a Phase 1 follow-up.
Develop
npm test -w @aptivus/connector # runs on source via Node's type stripping
npm run build # from the repo root
APTIVUS_HOME=/tmp/x CLAUDE_CONFIG_DIR=/tmp/c node -e 'import("./src/cli.ts").then(m=>m.main(["install"]))'
One runtime dependency: zod (D-017). The wire contract, @aptivus/schema, is
compiled into the published bundle rather than installed alongside it — one
package, one version, nothing for a user to resolve.
Platform support is stated at the top of this file and enforced by
aptivus install, not left to be discovered.