No description
  • Rust 81.1%
  • TypeScript 14%
  • CSS 1.3%
  • Shell 1.2%
  • PowerShell 1.2%
  • Other 1.2%
Find a file
2026-09-04 21:55:15 +00:00
.claude Give the Mac pill its icons and a pulse, and the console one surface 2026-09-04 11:07:57 +03:00
apps/admin here-telemetry: the Windows capture client, end to end 2026-09-04 06:47:03 +03:00
collectors Send a frame across the pipe as a PNG, not as six mebibytes of base64 2026-09-04 23:19:19 +02:00
contracts Sign the session's start, on the row and on the log, with the same signature 2026-09-04 23:45:45 +02:00
crates Sign the session's start, on the row and on the log, with the same signature 2026-09-04 23:45:45 +02:00
docs Sign the session's start, on the row and on the log, with the same signature 2026-09-04 23:45:45 +02:00
extension here-telemetry: the Windows capture client, end to end 2026-09-04 06:47:03 +03:00
packages Open the window, and say "waiting for consent" before "held" 2026-09-04 23:55:14 +02:00
scripts Declare the Node floor the pairing tests already had 2026-09-04 19:12:24 +02:00
spikes Make the Windows half build and lint after the macOS merge 2026-09-04 14:19:30 +03:00
tests/e2e Answer every record, count what was not stored, and let a second writer wait 2026-09-04 23:08:36 +02:00
.gitignore A macOS app somebody can double-click 2026-09-04 11:16:03 +03:00
.npmrc Declare the Node floor the pairing tests already had 2026-09-04 19:12:24 +02:00
Cargo.lock Give the device a key, and sign consent with it 2026-09-04 23:31:26 +02:00
Cargo.toml here-telemetry: the Windows capture client, end to end 2026-09-04 06:47:03 +03:00
HANDOFF.md Record that the Windows build shipped, and as which tag 2026-09-04 14:33:54 +03:00
package.json Declare the Node floor the pairing tests already had 2026-09-04 19:12:24 +02:00
pnpm-lock.yaml here-telemetry: the Windows capture client, end to end 2026-09-04 06:47:03 +03:00
pnpm-workspace.yaml here-telemetry: the Windows capture client, end to end 2026-09-04 06:47:03 +03:00
README.md Sign the session's start, on the row and on the log, with the same signature 2026-09-04 23:45:45 +02:00
rust-toolchain.toml Pin the toolchain, and pass the lint the next stable was going to fail 2026-09-04 19:12:12 +02:00
tsconfig.base.json here-telemetry: the Windows capture client, end to end 2026-09-04 06:47:03 +03:00
vercel.ts here-telemetry: the Windows capture client, end to end 2026-09-04 06:47:03 +03:00

here-telemetry

Work-session telemetry that runs on people's own machines, gates every byte locally before anything can reach the network, and makes what it captures visible to the person being captured.

Start here

Hooking this up to the platform?docs/PLATFORM-INTEGRATION.md is the handoff, and it is the only document you need to implement the server side. It carries the seven ports you write, the invariants you must not break with the test that defends each, the five endpoints and the idempotency rules that are easy to get wrong, a migration checklist, and what is deliberately missing. The same document is published as a page for reading: Ports and Invariants.

Porting the client to another OS?docs/PORTING.md, capability by capability across Windows, macOS, X11 and each Wayland compositor, with what changes about the promises.

Picking the work back up?HANDOFF.md: where it stands, what is next, and what is blocked on what.

Want the shape of it rather than the detail? → the wiki, whose source is docs/wiki/: how a byte becomes a record, what each signal refuses to do, the decisions not to relitigate, the bug classes found by running it, and the environment traps. It explains; this file promises. Where the two disagree, this file wins.

Why it is built this way

The invariants

Everything else is detail. These are the claims the code is built to keep, each with the test that defends it:

Claim Where it is enforced Test
No consent record, nothing stored here-store::append_event refuses events_are_refused_without_consent
A collector cannot leak, because it has no network per-exe outbound Block rule, applied by the supervisor before it starts a collector here-supervisor::firewall
A collector cannot emit what nobody declared admission joins the generated registry to the grants table an_undeclared_event_type_is_rejected_over_the_wire
A denied app is absent, not masked per-HWND capture; content fields stripped on suppression a_denied_app_produces_no_pixels_at_all
Suppression is a record, not a hole gate_state on every row, content fields marked x-content in the schemas a_denied_app_is_stored_as_timing_only
Secrets never reach disk L2 runs before the write, not before the upload corpus: 0% leak, 0% over-redaction
No verified indicator, no content capture the gate holds and opens a gap content_suppresses_when_the_indicator_reports_itself_covered
A sense that reaches past the person who pressed Start does not turn on every manifest declares subjects; a room signal is refused at grant time, held at the gate and told room_clear: false, until a machine can say the room agreed a_room_signal_is_refused_and_an_operator_signal_is_not
A signal the machine cannot run says so, and its switch does not pretend refusals are written to signal_state at startup; the console shows cannot run here with the machine's sentence, and SetGrant answers with it instead of recording a grant a_refused_signal_reports_itself_refused_and_cannot_be_switched_on
Consent is an act, not a boot step a session starts with no consent and stores nothing until the person confirms in the console; a console consent under the same policy carries forward; a changed policy needs a fresh act consent_is_an_act_and_nothing_is_stored_before_it
A session's start cannot be back-dated the session-start record is signed by the device key and the session row carries the same signature; the server checks it like consent the_session_start_on_the_log_is_signed_and_the_row_carries_the_same_signature
Consent cannot be back-dated signed by a device key derived from the store's key; the public key is in every batch header and the server refuses a consent record that does not verify a_signed_consent_verifies_and_a_tampered_one_does_not
A full store refuses pixels and keeps the timing with a disk budget set, the gate measures the disk before each frame and suppresses under L1.disk_budget rather than writing until the disk fills a_store_at_its_disk_budget_refuses_pixels_and_keeps_the_timing
Every line the uploader builds is a valid envelope the schema admits signal_id, which the uploader always sent; a test parses each built line with the generated envelope type, which refuses unknown fields every_line_the_uploader_builds_is_a_valid_envelope
A frame crosses the pipe as a PNG, not as base64 the collector encodes with the gate's settings and sends the pixels as a sidecar frame the record sizes; the gate decodes, then redacts a_full_size_window_of_text_crosses_in_well_under_a_mebibyte
A switch that cannot deliver does not turn on a desktop that cannot verify the indicator refuses every content signal at grant time, the same set the hold would suppress; window activity is reduced to timing and told so an_unverifiable_indicator_refuses_every_content_signal_and_reduces_focus
A collector's count is what the gate stored every record is answered stored, rejected or dropped; only stored is emitted, the rest is events_dropped on the heartbeat and collector_dropped in the store a_record_the_gate_stored_is_delivered_and_a_rejected_one_is_not
A second writer waits instead of failing busy_timeout=5000 on every connection a_second_writer_waits_for_the_first_instead_of_failing
A session can be replayed from its log alone the gate writes session start and end, consent, grant changes, pauses, scope changes and indicator gaps and states as events on the same log as the data; collectors cannot write those types a_session_can_be_replayed_from_its_log_alone
A nap is a record, not a hole every record carries the collector's boot-clock tick_ms; the gate compares that clock with the process clock and writes a clock.sleep decision when they drift; indicator freshness uses the boot clock an_hour_on_the_boot_clock_against_a_second_awake_is_an_hour_asleep_reported_once
A record is dated by the collector, and the gate's clock is kept beside it ts_utc is the collector's at at microsecond resolution; received_at is the gate's write; the envelope carries both as time and gate_time a_record_is_dated_by_the_collector_and_the_gates_clock_is_kept_beside_it
A still screen is a record, not a hole the frames collector counts the frames it drops as unchanged and reports the count as here.frame.still.v1, timing only a_minute_of_static_screen_is_one_frame_and_one_still_record
A day's counts outlive its records the sweep writes a day digest before expiring anything; retention keeps it, erasure drops it expiry_keeps_a_day_digest_and_erasure_drops_it
Delete means delete, including derived data tombstones propagate to frames, blobs, digests and the upload queue deletion_propagates_to_frames_blobs_and_digests
A record leaves the device on a timer, and the server on request only the gate sweeps hourly by the registry's local window, only for records the server confirmed, through the deletion path, sending nothing to the server local_retention_expires_uploaded_records_and_keeps_the_rest
Delete reaches the server copy too a deletion for anything already uploaded is queued ahead of new traffic and retried until confirmed; the receipt says which deleting_an_uploaded_record_queues_a_deletion_for_the_server
A deletion the server would not honour is never reported as done refusal is a distinct state from confirmed, and survives the queue marking it sent a_partly_refused_deletion_is_recorded_as_refused
A replayed upload cannot duplicate batch id as idempotency key, both sides is_idempotent: a replayed batch stores nothing twice
A device may only touch its own records ownership checked against the credential, never the request body refuses a device deleting another device's records
An unidentified caller can do nothing every endpoint fails closed before it reads a body refuses an unauthenticated request before reading the body
Looking leaves a mark reads are logged where they happen, and a read that cannot be logged is refused lets a failed write through so the caller can refuse the read
A new app is never captured silently the first capture from an app nobody has been told about always raises a card, at every rung of the verbose ladder a_newly_seen_app_always_gets_a_card
There is no keystroke buffer typed text is read from the focused field at a boundary, never accumulated from key events fields::a_document_reports_its_length_and_withholds_its_text
What you copied is never stored the clipboard record carries a keyed fingerprint, and the fingerprint is content, so a denied app's copies are not linkable a_clipboard_record_from_a_denied_app_keeps_only_its_timing
Content is encrypted on disk payloads and blobs are AES-256-GCM under a DPAPI-wrapped key; the index is not, and the docs say so a_sealed_payload_does_not_contain_the_plaintext
An allow rule matches what the collectors actually send rules are written as file names and collectors report full paths; the comparison handles both a_rule_written_as_a_file_name_matches_the_full_path_a_collector_reports
A frame the pipe would refuse is never built the collector fits each capture to the stored width and a pixel budget before sending every_plausible_window_shape_fits_the_pipe_after_fitting
Off means off one press closes the session, and every process is gone within seconds measured: 9 processes to 0 in ~3s
Stopping is recorded, not a gap the session's end reason names the control that was used stopped_by_hotkey in the session row
A frozen indicator stops content the hold rule asks whether the last report is recent, not just what it said an_indicator_that_stops_reporting_stops_being_verified
A signal that cannot work here is refused, not switched on to produce nothing the gate asks here-os at startup and marks the grant refused with the reason a_mac_with_nothing_granted_refuses_exactly_what_the_permissions_gate
A signal that works here with less in it says so Capabilities::limitations, separate from the refusals, naming the setting to change a_signal_that_still_works_with_less_in_it_is_reduced_and_not_refused
A refusal tells you about your machine the reason for each refusal is true on every platform that can hit it, and names the remedy for each a_mac_is_told_where_to_go_and_not_about_someone_elses_desktop
The clipboard's hash-only rule is one rule, not one per platform the policy lives in here-os above the OS call, so a port cannot ship a weaker version of it a_copied_password_appears_nowhere_in_the_record

Layout

contracts/           source of truth: schemas, registry, enums, manifest schema
crates/
  here-contract      GENERATED types, registry and signal catalogue (do not edit)
  here-store         SQLite + content-addressed blobs; the local source of truth
  here-gate          the pipeline: admission, L0 scope, L1 suppression, hold, L2, L3
                     + here-gated, the gate process
  here-ipc           framed named-pipe IPC between the four process roles
  here-win           Windows capture primitives: focus, input counts, secure fields,
                     per-HWND capture, indicator visibility
  here-indicator     the pill, the captured-window outline, tray state, UIA provider
  here-uploader      batching + backoff + idempotency; the only credential holder
  here-supervisor    starts the roles, enforces per-collector network denial
  here-console       the desktop app: Tauri v2 host over the gate's control channel
  xtask              codegen and contracts-lint
collectors/          one independent process per signal, each with its own manifest
                     (here-collector-browser is launched by the browser, not the supervisor)
extension/           the browser extension: reports the host name of the site in front
packages/            @here/contract, @here/platform-adapter, @here/ui,
                     @here/console-web (the console's React front-end)
apps/admin           Next.js: /v1/batches, /v1/blobs/:hash, /api/stream, live view
spikes/              P0 probes, kept because they double as per-machine acceptance checks
tests/e2e            the real gate process over the real pipes
docs/ADRs            decisions that overturned something, with the measurement

Build and test

One command, from anywhere. It finds the repo from its own path, runs every stage, and prints a pass/fail line per stage plus the test count:

.\scripts\verify.ps1

Windows PowerShell 5.1 has no &&, which is why this is a script rather than a chained one-liner. Nothing in it captures anything or touches the network — the tests use temporary stores and an in-process transport.

On a Mac or a Linux box, where there is no PowerShell and no Win32:

./scripts/verify.sh

It runs the same stages against everything that is not Windows — the contract, the store, the gate, the uploader, the OS seam and the whole TypeScript half — and prints what it skipped and why. The two are not interchangeable. verify.sh runs 389 tests — 303 Rust and 86 TypeScript — and verify.ps1 runs those plus the Windows half; a change that touches a collector, the indicator or the supervisor is unverified until it has been run on Windows. The script says so in its own summary rather than leaving a green line to imply otherwise, and it fails if the list of packages it skips has drifted from the packages that exist.

Individual stages, if you want them separately:

cargo run -p xtask -- contracts-lint
cargo run -p xtask -- codegen-check
cargo test --workspace
pnpm install
pnpm -C packages/platform-adapter exec vitest run
pnpm -C apps/admin exec tsc --noEmit -p tsconfig.json

Regenerate after touching contracts/ or any manifestcodegen-check fails if the generated files are stale:

cargo run -p xtask -- codegen

Releases

Built artifacts live on the repository's releases page. v0.1.0 carries the macOS app as a universal build — one download that runs on Apple Silicon and on Intel under Rosetta; the Windows build is attached to the same tag from a Windows machine. The macOS bundle is signed ad hoc, which makes it verifiable but is not an identity Gatekeeper accepts, and modules.signature_ok records false rather than pretending on either platform.

A macOS build you can double-click

./scripts/bundle-macos.sh              # this machine's architecture
./scripts/bundle-macos.sh --universal  # Apple Silicon and Intel in one bundle

Produces dist/here capture.app — all twelve binaries in Contents/MacOS together, which is not decoration: the console starts the supervisor from its own directory and the supervisor starts the collectors from that same directory, so one folder is the layout the code already expects. This is not an installer; it is that same folder, wrapped so it can be double-clicked and so macOS has something stable to attach permissions to.

--universal compiles everything twice and lipos each binary, so one download runs on both architectures rather than making somebody work out which file to pick. It is what releases carry; a local check does not need it.

The signature is ad hoccodesign -s -, an identity with nothing behind it. It is worth being exact about what that does:

Verifiable yes: codesign --verify --deep --strict walks all twelve and names anything that changed after the build
Named on purpose each binary carries com.hereandmore.capture.<role> rather than the identifier codesign invents from the filename and a file hash
Accepted by Gatekeeper no: TeamIdentifier=not set and no notarisation, so a downloaded copy is still refused
Keeps a permission grant across a rebuild believed not — TCC keys on the code hash and a rebuild changes it. Stated as belief: checking it means clicking through System Settings twice and that has not been done

A Developer ID is what changes the last two, and it is on the blocked list.

So Gatekeeper refuses to open it after a download. Either right-click it and choose Open, or:

xattr -d com.apple.quarantine "/Applications/here capture.app"

The app asks for its own permissions, and that is the point of bundling it. A binary run from a terminal inherits whatever the terminal was granted, which is both too much and the wrong thing; a bundle has its own identity, so Screen Recording, Accessibility and Input Monitoring are granted to here capture and to nothing else. Until they are, the Capture screen lists the three signals that are refused and the setting each one needs.

Run it

The console is the way in: it starts and stops capture itself, so this is the only command a contributor needs.

pnpm -C packages/console-web build
cargo build --release
.\target\release\here-console.exe

The front-end build comes first because here-console compiles the UI into its own binary. A console that loaded its interface from a writable folder would be a way to put arbitrary code inside the process that talks to the gate.

Then press Start capture on the Capture tab. It launches the supervisor, which applies the firewall rules and starts the gate, the pill and the collectors. The button waits until the gate actually answers rather than reporting success because a process spawned — allow up to a minute for a first start, since each collector's outbound-block rule is a separate Windows firewall call (measured at ~35s here).

To start and stop the whole tree by hand, without any windows:

.\scripts\start.ps1 -Suffix live -Ingest http://localhost:3000 -Console
.\scripts\stop.ps1

Start-Process is the wrong tool for this and worth knowing why: it gives every console application its own terminal, so launching six roles that way puts six black windows on screen and looks nothing like the product. start.ps1 spawns with CREATE_NO_WINDOW and drains each role's output into logs\<role>.log, which is what the app itself does. It does not apply the firewall rules — only the supervisor does that — so use the supervisor or the console's Start button for a run with the network-denial invariant in force.

Every background binary is a windows-subsystem executable in release builds (#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]), so a release build has no console to show however it is launched, including a double-click. Debug builds keep theirs, because that is where development output goes. Check it:

# 2 = GUI (no console window), 3 = console
$exe = ".\target\release\here-gated.exe"
$b = [IO.File]::ReadAllBytes($exe)
$pe = [BitConverter]::ToInt32($b, 0x3C)
[BitConverter]::ToUInt16($b, $pe + 92)

To start the tree without the console:

.\target\release\here-supervisor.exe

The supervisor applies the outbound-block rules first and prints whether they took. Run it elevated: without administrator rights the firewall rules cannot be created, and it will say so and refuse to start the collectors rather than capture under a guarantee that is not true.

To see the indicator without capturing anything, start only the gate and the pill. With no collector running there is nothing to capture, so this is safe to leave up while you look at it:

$env:HERE_DATA_DIR = "$env:TEMP\here-demo"
Start-Process .\target\release\here-gated.exe
Start-Process .\target\release\here-indicator.exe
# bottom-right of the primary display. Ctrl+Shift+Alt+I expands, Ctrl+Shift+Alt+H shrinks.
Get-Process here-gated, here-indicator | Stop-Process   # when you are done

Environment:

Variable Effect
HERE_DATA_DIR Store location. Defaults to %LOCALAPPDATA%\here\store.
HERE_GRANTS Development and test override: consent from the environment, recorded as such. Unset, a session starts from the consent the person last gave in the console under this policy, or from nothing — no consent row, nothing stored — until they act.
HERE_INGEST_URL Where the uploader ships. Unset means dry-run — batches are built and counted, never sent.
HERE_UPLOAD_ONCE One upload pass, then exit.
HERE_CHANNEL_SUFFIX Suffixes the pipe names, so two instances can coexist.
HERE_FINGERPRINT_SALT HMAC salt for secret fingerprints. Replace at install.

Websites

A browser is one window to Windows, so without help every site inside it is the same app and a rule like "never capture my bank" cannot be enforced. That help is a browser extension in extension/, and a native messaging host, here-collector-browser, that the browser starts.

# 1. chrome://extensions -> Developer mode -> Load unpacked -> pick .\extension
# 2. copy the extension ID Chrome shows, then:
.\scripts\install-extension.ps1 -ExtensionId <that-id>
# to undo:
.\scripts\install-extension.ps1 -Uninstall

Native messaging rather than a local port, because collectors have no network path and a socket would have meant opening one. The extension has no content scripts and no host permissions, so it cannot read a page or see what you type — a limit Chrome enforces rather than one we promise. It reports the host name only, never the address, and nothing at all from a private window; the host program refuses anything that arrives looking like a URL, so an extension bug cannot put a path in the database.

Deleting

Deleting locally is the easy half. The copy that matters is the one that was uploaded, so a deletion covering anything with an uploaded_at produces a request on the same durable queue as the uploads — priority -1, so it ships before new records — and the uploader retries it until the server confirms. Until then the Account tab shows it as waiting, not done.

node scripts\delete-records.mjs <suffix> <event-id>...   # specific records
node scripts\delete-records.mjs <suffix> --everything    # all of it, every session
node scripts\delete-records.mjs <suffix> --receipts      # what went, and whether the server confirmed

Four states, and the difference between them is the point:

State Means
local only none of it had been uploaded, so there was nothing on a server to remove
waiting the server has been asked and has not confirmed — retried until it does
gone from the server the server confirmed the records are gone
still on the server the server refused part of it, and those copies still exist

A record the server never had counts as success: that is the state the person asked for, and anything else leaves the deletion pending forever. A record belonging to another device is refused and named, because a silent skip is indistinguishable from success.

Pairing and the access log

Both are ports, not implementations, because this moves to a platform that already has accounts, sessions and a database. Everything it has to supply is an interface:

File It provides
auth.ts Authenticator — turn your credentials into a Principal
pairing.ts PairingStore + TokenMinter — how a device gets a credential
access-log.ts AccessLog — who looked at whose records
ingest.ts IngestSink — where accepted records go
deletion.ts DeletionSink — how records are removed
live.ts LiveBus — the live stream

The rules stay in the adapter; the storage, identity and transport are the platform's. apps/admin/src/identity.ts is the development implementation of the first three, and it is the file that gets deleted when the real one lands.

Pairing. The web app issues a short code against an account; the person types it into the console; the uploader redeems it, because the console and the gate have no network path and giving either one would undo the reason they do not. A code is single use, expires in ten minutes, and a client token never carries the administer scope — so a leaked token cannot reach another device's records. The account password never touches the machine being recorded.

# development: mint a code the way the web app would
curl -s -X POST http://localhost:3000/v1/pairing/codes -H "content-type: application/json" -d '{"account":"acme","issued_by":"you@example.com"}'
node scripts\pair.mjs <suffix> HERE-XXXX-XXXX   # hand it to the uploader
node scripts\pair.mjs <suffix>                  # state, and the access log

The access log. With nothing expiring, "who has been looking" replaces "when will this be gone" as the question that matters, so the subject can fetch the answer themselves: a device reads its own log with its own token, the subject is taken from the credential and never from the query, and the client mirrors it locally so a person keeps their own copy of the audit trail rather than asking the people it is about.

Reads are logged where they happen — entriesForRead takes the records as an argument, so you cannot call it without having done the read — and a read that cannot be logged is refused. An unlogged read is worse than a blocked one, because the promise is that looking leaves a mark.

Encryption at rest

The records are encrypted on disk with AES-256-GCM. The key is 32 random bytes, generated on first open and wrapped with DPAPI to one Windows user on one machine, so a copy of the folder decrypts to nothing anywhere else.

Being exact about this matters more than the cipher, because a vague claim about encryption is worse than none — it invites people to relax about the cases it does not cover.

What is encrypted

events.payload_json the content of every record: commands, typed text, window titles, clipboard fingerprints, frame references
blob files the frame pixels, which are the most sensitive bytes on the disk

What is not

The index: event ids, timestamps, signal ids, app ids, sequence numbers, gate verdicts. Those are what the store is queried by — deletion by app, counts by verdict, the upload queue's ordering — and encrypting them would mean either decrypting the whole table to answer any question or keeping a plaintext shadow copy of the same facts, which protects nothing.

So somebody holding the files can learn that this person used zen.exe at 14:31 and that the gate suppressed it. They cannot learn what was on the screen or what was typed.

What it defends against: a stolen or resold laptop without disk encryption, a backup or sync client that took the profile folder, another account on a shared machine, a support engineer browsing %TEMP%.

What it does not: code running as that person. DPAPI unwraps for the logged-in user, so anything running as them can ask for the same plaintext the gate can. That is true of every at-rest scheme that does not prompt for a passphrase, and prompting is not on the table for something that has to survive an unattended reboot.

Two smaller decisions worth knowing:

  • A deletion tombstone stays in the clear. {} is a public constant meaning "the content is gone". Sealing it would make a deleted record indistinguishable from a live one from outside the process, which removes the one check somebody can make on the file without trusting us.
  • Records written before this existed are re-encrypted on the next open, and the log says how many. A promise that only held for records made after an upgrade is not a promise anybody can act on.

Terminal commands

Observed as process creation, not by reading shell history and not by injecting a hook into anybody's profile. When a shell starts a program, its command line, working directory and exit code are all readable for a process running as the same user — which is what Process Explorer does, and it needs no elevation.

The manifest already ruled out the alternative: "It never reads shell history files directly as a workaround." A history file holds everything the person has ever typed, including from before they installed this and from sessions where the signal was off, so reading it would mean the grant controlled nothing.

The honest limitation: only commands that start a program are visible. git push is recorded; cd .., ls in PowerShell, a for loop and a profile function are not, because none of them spawn a process. The signal's absence claim says so in the console.

A command is reported when it exits, so the record carries the exit code and the duration. One still running after sixty seconds is reported without them — otherwise a dev server started at nine would not appear until it was killed at six, and the timeline would say nothing happened all day.

Each record is attributed to the shell, not to whatever is in the foreground when the command finishes. A long build would otherwise be filed against the browser somebody read while waiting for it, and the scope rules would be evaluated against the wrong app.

Typed text

There is no keystroke buffer anywhere in this design. Accumulating key events is a keylogger: a process holding a growing buffer of characters, which can leak, can be dumped from memory, and sees passwords before anything has a chance to classify them.

So the collector asks the field instead. UI Automation exposes a control's current value, so it samples the focused field and reports at a boundary. Two things count as a boundary:

  • Focus leaves the field. The value at that moment is what was committed.
  • The field empties while still focused — a message being sent, a form being submitted. Without this rule the most common typed text on a work machine, everything said in Slack, would be invisible.

Four refusals, in the order they apply:

  1. No grant, no sampling. The grant is re-checked every five seconds and the field watch is dropped the moment it goes away, so a switched-off signal is not reading text and discarding it — it is not reading it.
  2. A password field is never sampled, and a field that will not say whether it is one is treated as one.
  3. A document is counted, not copied. Over two thousand characters and the record carries the length alone: "4,200 characters into Visual Studio Code" is true and useful, and copying somebody's whole file into a telemetry record is not what the signal promised.
  4. The gate redacts what is left, and chars is the length before redaction — so a record where the text and the count disagree is a record where something was removed, which is visible rather than silent.

The clipboard

A clipboard is where a password lives for thirty seconds, so this signal is built around never carrying its content. What it stores is the format, the length, and a keyed fingerprint.

The key is what makes it safe and useful at the same time:

  • a paste chain across three apps shows up as the same fingerprint three times, which is what makes the sequence learnable; and
  • nobody holding the uploaded records can test a guess against them. An unkeyed SHA-256 of hunter2 is a lookup table away from hunter2; an HMAC under a key that never leaves the machine is not.

The key is per machine, DPAPI-wrapped to the Windows user, and kept beside the store rather than inside it — this collector has no handle on the store and must not acquire one.

The fingerprint is content, so a copy made in a denied app is stored as timing only. That matters: the fingerprint is exactly what would let somebody link "this was copied in an app you denied" to "the same thing was pasted in an app you allowed", across the boundary the person drew.

The schema allows a text field with an explicit clipboard-content grant. No such grant exists in this build, and the collector has no code path that fills it in.

Turning it off

Three ways, and all of them stop everything:

The pill TURN OFF on the bar. It asks once — the button changes to SURE? for four seconds — because the pill sits over your work all day and a stray click would cost you the record of what you were doing.
A keyboard shortcut Ctrl+Shift+Alt+X, once. A three-modifier chord is already deliberate, and its armed state would be invisible to anyone whose pill happens to be covered, so this one does not ask.
The console Stop capture on the Capture tab.

Whichever you use, the same thing happens: the gate closes the session with a reason, replies, and exits; the supervisor sees that clean exit and takes everything else down. Measured on this machine, every process is gone about three seconds after the press.

here-gated: stopping at the request of hotkey; session sess-… closed
here.collector.focus: no gate on here.gate.ingest.v1 after 4s; exiting rather than lingering as an orphan
here-uploaderd: capture has stopped; exiting with the queue intact

Three details are deliberate:

  • It is a message, not a kill. Each surface could taskkill the process list, and the console still does as a backstop if the gate is wedged. But then stopping would be an unexplained gap in the history, indistinguishable from a crash. Asking the gate means the session record says stopped_by_hotkey — a person turning this off is doing something they are entitled to do, and the record should say so.
  • The collectors exit on their own too, four seconds after losing a gate they had. They used to retry forever, which left capture processes running with nothing supervising them; that is precisely what somebody is entitled to be suspicious about. A longer window applies at startup, where a missing gate means "not up yet" rather than "gone".
  • Nothing is deleted. Records stay, and anything queued for upload stays queued and goes on the next run. "Off" is not "wipe" — that is what the Account tab is for.

There is also .\scripts\stop.ps1, which kills the process list by name. It is the development fallback for a stack started by start.ps1, which runs the roles directly with no supervisor.

The tray icon

Right-click it for the three things people go to a tray for, and double-click to open the app:

  Open here capture
  Pause for 15 minutes        (or Resume, when it is paused)
  ─────────────────────────
  Turn capture off

It owns its own hidden window rather than borrowing the pill's. The pill is WS_EX_NOACTIVATE so it can never take focus from your work, and a popup menu whose owner cannot take focus does not dismiss when you click away.

Open raises the existing window if the console is already running rather than starting a second copy. Two copies cannot both have WebView2: the second comes up blank and raises its own "the interface did not load" dialog, so an Open item that started one would be a button whose job is to break the app.

Shortcuts

Every chord is Ctrl+Shift+Alt plus a letter, and the pill's expanded form lists them:

O open the app
M move the pill to the next corner
I change size
H hide or show
P pause for 15 minutes
X turn capture off

The list and the bindings used to live in two files, and drifted: the pill offered X stop while X called pause. every_shortcut_on_the_list_is_one_the_process_binds reads the line the pill actually draws and fails if it names a key nothing registers, or misses one that is bound.

Capture cards

The pill narrates. When a record is stored it can raise a card for a few seconds — which signal, which app, what the gate did to it, and why you are being told:

● Screenshots · zen.exe                        3 in a row
  kept, 2 areas painted out
  first capture from this app        [quiet this] [seen]

The last line is the part that matters. An indicator that narrates without saying why trains people to dismiss it; one that says "first capture from this app" is saying something they can check, and one that says "showing each capture while this is new to you" is promising to stop.

It does stop. The ladder in crates/here-gate/src/verbose.rs climbs on cards shown, never on elapsed time — what earns silence is having been seen, not having waited out a timer while the laptop was shut:

Rung After Shows
0 from install a card for each capture
1 10 cards the first time each app is recorded, and whenever nothing is stored
2 30 cards one card when the signal starts recording
3 60 cards no cards; the pill's light and counter only

The count is in indicator_prefs and survives a restart. A ladder that reset on launch would narrate the first ten captures of every working day, which is the behaviour it exists to avoid.

Two things refuse to be quieted at any rung: a person who pinned the signal, and the first capture from an app this machine has never seen. The second is the surprise the whole subsystem exists to prevent, so it sits ahead of the stage arithmetic rather than inside it, and it is also the only card that is spoken aloud through the UI Automation live region — narrating every capture would make the pill unusable with a screen reader, which is the same failure as hiding it.

One thing overrules even those: quiet on the signal. That is the person saying "stop telling me about this one", and honouring it is the difference between a control and a suggestion. It costs them a card, never a record: the capture still lands in the counter, the hover flyout and the Activity tab.

The card carries quiet this, so the thing interrupting you holds its own off switch. The inverse is in the console on each signal row — "Show every capture", "Let it quiet down", "No cards" — because a control that can only be pressed one way is a trap.

Cards do not stack. One shows at a time for 3.6 seconds, at most four wait behind it, and identical captures fold into a count rather than a queue. The band they use is the same one the hover flyout uses, and the flyout wins: a card arriving over the top of a panel somebody opened would replace what they asked to see with what we decided to say.

Checking it without a screenshot. The pill sets WDA_EXCLUDEFROMCAPTURE on Windows and sharingType = .none on a Mac, so it is a hole in every screen capture — including ours. Two things make a card verifiable anyway: the indicator logs each one, and the window changes size by exactly the card's height while one is up.

# what the pill said, after the fact
Select-String 'card ' $env:TEMP\here-manual\logs\here-indicator.log
grep 'card ' ~/Library/Application\ Support/here/store/logs/indicator.log

On a Mac the card is its own window rather than a band inside the pill, so the third way to check it is the window list: a second window owned by here-indicator, one level below the pill, appears while a card is up. cargo test -p here-indicator write_the_card_sheet writes the three verdicts to PNGs, which is the only way to actually look at one.

here-indicator: card focus · SearchHost.exe - timing only, nothing from the window (first time this app has been recorded)
here-indicator: card input.metrics · zen.exe - kept (first capture from this app)

Looking at it

The console is a native window, so "the tests pass" says nothing about whether anything is on screen. These two scripts walk it:

.\scripts\shot.ps1  -Process here-console -Out home.png
.\scripts\click.ps1 -Process here-console -X 85 -Y 160
.\scripts\shot.ps1  -Process here-console -Out activity.png -NoFront

The console also writes console.log next to the store, including a line for each start and one for the moment the interface actually rendered. If ten seconds pass without that line, the app says so in a dialog: a window that opens blank cannot report its own failure through its UI.

The pill deliberately does not appear in these captures. It sets WDA_EXCLUDEFROMCAPTURE, which removes it from every capture API including screenshots — that is the S1 invariant working. Check the pill through its window rect and its own log.

Verifying the claims yourself

The network-denial claim is the one worth not taking on trust. The supervisor prints the exact command; it is:

Get-NetFirewallRule -DisplayName 'here-telemetry deny egress: here-collector-focus' | Get-NetFirewallApplicationFilter

The per-machine capture checks, which exit non-zero on failure:

foreach ($s in @('s1-wda-capture','s2-visibility','s3-outline','s4-tick-latency','s6-hotkeys','s5-uia-live')) {
  cargo run -q --release -p $s
  if ($LASTEXITCODE -ne 0) { Write-Host "$s FAILED" -ForegroundColor Red }
}

The cross-stack check, which compares the gate's own counts with the server's:

node scripts\xstack-check.mjs xstack

Other platforms

docs/PORTING.md is the audit. The short version is that one guarantee does not survive the move, and it changes what the product can promise rather than how it is built:

Wayland is not one thing, and the difference between two compositors is larger than the difference between Wayland and X11. Capturing a single window works everywhere through xdg-desktop-portal, and since ScreenCast v4 the grant survives a restart instead of prompting each session. What varies is the rest:

wlroots (Sway, Hyprland) KWin Mutter (GNOME)
Indicator provably visible (layer-shell) yes yes no
Which window is in front (foreign-toplevel) yes yes no
Clipboard without focus (data-control) yes yes no

On a compositor with layer-shell the indicator guarantee is stronger than the one shipping on Windows today: a surface on the overlay layer cannot be covered by construction, so there is nothing to poll and nothing to race. On GNOME none of the three exist, so window activity, screenshots, typed text and the clipboard are refused there — with the reason — rather than granted and left to produce nothing.

macOS has a different problem, and it is a permission rather than a protocol. The APIs are all there — ScreenCaptureKit filters to one window, NSWindow.sharingType excludes our overlay, occlusion is computable from the window list — but three of them are behind a switch a person grants per-application and can revoke at any time: Screen Recording, Accessibility and Input Monitoring. So on a Mac the capability report is not a table of what the platform supports, it is a preflight of what this machine has actually agreed to, taken at startup and turned into refusals with the name of the setting to change. here_os::from_macos is that mapping and it is tested against every combination of the three.

So the port starts with crates/here-os, which states what the client needs from a machine and what each platform can actually do:

pub struct Capabilities {
    pub window_identity: bool,
    pub window_titles: bool,
    pub indicator_visibility_verifiable: bool,
    pub screen_capture: bool,
    pub single_window_capture: bool,
    pub self_exclusion_from_capture: bool,
    pub accessible_fields: bool,
    pub global_input_counts: bool,
    pub clipboard_readable_unfocused: bool,
    pub command_lines_readable: bool,
    pub per_process_network_denial: bool,
    pub hardware_bound_key: bool,
}

The gate asks it at startup and refuses grants it cannot honour, with the reason attached:

here-gated: every signal is supported on this machine

On GNOME Wayland the same line would name window activity, screenshots, typed text and the clipboard, and those signals would not be granted however they were requested. That is the difference between a limitation somebody reads about and one the software enforces.

A refusal is not the only way a machine can fall short, and the second way is the more dangerous one. A reduction is a signal that runs and delivers less than the README promises: on a Mac without Screen Recording, window activity still records which application somebody was in and for how long, and cannot record which document, because kCGWindowName comes back absent. Nothing about that looks wrong — a focus record with no title reads exactly like a record for a window that has no title — so Capabilities::limitations says it out loud, alongside the setting to change, and the console's summary line carries it:

here-gated: not available here: frames, input.metrics, text.commit; reduced here: focus

Two claims weaken off Windows and the honest place to say so is here: network denial is an enforced firewall rule only on Windows — elsewhere it rests on a reviewed binary with no network code — and the store's key is hardware-bound on Windows and macOS but protected by file permissions alone on Linux.

The client runs on a Mac. Four processes — the gate, the indicator and two collectors — with records flowing over Unix domain sockets and the hold rule enforced. What is in:

Transport Unix domain socket in a 0700 directory, the same kernel-enforced access control as the pipe's ACL
Where things live ~/Library/Application Support/here/store, sockets beside it
Which window is in front NSWorkspace.frontmostApplication for the app, CGWindowListCopyWindowInfo for the window
Clipboard NSPasteboard.changeCount, hash-only policy shared with every platform
Indicator an NSWindow at status level, sharingType = .none, drawn from a bitmap
Indicator visibility CGWindowListCopyWindowInfo with OnScreenAboveWindow, no permission needed
Capability preflight Screen Recording, Accessibility and Input Monitoring, asked rather than assumed
Input counts CGEventTap, listen-only, counting keys and never characters
Typed text AXUIElement, sampled at a boundary, never a keystroke buffer
Password-field probe AXSecureTextField as role or subrole, failing closed
Screenshots CGWindowListCreateImage, one window, with the password fields located first
Terminal commands proc_listpids on a timer, KERN_PROCARGS2 for the command line
Websites the same native-messaging host as Windows; only the registration differs
Supervisor all nine roles, and it says out loud that network denial is not enforced here
Console the Tauri app, which now describes the platform it is on rather than Windows
Controls a menu bar item and four global hotkeys via Carbon RegisterEventHotKey, because the pill is click-through and a Mac user looks in the menu bar for a background process
Capture cards their own NSWindow under the pill, with the queue and the 3.6-second timing shared with Windows, and spoken through NSAccessibilityAnnouncementRequested
Captured-window outline an NSWindow traced around the window frames are being taken from, four modes told apart by dash pattern as well as colour
Hover flyout a panel beside the pill showing one signal's last six records, opened by observing the pointer with NSEvent.mouseLocation rather than capturing it

The one deliberate difference is that the Mac pill never takes a click. That is what stops it swallowing a click meant for the window underneath, and it is free to do: the visibility check is Z-order coverage, not hit-testing, on both platforms (ADR 0005), so a click-through pill is not a cheat. The pointer is polled instead of captured — no event tap, no permission, no interception. Two things follow. Hover-in waits for the next frame rather than arriving on the event, which is why the loop drops to the frame clock while the pointer is over the pill. And the card's two controls (seen, quiet this) live in the menu bar, where a Mac user looks for a background process anyway, rather than as buttons on the card.

Linux has nothing.

Things that only came out of running it, and are the reason this work does not get done by cross-compiling:

  • NSPasteboard is not thread-safe and does not fail politely. Two threads reading the general pasteboard at once trap inside -[NSPasteboard _updateTypeCacheIfNeeded] and take the process down — EXC_BREAKPOINT, no panic, no unwind, nothing in a log. Every call into it now goes through one mutex, and many_threads_may_read_the_pasteboard_at_once is the test that fails without it.
  • An application on a Mac is a bundle, not an executable. Three unrelated applications ship an executable called Electron, so the name a scope rule matches on comes from the outermost .app in the path — which also makes Google Chrome Helper match a rule written against Google Chrome.
  • A permission check cost 22 ms. CGPreflightScreenCaptureAccess and IOHIDCheckAccess each cross into another process. The first foreground watcher called them through a constructor that looked free, which would have burned nine percent of a core polling four times a second. Cached for five seconds: 120 µs, and a revoked permission is still noticed while the person is walking back from System Settings.
  • The Keychain cannot hold this client's keys yet. An item's access list is per-binary and this client is four processes; a second unsigned binary reading an item the first created raises a modal dialog and blocks. Measured — the reader hung until it was killed. So hardware_bound_key is false on a Mac, the key is a file only its owner can read, and code signing is what changes that.
  • An invisible window can sit in front of the real one. Claude's own process keeps a 1440×32 window at layer 0, alpha 0, parked entirely off the top of the screen — and the window server lists it ahead of the real one. Taking the first layer-0 window meant window activity named a window with no title and the frames collector captured a blank strip, which the gate rejected as a black frame every two seconds. Found by capturing and looking at the result.
  • A password box's role is AXTextField. Only its subrole says AXSecureTextField. Checking the role alone is the obvious implementation and would have read the password straight out of a real dialog — caught by running it against one.
  • A cached accessibility handle goes blind. AXUIElementCreateSystemWide kept in a struct at process start reported "nothing has focus" for the rest of that process's life, while a process launched a second later read the very same focused field. The collector would have sampled nothing all day and reported that nobody was typing. It is created per query now.
  • Two bodies of one collector can disagree about its own name. The gate looks a collector's signal up by module id, so here.collector.text.commit where the manifest says here.collector.text-commit is a collector whose grant is never found: it connects, it heartbeats, and the console shows the signal as off while the process runs happily. Both macOS collectors had it. There is a test in each now that reads the other body's source.
  • A tap the system can switch off is worse than one that fails. macOS disables an event tap whose callback is too slow, and a disabled tap returns zero for ever — which looks exactly like somebody who stopped typing. The callback re-enables itself, and the collector asks is_listening() rather than trusting a quiet zero.
  • pgrep cannot see its own ancestors. is_running used pgrep -x, which returns nothing for a process that is an ancestor of the pgrep — so the console would have reported every collector as not running and told the person capture had failed while it was working. ps finds the same process by the same name in the same instant. Matching the executable's file name rather than the command line matters for a second reason: pkill -f here-collector-focus would kill an editor with that file open, and this is the Stop button.
  • Drawing a circle for every state quietly halved the encoding. paused and disabled share a colour and are told apart by shape. Caught by looking at the rendered PNG, which is what write_the_pill_sheet exists for — the pill excludes itself from capture, so a screenshot of the running indicator does not contain it.

What is not built

Named so it is not mistaken for done:

  • No code signing. The client is unsigned, so SmartScreen and EDR will flag it. modules.signature_ok records false rather than pretending otherwise. EV cert procurement has lead time and has not started.
  • No installer. No WiX package, no service registration, no auto-start.
  • The browser extension is unpacked and unsigned. It is not in any store, so it has to be loaded through Developer mode and registered with .\scripts\install-extension.ps1. The console shows those steps when Websites is switched on and nothing is reporting.
  • No operator identity. Devices authenticate; people do not. /v1/pairing/codes stands in for a web app that would have a signed-in person behind it, and the live view logs its reads against a placeholder actor. The administer scope exists and is enforced, and nothing issues it yet.
  • The development server keeps everything in memory. Restarting it un-pairs every device and loses the audit trail, which for an audit trail is disqualifying. That is a property of the test backend, not the design — IngestSink, PairingStore and AccessLog are where the real thing goes.
  • Nothing expires, by decision. Records are kept until deleted: they verify paid work and they train a computer-use model, and both need them long term. There is no retention timer to build, and the manifests, the registry and the console all say so (-1, "until you delete it") rather than quoting a number nobody enforces. contracts-lint fails if those two places disagree. Deletion is therefore the whole control, which is why it reaches the server copy and leaves a receipt.
  • The admin store is in-memory. apps/admin/src/store.ts says so at the top. Postgres and Redis go behind the same two interfaces.
  • The index is not encrypted, only the content. Timestamps, app ids and gate verdicts sit in the clear so the store can be queried by them; see "Encryption at rest" for what that does and does not protect. Nothing at rest protects against code running as the person, either.
  • The shell signal cannot see built-ins. cd, ls in PowerShell and profile functions start no process, so nothing observes them. The console says so rather than implying full coverage.
  • All of Linux, and one difference on macOS. Every signal has a macOS body and each has been exercised against a real desktop; the pill, the menu bar, the capture cards, the captured-window outline and the hover flyout are all in. The Mac pill takes no clicks by design, so the card's two controls are in the menu bar rather than on the card — see "The client runs on a Mac". On Linux nothing is written. here-win's nine modules have also not moved behind the here-os traits yet, which is Windows-only work. See docs/PORTING.md.
  • Deployment. The admin app builds; it has not been deployed.