Research report: a daemon head for Heinzelmännchen¶
- Date: 2026-10-23
- Scope: research only. Decision record: ADR 0020 (proposed). Nothing is built before 0.1.0 is released.
- Read first:
INTENT.md,docs/ARCHITECTURE.md, ADRs 0003, 0004, 0010, 0015, 0018, 0019,src/heinzel/kernel/_lets.py,_module.py,_installation.py,src/heinzel/heads/(_exposure.py,cli/_serve.py,mcp).
TL;DR¶
- Let the OS supervise and the daemon tick.
hz system daemon installwrites a launchd LaunchAgent on macOS or a systemd--userservice plus timer on Linux. Every few minutes it runshz system daemon tick: a short-lived process that boots the kernel, finds what is due or changed, runs it under a lease, writes receipts and exits. launchd'sStartCalendarIntervaland systemd'sPersistent=truecatch up after sleep, so catching up is the OS's job. A fresh process per tick also surviveshz system update. That command replaces the uv tool environment, and a resident Python process would be left running code that is no longer on disk.hz system serve daemonis the same loop in the foreground. A resident mode (KeepAlive) for file watching comes later. - GitHub is the shared state, the lock service and the approval inbox. A team state repository holds receipts
(one file per receipt, so concurrent writers never conflict), cursors and policies. Leases are Git refs whose
commits form a chain, so a rejected push is the compare-and-swap. Approvals are issues and pull requests, in the
style of Renovate's Dependency Dashboard. Team-scoped jobs can run as a scheduled GitHub Actions workflow with a
concurrencygroup instead of on laptops, and both runners write to the same ledger. - New lets:
WatchLetandJobLet. AContractLet(ADR 0010) compiles to them, so the daemon head knows only two new kinds. Jobs declare schedule, triggers, scope, idempotency key, timeout, retries, concurrency and the action types they publish. Jobs only draft. Publishing is a separate, approved step, and every step leaves a hash-chained receipt with lineage, as Reactor does. - Untrusted input is data until a human registers it. Workflows embedded in a Google Doc or Sheet tab ("🧝 Heinzel") run only after an owner registers them and pins a fingerprint. Plugins run only from an allowlist, one subprocess per job with limits. OS sandboxing (Seatbelt, bubblewrap) comes in a later phase.
1. Process model¶
What comparable tools do¶
| Tool | macOS | Linux | Install / uninstall | Logs | Notes |
|---|---|---|---|---|---|
| Homebrew services | LaunchAgent in ~/Library/LaunchAgents, or a LaunchDaemon with sudo |
unit in ~/.config/systemd/user, or system-wide with sudo |
brew services start|stop|restart|kill|run|list|info|cleanup |
Whatever the formula declares | start = run now and register at login; run = run without registering; kill = stop but stay registered. The vocabulary is worth borrowing. brew manpage |
| Syncthing | User LaunchAgent plist in ~/Library/LaunchAgents |
user unit for desktops (starts at login), system template syncthing@.service for servers; loginctl enable-linger to start user units at boot |
Documented manual steps | journalctl --user-unit=syncthing.service |
The usual split: user units on desktops, system units on servers. docs |
| Ollama | Menu-bar app registered as a login item | System unit /etc/systemd/system/ollama.service, dedicated ollama user, restart always |
Updated by rerunning the install script; the unit stays | journalctl -e -u ollama |
System scope because it serves a local API. linux docs |
| Tailscale | GUI variants (standalone app, App Store Network Extension); open-source tailscaled installs itself with sudo tailscaled install-system-daemon → /Library/LaunchDaemons/com.tailscale.tailscaled.plist |
system tailscaled unit |
install-system-daemon / uninstall-system-daemon: the binary installs and removes its own unit |
journald | The precedent for hz system daemon install|uninstall. wiki |
| Docker Desktop | App setting "start when you sign in" (login item) plus a privileged helper LaunchDaemon | Desktop app or a user service | Managed by the app | App diagnostics | A GUI app, not a template for a CLI. |
| gh-dash | none | none | none | none | A TUI that refreshes only while open; it has no background mode. |
| pipx / uv tools | none | none | none | none | They install executables and leave services to the tool. uv puts environments in ~/.local/share/uv/tools (UV_TOOL_DIR) and executables in ~/.local/bin (UV_TOOL_BIN_DIR/XDG_BIN_HOME); uv tool dir --bin prints it. storage, tools |
Facts that decide it¶
- Sleep.
StartCalendarIntervaljobs whose time passes during sleep run on wake, and missed intervals are "coalesced into one event".StartIntervalfirings that fall during sleep are lost.KeepAliverestarts are throttled (10 s by default,ThrottleInterval).ProcessType=Background,LowPriorityIO,NiceandSoftResourceLimitskeep a job from disturbing the user.WatchPathscan miss modifications (launchd.plist(5)). On systemd,OnCalendar=timers catch up after suspend and coalesce,Persistent=truecatches up after downtime,RandomizedDelaySec=spreads load, andWakeSystem=is available only to the system manager (systemd.timer). - Modern launchctl verbs. Use
bootstrap gui/$UID <plist>,bootout,enable/disable(persistent),kickstart -k(restart a running service) andprint. The output ofprintis not a stable API (launchctl(1)). - macOS 13+ background items. A new LaunchAgent triggers "Background Items Added" and appears under System
Settings → General → Login Items → Allow in the Background, where users can switch it off
(Eclectic Light).
SMAppServiceneeds an app bundle, so a uv-installed CLI uses a plain plist. The item may show up under the name of the executable or interpreter.doctormust detect "loaded but disabled in Login Items". hz system updatereplaces the environment (uv tool upgrade/uv tool install --forcein_installation.py). A long-lived process that imports lazily (heads already do, ADR 0019) would load new code into an old process or miss deleted files. The executable directory holds symlinks that uv recreates on every install, so the shim path stays stable.- Monotonic clocks. On macOS,
mach_absolute_timedoes not advance during sleep (PEP 418). A residentasyncio.sleeploop must recheck wall-clock deadlines after wake. The OS schedulers already do.
Options¶
| Option | Upgrade-safe | Sleep / catch-up | Crash restart | Latency | Effort |
|---|---|---|---|---|---|
| A. Self-daemonizing process (double fork, pidfile) | no | must be built | must be built | low | high; nothing starts it at login |
B. Resident hz system serve daemon under KeepAlive / Restart=always |
needs a restart hook | the process must handle wall-clock time | OS | seconds; can watch files | medium |
C. OS timer → short-lived hz system daemon tick |
yes, by construction | OS | not needed | one tick (default 5 min) | low |
| D. cron | yes | macOS cron skips runs during sleep | n/a | 1 min | low; no unit, logs or limits |
Recommendation¶
Option C as the default. Option B as an opt-in --resident mode later, for file watching and a local
notification or inbox server. Both run the same tick().
- macOS:
~/Library/LaunchAgents/<label>.plist,ProgramArguments=[<uv tool bin>/hz, system, daemon, tick], aStartCalendarIntervalarray (every 5 min),ProcessType=Background,LowPriorityIO,Nice=10, stdout and stderr into the log directory (crash output only), minimalEnvironmentVariables. Load it withlaunchctl bootstrap gui/$UID. - Linux:
~/.config/systemd/user/hz-daemon.service(Type=oneshot,ExecStart=<bin>/hz system daemon tick,Nice=10,TimeoutStartSec=) plushz-daemon.timer(OnCalendar=*:0/5,Persistent=true,RandomizedDelaySec=60), enabled withsystemctl --user enable --now hz-daemon.timer.--lingeris optional. - Resolve
uv tool dir --binat install time and write the absolute shim path, never the environment's ownpython. Outside a uv tool environment, refuse unless--executableis given.
| Command | Does | read_only |
|---|---|---|
hz system daemon install [--interval 5m] [--resident] [--linger] [--dry-run] |
Writes the unit or plist, loads and enables it, runs one tick. Idempotent. --dry-run prints the unit. |
False |
hz system daemon uninstall [--keep-state] |
bootout / disable --now, then removes the files |
False |
hz system daemon status |
Installed, loaded, enabled in Login Items, last and next tick, last receipt per job, leases, paused; mirrored as a HealthLet |
True |
hz system daemon logs [--follow] [--lines N] [--job X] |
Tails the JSON-lines log; on Linux, journalctl --user -u as a fallback |
True |
hz system daemon tick [--dry-run] [--job X] [--scope team] |
One reconciliation pass | False |
hz system daemon pause / resume [--team] |
Kill switch | False |
hz system daemon jobs |
Jobs and watches with schedule, triggers, scope, owner, last disposition | True |
hz system serve daemon |
Foreground loop calling tick() (development, containers, CI) |
– |
Surviving hz system update. In tick mode nothing is needed: the next tick runs the new code through the same shim.
update should still take the local tick lock (so it never replaces the environment under a running tick), restart
resident mode (launchctl kickstart -k / systemctl --user restart), and verify with hz system daemon status --json
in a fresh interpreter, as it already does with hz --version. doctor flags a stale shim path.
2. Triggers¶
| Source | Push | Why push fails on a laptop | Pull (recommended) |
|---|---|---|---|
| Clock | – | – | Cron string on the job, evaluated per tick against the ledger's last run, not local memory |
| GitHub | Webhooks / GitHub App (Probot uses a smee.io relay in development: probot) | Needs a public HTTPS endpoint; laptops sit behind NAT and sleep | Conditional GET with ETag: "a 304 doesn't count against your primary rate limit"; honour x-poll-interval; make requests serially; follow retry-after, else wait ≥ 1 min and back off exponentially (best practices, rate limits) |
| GitHub Actions | push/issues/pull_request workflows run hz system daemon tick --job X; repository_dispatch lets anything wake a workflow (events) |
n/a: runs on GitHub | Event latency without a server; laptops read the result from the ledger |
| Google Drive | changes.watch/files.watch need HTTPS with a CA-signed certificate; channels expire (files ≤ 1 day, changes ≤ 1 week, default 1 h) and need manual renewal (push) |
No public endpoint | getStartPageToken once, then changes.list(pageToken); save newStartPageToken after the last page (changes) |
| Jira / Confluence | Webhooks: HTTPS with a CA-signed certificate; REST-registered ones expire after 30 days (5 per OAuth app per user); at-least-once delivery (webhooks) | No public endpoint | JQL updated >= cursor ordered by updated; cost-based limits (rate limiting) |
| Feeds | WebSub on some feeds | Same | Conditional GET (ETag/Last-Modified) with feedparser (docs); items fingerprinted by guid |
| Local files | FSEvents/inotify (watchfiles) |
Needs a resident process | stat and hash at each tick |
Push or pull. Pull wins for a laptop daemon. Push needs a public endpoint (a tunnel or relay adds infrastructure and attack surface), channel renewal, signature checks, and delivery to a machine that is asleep. Pull with conditional requests costs almost nothing when idle. Where latency matters, push lands in GitHub Actions. Reactor ships the same: "local cron-poll and HTTP, plus gateway poll connectors with a durable idempotency cursor", with provider subscriptions and webhook auth "not yet shipped" (pattern spec).
Backoff and offline handling. Keep backoff state per source in user_cache_dir. With no network, a tick logs
"offline" once and exits 0. Honour retry-after and 429s; otherwise exponential backoff with jitter, capped at the job
period. Only the leaseholder of a source polls it, so a fleet of laptops does not multiply traffic or renders.
Catching up missed schedules. The OS coalesces missed ticks. Each job then computes its missed slots from its last
receipt in the shared ledger, so a run done by another daemon or by Actions counts. The catch_up policy is
latest (default, one run), all (one run per slot) or skip. This mirrors APScheduler's coalesce and
misfire_grace_time (docs);
croniter suffices.
Fingerprints decide; clocks only wake (Reactor). The memo key is
(contract fingerprint, input facet fingerprints[, freshness epoch]). Volatile fields (timestamps, request IDs,
ordering, headers) are stripped before hashing. An unchanged key gives a cheap skipped disposition. The schedule
only advances the freshness epoch (Reactor's valid_until in ### Continuity).
3. Team coordination through GitHub¶
Prior art
- Renovate: per-repository runs on a schedule. All durable state lives on the platform (branches, PRs, a
Dependency Dashboard issue whose checkboxes are the approval UI,
dependencyDashboardApproval) (dashboard, how it works). The closest model. - Dependabot: hosted, configured in the repository, output as PRs.
- Mergify: a hosted GitHub App driven by webhooks; needs a server.
- Probot apps: webhook apps with an always-on public endpoint; right for a later org service, wrong for laptops.
- git-bug: issues as operations in Git refs that merge like a CRDT (repo). It shows that an append-only log in Git merges cleanly.
State repository ([state] repo = "org/hz-state"):
control.toml # paused, disabled jobs, cost ceilings (changed by PR → owned)
policies/auto-approve.toml # owner, action type, scope, expiry (CODEOWNERS)
workflows/registry.toml # registered artefact-embedded workflows: source, owner, pinned fingerprint
receipts/2026/10/<receipt-id>.json # one file per receipt; never edited
cursors/<source>/<key>.json # ETags, page tokens: written by the source's leaseholder only
fleet/<person>@<host>.json # heartbeat: hz version, plugins, last tick
refs/heads/hz/lease/<job-or-source> # leases, not files on main
- Receipts never conflict. One new file per receipt, one commit per tick. A push rejected as non-fast-forward
means fetch, rebase and push again; the rebase cannot conflict
(git-push: branch pushes must fast-forward).
skippedreceipts stay local by default (open question). - Leases as a chain of commits on a ref per job or source. Acquire, renew and release are each a child commit
of the lease commit just read. The commit records holder,
expires_atand the fencing token; a release commit has holdernull, and the ref is never deleted. Every update is therefore a fast-forward, and if someone else moved the ref first, the ordinary non-fast-forward rejection is the compare-and-swap. No force push is needed.--force-with-lease=<ref>:<sha>(marked experimental in the git docs) is kept only for repairing a broken chain. - Alternatives: a lease file updated through the Contents API (the update requires the replaced blob
sha, which is optimistic concurrency, but it shares the branch with receipts and GitHub warns that parallel calls conflict: contents API), and issue labels or assignees (readable, not atomic; fine for human claims of approvals). - Expiry and skew.
ttl = job timeout + 2 × tick interval. Renew at half the TTL during long work. Take over only whennow > expires_at + 60 s. Receipts record the lease commit sha as a fencing token, and every external write carries an idempotency key, so a slow former holder is harmless. This is the Kubernetes Lease pattern (leases) with Git as the store. - Hot standby (ADR 0010) follows: every daemon ticks, the leaseholder works, and an expired lease is taken over at the next tick.
When the team daemon should be GitHub Actions. Every job declares a scope:
personal: needs one person's credentials or machine. It runs only on that person's daemon.team: needs only team credentials. It runs in Actions when the team configures it: a scheduled workflow runninghz system daemon tick --scope teamwithconcurrency: {group: hz-team, cancel-in-progress: false}(one running and one pending per group, concurrency). Otherwise it is leased among laptops.
Prefer Actions for team jobs: always on, no NAT, no fleet to coordinate. Its limits: an interval of at least 5 minutes; delays "during periods of high loads"; queued jobs that "may be dropped"; peaks at the top of the hour; public repositories disabled after 60 days without activity (schedule). Ledger-based catch-up and lease takeover make a dropped run harmless. Ship the workflow as a template.
4. Extension point¶
@module.watch(every="5m", scope="team")
def releases(github: GitHub, cursor: Cursor) -> Facets:
"""New releases of the repositories the team follows."""
@module.job(
schedule="0 9 * * MON",
on=["releases"],
scope="team",
publishes={"newsletter.send"},
timeout="15m",
retries=Retry(max=3),
concurrency=1,
catch_up="latest",
idempotency=lambda inputs: inputs.week,
)
def weekly_digest(feeds: Feeds, releases: Facets) -> Proposal:
"""Draft this week's learning newsletter."""
| Let | Fields | Notes |
|---|---|---|
WatchLet |
name, provider (injected), period, scope, source id, canonicalizer | Returns facets; the kernel fingerprints them (sha256 of canonical JSON); cursor injected and persisted by the head |
JobLet |
name, func, summary, schedule, triggers, scope, publishes, timeout, retries, concurrency, catch_up, idempotency, owner; later sandbox profile | func is injected like an operation and never prints (ADR 0003); returns a Proposal or a result model |
ContractLet (later) |
a *.prose.md-style file: Goal, Requires, Maintains, Continuity, Schedule |
Compiled at boot to a JobLet whose func runs an agent session; artefact-embedded workflows use the same path |
| Publish | an ordinary OperationLet with read_only=False |
CLI, MCP and REST get it free; the job's publishes names its action type |
Consumption. heinzel.heads.daemon does what _exposure.exposed() does: kernel.lets(JobLet) and
kernel.lets(WatchLet), with kernel.available() filtering out disabled modules, and invocation through kernel
injection. It must not import modules ("heads only know what the kernel discovered"). Shared state is reached by type:
the kernel defines Ledger, Leases and Inbox protocols, the state module provides them, and local fallbacks
in user_data_dir apply when it is disabled.
Tick:
- Check the kill switch. Take the local tick lock.
- Fetch the state repository (shallow, sparse).
- Poll the watches this daemon holds leases for.
- For each due or triggered job in scope, compute the memo key. If unchanged, the disposition is
skipped. Otherwise take the lease and run the job in a subprocess with a timeout; the disposition isrenderedorfailed, and proposals go to the inbox. - Publish approved proposals with their idempotency keys.
- Push receipts, release leases, write the heartbeat.
Retries and idempotency. Within a tick, retry only transient errors. Across ticks, a failed receipt with
retry_at counts toward retries.max; after that the job is parked and the owner notified. A failed render commits
nothing (Reactor). The idempotency key defaults to the memo key; for publishing it is
hash(job, proposal, target), checked at the target before writing
(Stripe's semantics).
5. Safety and observability¶
- The approval queue lives in GitHub. Each proposal is an issue in the state repository, or a PR when the draft
is a diffable file (newsletter, skill, ADR). It carries the draft, the sources, the receipt id and checkboxes
(approve, reject, approve and always auto-approve this for me). Labels are
hz:awaiting-approval,hz:approvedandhz:published.hz inbox list|show|approve|rejectare ordinary operations, so the inbox is also a CLI command, an MCP tool and a REST endpoint. - Auto-approval is a policy entry (
owner,action_type,scope,until) added by PR under CODEOWNERS. Receipts name the policy and its owner. - Receipts are modelled on Reactor (content-addressed, chain-verifiable, dispositions, wake cause, fingerprints, cost) and extended with approval, publishing, runner and time. Reactor omits time and actor; we need both for tenet 4, outside the memo key. Field list (the real receipt is plain JSON):
id,prev: content hash and the previous receipt of the same job (the chain)job,contract,inputs(facet → fingerprint),memo_keywake: schedule | input | external | manualdisposition: rendered | skipped | failed | awaiting_approval | approved | rejected | publishedrunner: who, host hash, hz and plugin versions, laptop | actionslease(fencing token),idempotency_keydraft.ref,approval{by, via: issue | policy:id, ref},published{target, ref},sourcescost{tokens, seconds},as_of,error
Git history is a second chain; signed commits come later. Reactor calls its chain "tamper-evident, not
tamper-proof". Receipts map onto W3C PROV (entities, activities, agents),
so hz receipts verify|show|lineage follows.
- Logs are JSON lines in platformdirs.user_log_dir("heinzel") (~/Library/Logs/heinzel,
~/.local/state/heinzel/log) (platformdirs), rotated with
RotatingFileHandler (logging.handlers), because launchd
never rotates StandardOutPath. Lines carry tick_id, job and receipt. journald also captures crash output on
Linux.
- Secrets go through keyring (macOS Keychain, Secret Service, KWallet). Caveats
(keyring):
- On macOS, any script run by the same Python executable can read them.
- On Linux, Secret Service needs D-Bus: present for a login user unit, maybe absent under linger. The fallback is
keyrings.alt or an environment variable, with a doctor warning.
- Never put tokens in the unit file. In Actions, use repository secrets.
- Resource limits: a per-job subprocess timeout; per-day cost ceilings in control.toml. launchd provides
ProcessType=Background, LowPriorityIO, Nice and SoftResourceLimits. systemd provides Nice=, MemoryMax=,
CPUQuota= and TasksMax=, provided the controllers are delegated to the user manager
(resource-control).
Optionally skip heavy jobs on battery.
- Kill switch: a local flag (pause), team-wide control.toml paused = true (pause --team), per-job
disabled. All three are checked at tick start and again before every publish. uninstall is the hard stop.
- Dry run: tick --dry-run (evaluate only: no leases, no renders), --no-publish (drafts to the local inbox
only), install --dry-run (print the unit).
6. Security¶
- Least privilege.
- Personal daemons: fine-grained personal access tokens scoped to the state repository plus read-only access to the
repositories jobs read (fine-grained PATs).
gh auth tokenusually carries the broad classicreposcope; allow it with a warning. - Team runner: a GitHub App with installation tokens that expire after one hour and are scoped to repositories and permissions (installation tokens).
- Google:
drive.file,drive.readonly,documents.readonly. Restricted scopes require Google's verification for a public client (restricted scopes), so each org likely brings its own OAuth client. - Workflows embedded in artefacts are a prompt-injection surface. A "🧝 Heinzel" tab runs only after it is
registered by PR in
workflows/registry.tomlwith an owner and a pinned fingerprint of the tab text. Discovery only proposes a registration. When the text changes, the workflow pauses with a diff for approval, similar to GitHub requiring approval before running workflows from first-time contributors. Discovery uses Drivechanges.list, thendocuments.get(includeTabsContent=true), walking nestedchildTabs(tabs), or sheet titles for Sheets. Google-native files lackmd5Checksum, so hash the extracted text and use the fileversionas a pre-check. Source content goes to renders as data, with read-only tools. Writes happen only through the approval and publish path. - Plugins run in-process with full user privileges (ADR 0004), and nothing makes them safe in-process. Limits:
- a module allowlist for unattended runs;
- one subprocess per job with timeout and rlimits;
- later, per-job OS sandbox profiles (Seatbelt, bubblewrap, and a proxy with a domain allowlist), as Claude Code
does with its open-source
sandbox-runtime(sandboxing, bubblewrap), with the let declaringnetwork=[…]andwrites=[…].
Agent CLIs used for renders run non-interactively, with read-only tool profiles.
- Supply chain: the daemon never runs hz system update or plugin updates itself. It may propose them for approval.
Recommended architecture¶
flowchart TB
subgraph Laptop["Each person's machine (macOS / Linux)"]
OS["launchd LaunchAgent / systemd --user timer<br/>every 5 min, catch-up on wake"]
Tick["hz system daemon tick<br/>(fresh process, via uv tool shim)"]
subgraph Kernel["Kernel"]
Lets["JobLet · WatchLet · ContractLet→JobLet<br/>OperationLet (publish, inbox)"]
Protocols["Ledger · Leases · Inbox (protocols)<br/>provided by the state module"]
end
Sub["job subprocess<br/>timeout · rlimits · (sandbox)"]
Agent["agent CLI session<br/>read-only tools"]
Local["local: keyring · logs · cache · skipped receipts · pause flag"]
OS --> Tick --> Kernel
Tick --> Sub --> Agent
Tick --- Local
end
subgraph GitHub["GitHub (team state)"]
Repo["state repo: receipts/ · cursors/ · policies/ · registry · control.toml"]
Leases["refs/heads/hz/lease/* (fast-forward chain = CAS)"]
Inbox["issues / PRs = approval queue"]
Actions["Actions team runner<br/>schedule + concurrency group"]
end
Sources["GitHub API (ETag) · Drive changes (page token) · Jira (JQL cursor) · feeds (ETag)"]
Humans["People: hz inbox (CLI) · MCP tools · GitHub UI"]
Tick -- "fetch / push receipts" --> Repo
Tick -- "acquire / renew" --> Leases
Tick -- "poll (leaseholder only)" --> Sources
Tick -- "proposals" --> Inbox
Actions -- "tick --scope team" --> Repo
Actions --> Leases
Humans -- "approve / reject" --> Inbox
Inbox -- "approved → publish op (idempotent)" --> Tick
Phased plan¶
| Version | Scope | Done when |
|---|---|---|
| 0.2: the daemon runs locally | JobLet (schedule, personal); hz system daemon install|uninstall|status|logs|tick|pause|resume|jobs; hz system serve daemon; plist and unit writers; local hash-chained receipts; rotating logs; HealthLet; update takes the tick lock and restarts resident mode; --dry-run; example job: learning feeds → local weekly digest draft (no publishing) |
Catch-up after sleep works once; golden-file tests for plist and unit; tick tested with a fake clock |
| 0.3: the team | state module with Ledger/Leases/Inbox; shared receipts; lease refs; WatchLet with conditional GitHub polling; approvals as issues and PRs plus hz inbox; owned auto-approval; idempotent publishing; scope=team and the Actions template; team kill switch; the newsletter published after approval |
Two daemons plus Actions against one repository: exactly one render per slot, no lost receipts |
| 0.4: contracts and artefacts | ContractLet (Heinzelmännchen dialect, a subset of OpenProse) → JobLet; facets and memo-skip; agent-agnostic renders; Drive watcher; discovery, registry and pinning of "🧝 Heinzel" tabs; self-improvement loop proposing issues and PRs (skills, ADRs, plugins) from digests and friction notes |
A Sheet tab is discovered, registered, runs, and pauses when edited |
| 0.5+ | Resident mode with file watching; per-job sandbox profiles; GitHub App; Atlassian watchers; signed receipts; ledger compaction; optional push relay into Actions | — |
Risks¶
- API rate limits across a fleet. Leaseholder-only polling, free 304s, serial requests, backoff, heartbeats.
- Ledger growth. Monthly directories, sparse checkout,
skippedreceipts kept local, archiving later. - Clock skew and slow holders. Skew margin, fencing tokens, idempotent publishing.
- macOS background item UX. Users may switch it off, or see an interpreter name.
doctordetects it and install explains it. - Headless Linux keyring. Documented fallback.
- Google restricted-scope verification. Narrow scopes, per-org OAuth clients.
- Prompt injection through artefacts and feeds. Registration, pinning, read-only renders, approval before writes.
- In-process plugin code in the background. Allowlist and subprocesses now; sandboxing later.
- Unreliable Actions schedules. Ledger catch-up and laptop fallback.
- Reactor is alpha and TypeScript. Borrow the concepts, keep the dialect compatible (ADR 0010).
Open questions¶
- State repository per team, or per org with directories?
- Team jobs on Actions by default, or on laptops?
skippedreceipts and draft text in the shared ledger, or pointers only?- Fine-grained PATs, a GitHub App,
gh auth token? - Google OAuth client: per org or project-registered?
- Which agent CLIs first, who pays, cost ceilings?
- Approvals in issues and PRs only, or also chat?
- Names:
hz system daemonorservice; launchd label; unit name. - Default interval; battery; linger.
- Who may register artefact-embedded workflows?
Sources¶
Fetched and read directly for this report: Reactor README · Reactor spec 03 · launchd.plist(5) · launchctl(1) · systemd.timer · GitHub REST best practices · Actions events · Actions concurrency · contents API · git-push · Drive push · Drive changes · Jira webhooks · uv tools · uv storage · Syncthing autostart · Homebrew manpage · Ollama Linux · tailscaled on macOS · Renovate dashboard · How Renovate works · keyring · Claude Code sandboxing · Docs tabs · Ventura background items.
Cited but not re-fetched (canonical documentation; check before quoting in the ADR): GitHub rate limits (the 5,000 and 1,000 req/h figures) · Jira rate limiting · systemd.resource-control · PEP 418 · feedparser ETag · APScheduler · Probot · git-bug · Kubernetes leases · platformdirs · logging.handlers · bubblewrap · fine-grained PATs · installation tokens · Google restricted scopes · W3C PROV · Stripe idempotency.