Problem Statement
Looper today is a macOS-only product. The daemon (looperd) — which is where Looper's value concentrates — is supervised exclusively by launchd, notifications go through osascript, and the release pipeline only produces darwin-arm64 artifacts. Users who want to run Looper on a public-cloud Linux host (e.g. an AWS EC2 instance, including Graviton/arm64) cannot do so without forking the project or hand-rolling their own supervision and notification layers.
This blocks two strategically important use cases:
- Cloud deployment of agent workflows. EC2 (and equivalent) is the natural home for long-lived agent-driven daemons that operate on a 24/7 cadence rather than a laptop's wake/sleep cadence. Looper currently has no story for this.
- Strategic positioning. As long as Looper is darwin-only, it cannot be presented as a serious infrastructure tool. "macOS-only daemon" is a contradiction for the daemon-first product Looper is becoming.
Solution
Add first-class Linux support, with linux-amd64 and linux-arm64 as supported targets. The shape of the support mirrors the macOS UX: one command (looper daemon install) installs the binary and registers a supervised daemon; status, start, stop, and uninstall work the same way; the user's state still lives under ~/.looper/. Under the hood, launchd is replaced by user-mode systemd on Linux.
Alongside the Linux port, ship a webhook notification channel that works on both macOS and Linux. This replaces the desktop-only osascript channel for headless cloud deployments, and gives macOS users a way to route notifications to Slack/Lark/Discord/etc. The webhook channel is independent of the Linux port at the code level, but is bundled into the same PRD because the Linux port without it weakens the cloud-native UX (silent daemon = bad).
Windows is explicitly out of scope for this PRD. The supervisor abstraction introduced here makes a future Windows port tractable, but no Windows code or testing is included.
User Stories
- As an EC2 operator, I want to install Looper on an Ubuntu instance with one command, so that I can run the daemon without hand-writing a systemd unit.
- As an EC2 operator on a Graviton instance, I want a
linux-arm64 build available, so that I do not need to run an emulated amd64 binary.
- As an EC2 operator, I want
looperd to be supervised by systemd-user, so that it restarts on crash and survives my SSH session disconnect (after enabling linger).
- As an EC2 operator who has never used Looper, I want a clear error or prompt telling me to run
loginctl enable-linger, so that the daemon actually survives logout.
- As an EC2 operator, I want
looper daemon install to detect that I am on Linux and produce a systemd unit file at ~/.config/systemd/user/looperd.service, so that I do not need to know the unit-file format.
- As an EC2 operator, I want
looper daemon start, stop, status, and uninstall to behave identically on Linux and macOS, so that my muscle memory transfers.
- As an EC2 operator, I want my daemon's logs to live under
~/.looper/logs/ on Linux just like on macOS, so that runtime artifacts are in one predictable place.
- As a cloud user, I want a webhook notification channel, so that my daemon can notify me via Slack/Lark/Discord/email-bridge/etc. when an agent finishes or needs input.
- As a cloud user, I want webhook delivery to be fire-and-forget, so that a slow or down receiver never blocks the daemon's main loop.
- As a security-conscious user, I want webhook payloads to be HMAC-signed by default, so that my receiver can verify authenticity.
- As a security-conscious user, I want to disable HMAC signing if my receiver does not need it, so that I can integrate with simple webhook receivers.
- As an EC2 operator, I want to put my webhook secret in an environment variable rather than in
~/.looper/config.json, so that secrets are not stored in plaintext on disk.
- As an EC2 operator, I want Looper to fail fast if my webhook configuration references an unset environment variable, so that I find out about a misconfiguration at startup rather than the first time a notification fires.
- As a macOS user, I want the webhook channel to also work for me, so that I can route Looper events to Slack/Lark/etc. without giving up
osascript notifications.
- As a Looper maintainer, I want a
Supervisor interface that abstracts daemon lifecycle, so that adding a new platform's supervision (Linux now, Windows later) does not require sprinkling if runtime.GOOS == "..." branches across the codebase.
- As a Looper maintainer, I want the supervisor refactor to ship in its own PR with no behavior change, so that it can be reviewed for authority-surface correctness independently of the systemd implementation.
- As a Looper maintainer, I want the design memo for the
Supervisor interface to enumerate accepted asymmetries between launchd and systemd, so that the interface does not silently pick one platform's primitives as the lowest common denominator.
- As a Looper maintainer, I want Linux CI to be a required check from day one, so that Linux support cannot bit-rot.
- As a Looper maintainer, I want OS-specific tests (launchd plist content, systemd unit content) to live in build-constrained files, so that the test suite stays compilable on every platform without runtime skips.
- As a Looper maintainer, I want generic supervisor lifecycle behavior to be tested against the interface (with a fake supervisor where useful), so that platform-specific test files stay small and focused on output content.
- As a Looper user on a fresh box, I want a clear hint telling me how to install
gh for my distro (apt / dnf / brew), so that I can resolve the missing-tool error without searching docs.
- As a Looper user, I want bootstrap to warn but not block when
gh is unauthenticated, so that I can authenticate later without restarting bootstrap from scratch.
- As a Looper user, I want
GH_TOKEN to be a documented alternative to gh auth login, so that I can authenticate non-interactively (e.g., via a paste or a cloud-init script).
- As a Looper user, I do not want bootstrap to interactively prompt me to set up a webhook, so that the bootstrap flow stays short. I will configure the webhook from documentation when I am ready.
- As a Looper user upgrading from a darwin-only version, I want my existing
~/.looper/config.json to remain valid with no migration step, so that the upgrade is invisible.
- As a Looper user on Linux, I want
daemon.mode to default to systemd automatically, so that I do not have to opt in to platform-appropriate supervision.
- As a Looper user, I want
looper upgrade and the install-source detection to recognize Linux install paths (~/.looper/bin/looperd), so that upgrades work the same way they do on macOS.
- As a Looper user, I want webhook notifications to mirror the events
osascript already fires for, so that I do not need to learn a new event policy when switching channels.
- As a Looper maintainer, I want the webhook channel to be implemented without introducing a
NotificationChannel plugin framework, so that the codebase does not gain a speculative abstraction with only two concrete implementations.
Implementation Decisions
Goal framing. Strategic positioning + EC2 as a first-class deployment target. Linux is the only new OS target in this PRD; Windows is explicitly deferred. The user profile assumed for Linux is daemon-first headless agent runner, with a human SSHing in for occasional manual intervention.
Modules to build/modify:
- New:
internal/supervisor (deep module). Defines a Supervisor interface that abstracts daemon lifecycle (Install, Start, Stop, Status, Uninstall). Two implementations: LaunchdSupervisor (extracted with no behavior change from current internal/cliapp/daemon_supervision.go and internal/cliapp/daemon_runtime.go) and SystemdSupervisor (new, user-mode systemd). Selection is driven by the existing Daemon.Mode config field, with platform-appropriate defaults (launchd on darwin, systemd on linux, foreground elsewhere).
- New:
internal/infra/notify/webhook (deep module). Webhook channel as a sibling to the existing osascript code in internal/infra/notify. Encapsulates HMAC signing, env-var secret resolution, async bounded buffer (size 64, 5s per-request timeout), and fire-and-forget delivery with a structured log line on failure. Consumed by the existing notification gateway alongside the osascript channel. No NotificationChannel interface is introduced — there are only two concrete channels and the abstraction is not yet earned.
- Modified:
internal/config. New notifications.webhook.* fields (enabled, url, secret, secretEnv, timeoutSeconds); validation rules (URL required when enabled, secret and secretEnv mutually exclusive, fail-fast if secretEnv references an unset variable). New systemd value in the Daemon.Mode enum. Platform-default Daemon.Mode resolution. Distro-aware install hint helper for missing tools.
- Modified:
internal/cliapp/bootstrap.go. Distro-aware install hints (apt/dnf on Linux, brew on macOS) for missing git / gh. Warn-not-block on missing gh auth; copy mentions GH_TOKEN as a supported alternative. No webhook prompt.
- Modified:
internal/cliapp/daemon_runtime.go, daemon_supervision.go, daemon_install.go. Replace inline if Daemon.Mode == launchd dispatch with calls into the new Supervisor interface. Extend resolveLooperdTarget() to support linux-amd64 and linux-arm64. No darwin-amd64 target.
- Modified:
.github/workflows/ci.yml. Add ubuntu-latest to the matrix as a required check. Build-constrained test files (*_darwin_test.go / *_linux_test.go) split OS-specific assertions; existing tests that hardcode darwin paths or darwin-arm64 artifacts are tagged or made platform-parametric where the underlying logic is shared.
- Unchanged in this PRD:
internal/runtime/runtime.go, internal/worker/runner.go, internal/fixer/runner.go. The /bin/sh -c shell invocations work on Linux as-is; the process-group isolation inconsistency between agent spawns (which set Setpgid: true) and shell-based spawns (which do not) is pre-existing on macOS, works in practice, and is out of scope. A separate follow-up issue will track it.
Supervisor interface design (PR1 memo). Per the project's design rules, PR1 (the Supervisor interface refactor) must include a design memo in its description that:
- States the methods on the interface and what each one represents in domain terms.
- For each method, states what
launchd does today and what systemd will do.
- Enumerates asymmetries deliberately accepted (initial list: no
reload primitive — both impls do stop+start; status fidelity floor is what launchctl print can produce; system-vs-user systemd mode is not exposed on the interface and lives inside SystemdSupervisor construction config so a future system-mode option does not change the interface).
- Confirms the interface is shaped by Looper's domain needs, not by
launchd's incidental shape.
PR1 and PR4 (the systemd implementation) require explicit @oracle review per the project's mandatory-review rule for authority-bearing changes.
Webhook firing policy. The webhook channel mirrors exactly the events the osascript channel fires for today. No new "is this important enough?" policy is introduced; the existing notification surface is the authority. If chattiness becomes a real complaint, an event allowlist may be added later.
Webhook payload (minimum). JSON body with event_type, timestamp, agent_id, message, looper_version, and an extra map for event-specific fields. HMAC signature, when enabled, is computed over the body and sent in an X-Looper-Signature: sha256=... header.
Webhook secret resolution. Both secret (inline) and secretEnv (env-var name) are accepted, but exactly one may be set. secretEnv is the recommended form for cloud deployments (cloud-init / SSM / secrets manager friendly). If secretEnv is set and the named variable is unset at startup, looperd fails to start with a clear validation error — consistent with the project's existing fail-fast posture for config validation.
Install model on Linux. looper daemon install writes ~/.looper/bin/looperd (mirroring macOS) and generates a user-mode systemd unit at ~/.config/systemd/user/looperd.service. It registers the unit via systemctl --user enable --now looperd. If the user does not have linger enabled (loginctl show-user or equivalent), install emits a clear post-install note instructing them to run loginctl enable-linger <user> (with sudo) so the daemon survives SSH disconnect. State (looperd.state.json, sqlite, backups, logs) continues to live under ~/.looper/.
Release matrix. looperd-darwin-arm64 (existing), looperd-linux-amd64 (new), looperd-linux-arm64 (new). No darwin-amd64. Cross-compilation handled in the existing release pipeline; the only code change is resolveLooperdTarget().
Config schema is additive. Existing macOS configs remain valid with no migration. Adding systemd to the Daemon.Mode enum and adding notifications.webhook.* are non-breaking changes.
PR sequence (six PRs). Each is reviewable in isolation; small PRs make reverts cheap.
| # |
Title |
Risk |
@oracle review |
Depends on |
| 1 |
refactor: introduce Supervisor interface for daemon lifecycle |
Low |
Yes (authority surface) |
— |
| 2 |
feat: add webhook notification channel |
Low |
No |
— (independent) |
| 3 |
chore: add Linux CI and build constraints for darwin-specific tests |
Low |
No |
1 |
| 4 |
feat: add SystemdSupervisor for Linux daemon mode |
Medium |
Yes (implements authority) |
1, 3 |
| 5 |
feat: support linux-amd64 and linux-arm64 release artifacts |
Low |
No |
4 |
| 6 |
feat: distro-aware install hints in bootstrap |
Trivial |
No |
5 |
PR2 and PR4 may proceed in parallel after PR3 lands.
Testing Decisions
What makes a good test in this work. Tests assert externally-observable behavior, not internal call sequences. For the supervisor abstraction specifically, this means: assert the content a Supervisor produces (plist XML for launchd, unit file content for systemd) and the outcome of lifecycle calls against a fake supervisor — not the specific launchctl / systemctl commands invoked. For the webhook channel, this means: assert what is sent on the wire (HTTP body, headers, signature) and what is logged on failure — not the goroutine choreography.
Modules with explicit test coverage:
internal/supervisor. Contract tests against the Supervisor interface using a fake implementation, plus implementation-specific tests asserting the launchd plist content (existing tests in daemon_runtime_test.go are retargeted) and the systemd unit-file content (new). Lifecycle tests (install / start / stop / status / uninstall transitions) live against the interface where possible.
internal/infra/notify/webhook. Tests for HMAC signing correctness against a known-good vector, env-var secret resolution (set, unset → fail-fast at config-validation time), fire-and-forget behavior when the receiver is down (no daemon stall, structured log line emitted), bounded buffer drop semantics under backpressure, and timeout behavior on slow receivers.
internal/config validation. Tests for the new validation rules: webhook URL required when enabled, secret and secretEnv mutually exclusive, secretEnv referencing an unset variable produces a fail-fast error, platform-default Daemon.Mode resolves correctly per runtime.GOOS.
internal/cliapp/bootstrap. Light coverage for distro-aware install hints (parametric over runtime.GOOS plus a fake /etc/os-release reader where needed). Cosmetic correctness; not a heavy test target.
Build constraints and CI. The test suite is split with //go:build constraints so darwin-only tests (launchd plist content, osascript-presence assertions, darwin-arm64-locked release-asset tests) live in *_darwin_test.go and equivalent linux-only tests live in *_linux_test.go. Generic logic stays platform-parametric. ubuntu-latest joins macos-latest in the GitHub Actions matrix as a required check from day one.
E2E scope. Foreground-mode end-to-end tests run in CI on both macOS and Linux runners. Real systemctl --user is not exercised in CI — GitHub Actions Ubuntu runners do not have a real user session, and bootstrapping one is a tarpit. Instead, systemd integration is verified by (a) generating-and-asserting unit-file content as unit tests, and (b) running looperd in foreground mode for end-to-end behavior. Real systemd integration testing happens manually on an EC2 box pre-release. This is consistent with the project's stance of escalating to sandbox E2E only for GitHub auth/scope/thread-mutation/rate-limit regressions.
Prior art. Existing launchd tests in internal/cliapp/daemon_runtime_test.go are the closest prior art for the new systemd unit-file content tests. Existing notification tests in internal/infra/notify/gateway_test.go are the closest prior art for the new webhook tests. Existing config validation tests in internal/config/config_test.go are the closest prior art for the new webhook-validation tests.
Out of Scope
- Windows support. The
Supervisor interface is designed to make a future Windows port tractable, but no Windows code, tests, packaging, or documentation are included in this PRD.
- Distro packages (.deb / .rpm) and apt/yum repositories. Linux install is exclusively
looper daemon install writing into ~/.looper/bin/. Native packaging is a separate, larger project.
- System-mode systemd units (
/etc/systemd/system/). Only user-mode (~/.config/systemd/user/) is supported. System-mode would require root install and a different state-ownership model; not in scope.
- IaC-first install flow (cloud-init / Ansible modules / Terraform providers / Helm charts). The headline UX is human-SSH plus
looper daemon install. The artifacts produced (~/.looper/bin/looperd and the unit file) are simple enough that an IaC user can place them directly without invoking looper daemon install, but no first-class IaC integration is built.
- Linux desktop notifications via
libnotify / notify-send. The Linux user profile assumed here is headless. Desktop notifications would target a small audience that is not the strategic focus.
- A pluggable
NotificationChannel framework. Two concrete channels (osascript, webhook) do not yet justify the abstraction. If a third channel appears, the framework can be extracted then.
- Webhook event filtering / allowlists. The webhook channel mirrors the existing
osascript firing policy exactly. Per-event filtering is deferred until there is a real chattiness complaint.
- Webhook delivery durability. Fire-and-forget only. No on-disk queue, no bounded retries, no replay. Notifications are advisory, not transactional.
- Process-group isolation cleanup. The pre-existing inconsistency between agent spawns (which set
Setpgid: true) and shell-based validation spawns in worker/fixer (which do not) is filed as a separate follow-up issue and not addressed here.
- Refactoring
/bin/sh -c shell invocations to direct exec.Command(name, args...) calls. Works on Linux as-is. Refactoring would be a behavior change (shell metacharacter semantics) and is out of scope.
- Auto-installation of
gh / git by Looper. Looper detects missing tools and emits distro-aware install hints, but does not modify system packages on the user's behalf.
- Config migration framework. All schema changes in this PRD are additive; no migration step is needed and none is built.
- Darwin-amd64 release artifacts. Existing
darwin-arm64-only macOS support is preserved unchanged.
Further Notes
- The webhook channel is genuinely cross-platform and is bundled into this PRD only because the daemon-first Linux UX is materially weaker without it. It is implemented as a sibling to
osascript, not as a Linux-only feature; macOS users get it too.
- The
Supervisor interface refactor (PR1) is the one new authority surface introduced by this work and warrants careful design review. The PR1 description must include the design memo enumerated above. The systemd implementation (PR4) is a thin client of that interface; if PR4 grows past ~300 lines of new test code, the refactor was not deep enough and the interface should be reconsidered.
- The
loginctl enable-linger requirement is a real footgun for first-time Linux users. Install-time detection plus a clear post-install message is the chosen mitigation; this is documentation-shaped rather than code-shaped, and a friendlier solution can be revisited if user reports indicate the message is missed.
- The decision to default
notifications.osascript.enabled based on runtime.GOOS == "darwin" is preserved unchanged. On Linux it is off; on macOS it remains on by default.
- This PRD captures decisions reached through a structured grilling session; the design memo for PR1 is the next deliverable and should be drafted before any code lands.
Problem Statement
Looper today is a macOS-only product. The daemon (
looperd) — which is where Looper's value concentrates — is supervised exclusively bylaunchd, notifications go throughosascript, and the release pipeline only producesdarwin-arm64artifacts. Users who want to run Looper on a public-cloud Linux host (e.g. an AWS EC2 instance, including Graviton/arm64) cannot do so without forking the project or hand-rolling their own supervision and notification layers.This blocks two strategically important use cases:
Solution
Add first-class Linux support, with
linux-amd64andlinux-arm64as supported targets. The shape of the support mirrors the macOS UX: one command (looper daemon install) installs the binary and registers a supervised daemon; status, start, stop, and uninstall work the same way; the user's state still lives under~/.looper/. Under the hood,launchdis replaced by user-modesystemdon Linux.Alongside the Linux port, ship a webhook notification channel that works on both macOS and Linux. This replaces the desktop-only
osascriptchannel for headless cloud deployments, and gives macOS users a way to route notifications to Slack/Lark/Discord/etc. The webhook channel is independent of the Linux port at the code level, but is bundled into the same PRD because the Linux port without it weakens the cloud-native UX (silent daemon = bad).Windows is explicitly out of scope for this PRD. The supervisor abstraction introduced here makes a future Windows port tractable, but no Windows code or testing is included.
User Stories
linux-arm64build available, so that I do not need to run an emulated amd64 binary.looperdto be supervised by systemd-user, so that it restarts on crash and survives my SSH session disconnect (after enabling linger).loginctl enable-linger, so that the daemon actually survives logout.looper daemon installto detect that I am on Linux and produce a systemd unit file at~/.config/systemd/user/looperd.service, so that I do not need to know the unit-file format.looper daemon start,stop,status, anduninstallto behave identically on Linux and macOS, so that my muscle memory transfers.~/.looper/logs/on Linux just like on macOS, so that runtime artifacts are in one predictable place.~/.looper/config.json, so that secrets are not stored in plaintext on disk.osascriptnotifications.Supervisorinterface that abstracts daemon lifecycle, so that adding a new platform's supervision (Linux now, Windows later) does not require sprinklingif runtime.GOOS == "..."branches across the codebase.Supervisorinterface to enumerate accepted asymmetries between launchd and systemd, so that the interface does not silently pick one platform's primitives as the lowest common denominator.ghfor my distro (apt / dnf / brew), so that I can resolve the missing-tool error without searching docs.ghis unauthenticated, so that I can authenticate later without restarting bootstrap from scratch.GH_TOKENto be a documented alternative togh auth login, so that I can authenticate non-interactively (e.g., via a paste or a cloud-init script).~/.looper/config.jsonto remain valid with no migration step, so that the upgrade is invisible.daemon.modeto default tosystemdautomatically, so that I do not have to opt in to platform-appropriate supervision.looper upgradeand the install-source detection to recognize Linux install paths (~/.looper/bin/looperd), so that upgrades work the same way they do on macOS.osascriptalready fires for, so that I do not need to learn a new event policy when switching channels.NotificationChannelplugin framework, so that the codebase does not gain a speculative abstraction with only two concrete implementations.Implementation Decisions
Goal framing. Strategic positioning + EC2 as a first-class deployment target. Linux is the only new OS target in this PRD; Windows is explicitly deferred. The user profile assumed for Linux is daemon-first headless agent runner, with a human SSHing in for occasional manual intervention.
Modules to build/modify:
internal/supervisor(deep module). Defines aSupervisorinterface that abstracts daemon lifecycle (Install,Start,Stop,Status,Uninstall). Two implementations:LaunchdSupervisor(extracted with no behavior change from currentinternal/cliapp/daemon_supervision.goandinternal/cliapp/daemon_runtime.go) andSystemdSupervisor(new, user-mode systemd). Selection is driven by the existingDaemon.Modeconfig field, with platform-appropriate defaults (launchdon darwin,systemdon linux,foregroundelsewhere).internal/infra/notify/webhook(deep module). Webhook channel as a sibling to the existingosascriptcode ininternal/infra/notify. Encapsulates HMAC signing, env-var secret resolution, async bounded buffer (size 64, 5s per-request timeout), and fire-and-forget delivery with a structured log line on failure. Consumed by the existing notification gateway alongside theosascriptchannel. NoNotificationChannelinterface is introduced — there are only two concrete channels and the abstraction is not yet earned.internal/config. Newnotifications.webhook.*fields (enabled,url,secret,secretEnv,timeoutSeconds); validation rules (URL required when enabled,secretandsecretEnvmutually exclusive, fail-fast ifsecretEnvreferences an unset variable). Newsystemdvalue in theDaemon.Modeenum. Platform-defaultDaemon.Moderesolution. Distro-aware install hint helper for missing tools.internal/cliapp/bootstrap.go. Distro-aware install hints (apt/dnf on Linux, brew on macOS) for missinggit/gh. Warn-not-block on missinggh auth; copy mentionsGH_TOKENas a supported alternative. No webhook prompt.internal/cliapp/daemon_runtime.go,daemon_supervision.go,daemon_install.go. Replace inlineif Daemon.Mode == launchddispatch with calls into the newSupervisorinterface. ExtendresolveLooperdTarget()to supportlinux-amd64andlinux-arm64. No darwin-amd64 target..github/workflows/ci.yml. Addubuntu-latestto the matrix as a required check. Build-constrained test files (*_darwin_test.go/*_linux_test.go) split OS-specific assertions; existing tests that hardcode darwin paths ordarwin-arm64artifacts are tagged or made platform-parametric where the underlying logic is shared.internal/runtime/runtime.go,internal/worker/runner.go,internal/fixer/runner.go. The/bin/sh -cshell invocations work on Linux as-is; the process-group isolation inconsistency between agent spawns (which setSetpgid: true) and shell-based spawns (which do not) is pre-existing on macOS, works in practice, and is out of scope. A separate follow-up issue will track it.Supervisor interface design (PR1 memo). Per the project's design rules, PR1 (the
Supervisorinterface refactor) must include a design memo in its description that:launchddoes today and whatsystemdwill do.reloadprimitive — both impls do stop+start; status fidelity floor is whatlaunchctl printcan produce; system-vs-user systemd mode is not exposed on the interface and lives insideSystemdSupervisorconstruction config so a future system-mode option does not change the interface).launchd's incidental shape.PR1 and PR4 (the systemd implementation) require explicit
@oraclereview per the project's mandatory-review rule for authority-bearing changes.Webhook firing policy. The webhook channel mirrors exactly the events the
osascriptchannel fires for today. No new "is this important enough?" policy is introduced; the existing notification surface is the authority. If chattiness becomes a real complaint, an event allowlist may be added later.Webhook payload (minimum). JSON body with
event_type,timestamp,agent_id,message,looper_version, and anextramap for event-specific fields. HMAC signature, when enabled, is computed over the body and sent in anX-Looper-Signature: sha256=...header.Webhook secret resolution. Both
secret(inline) andsecretEnv(env-var name) are accepted, but exactly one may be set.secretEnvis the recommended form for cloud deployments (cloud-init / SSM / secrets manager friendly). IfsecretEnvis set and the named variable is unset at startup, looperd fails to start with a clear validation error — consistent with the project's existing fail-fast posture for config validation.Install model on Linux.
looper daemon installwrites~/.looper/bin/looperd(mirroring macOS) and generates a user-mode systemd unit at~/.config/systemd/user/looperd.service. It registers the unit viasystemctl --user enable --now looperd. If the user does not have linger enabled (loginctl show-useror equivalent), install emits a clear post-install note instructing them to runloginctl enable-linger <user>(with sudo) so the daemon survives SSH disconnect. State (looperd.state.json, sqlite, backups, logs) continues to live under~/.looper/.Release matrix.
looperd-darwin-arm64(existing),looperd-linux-amd64(new),looperd-linux-arm64(new). Nodarwin-amd64. Cross-compilation handled in the existing release pipeline; the only code change isresolveLooperdTarget().Config schema is additive. Existing macOS configs remain valid with no migration. Adding
systemdto theDaemon.Modeenum and addingnotifications.webhook.*are non-breaking changes.PR sequence (six PRs). Each is reviewable in isolation; small PRs make reverts cheap.
@oraclereviewPR2 and PR4 may proceed in parallel after PR3 lands.
Testing Decisions
What makes a good test in this work. Tests assert externally-observable behavior, not internal call sequences. For the supervisor abstraction specifically, this means: assert the content a
Supervisorproduces (plist XML for launchd, unit file content for systemd) and the outcome of lifecycle calls against a fake supervisor — not the specificlaunchctl/systemctlcommands invoked. For the webhook channel, this means: assert what is sent on the wire (HTTP body, headers, signature) and what is logged on failure — not the goroutine choreography.Modules with explicit test coverage:
internal/supervisor. Contract tests against theSupervisorinterface using a fake implementation, plus implementation-specific tests asserting the launchd plist content (existing tests indaemon_runtime_test.goare retargeted) and the systemd unit-file content (new). Lifecycle tests (install / start / stop / status / uninstall transitions) live against the interface where possible.internal/infra/notify/webhook. Tests for HMAC signing correctness against a known-good vector, env-var secret resolution (set, unset → fail-fast at config-validation time), fire-and-forget behavior when the receiver is down (no daemon stall, structured log line emitted), bounded buffer drop semantics under backpressure, and timeout behavior on slow receivers.internal/configvalidation. Tests for the new validation rules: webhook URL required when enabled,secretandsecretEnvmutually exclusive,secretEnvreferencing an unset variable produces a fail-fast error, platform-defaultDaemon.Moderesolves correctly perruntime.GOOS.internal/cliapp/bootstrap. Light coverage for distro-aware install hints (parametric overruntime.GOOSplus a fake/etc/os-releasereader where needed). Cosmetic correctness; not a heavy test target.Build constraints and CI. The test suite is split with
//go:buildconstraints so darwin-only tests (launchd plist content, osascript-presence assertions,darwin-arm64-locked release-asset tests) live in*_darwin_test.goand equivalent linux-only tests live in*_linux_test.go. Generic logic stays platform-parametric.ubuntu-latestjoinsmacos-latestin the GitHub Actions matrix as a required check from day one.E2E scope. Foreground-mode end-to-end tests run in CI on both macOS and Linux runners. Real
systemctl --useris not exercised in CI — GitHub Actions Ubuntu runners do not have a real user session, and bootstrapping one is a tarpit. Instead, systemd integration is verified by (a) generating-and-asserting unit-file content as unit tests, and (b) running looperd in foreground mode for end-to-end behavior. Real systemd integration testing happens manually on an EC2 box pre-release. This is consistent with the project's stance of escalating to sandbox E2E only for GitHub auth/scope/thread-mutation/rate-limit regressions.Prior art. Existing launchd tests in
internal/cliapp/daemon_runtime_test.goare the closest prior art for the new systemd unit-file content tests. Existing notification tests ininternal/infra/notify/gateway_test.goare the closest prior art for the new webhook tests. Existing config validation tests ininternal/config/config_test.goare the closest prior art for the new webhook-validation tests.Out of Scope
Supervisorinterface is designed to make a future Windows port tractable, but no Windows code, tests, packaging, or documentation are included in this PRD.looper daemon installwriting into~/.looper/bin/. Native packaging is a separate, larger project./etc/systemd/system/). Only user-mode (~/.config/systemd/user/) is supported. System-mode would require root install and a different state-ownership model; not in scope.looper daemon install. The artifacts produced (~/.looper/bin/looperdand the unit file) are simple enough that an IaC user can place them directly without invokinglooper daemon install, but no first-class IaC integration is built.libnotify/notify-send. The Linux user profile assumed here is headless. Desktop notifications would target a small audience that is not the strategic focus.NotificationChannelframework. Two concrete channels (osascript,webhook) do not yet justify the abstraction. If a third channel appears, the framework can be extracted then.osascriptfiring policy exactly. Per-event filtering is deferred until there is a real chattiness complaint.Setpgid: true) and shell-based validation spawns in worker/fixer (which do not) is filed as a separate follow-up issue and not addressed here./bin/sh -cshell invocations to directexec.Command(name, args...)calls. Works on Linux as-is. Refactoring would be a behavior change (shell metacharacter semantics) and is out of scope.gh/gitby Looper. Looper detects missing tools and emits distro-aware install hints, but does not modify system packages on the user's behalf.darwin-arm64-only macOS support is preserved unchanged.Further Notes
osascript, not as a Linux-only feature; macOS users get it too.Supervisorinterface refactor (PR1) is the one new authority surface introduced by this work and warrants careful design review. The PR1 description must include the design memo enumerated above. The systemd implementation (PR4) is a thin client of that interface; if PR4 grows past ~300 lines of new test code, the refactor was not deep enough and the interface should be reconsidered.loginctl enable-lingerrequirement is a real footgun for first-time Linux users. Install-time detection plus a clear post-install message is the chosen mitigation; this is documentation-shaped rather than code-shaped, and a friendlier solution can be revisited if user reports indicate the message is missed.notifications.osascript.enabledbased onruntime.GOOS == "darwin"is preserved unchanged. On Linux it is off; on macOS it remains on by default.