Skip to content

Proposal: procoder as a service — a local daemon per machine #117

Description

@piwi3910

Rewritten 2026-08-28 after reading how the hooks actually run. The original
proposal treated "hooks call an API" as one switch. It is a tiered design, and
only the first tier is in scope here. Team mode is deliberately out of scope
— the design exists and is parked in #248.
The CLI is not being removed and does not become a wrapper.

What runs today

Five hook wirings, three entrypoints, all through one chokepoint — hooks/launcher.sh:

Event Command
SessionStart launcher.sh principles --hook
Stop, PreCompact launcher.sh hook stop
PreToolUse (Bash) launcher.sh hook pre-tool-use — intercepts git commit, runs the gate
PostToolUse (Write|Edit) launcher.sh hook post-tool-use — format, drift, secrets, lint, index refresh, ask queue

Each is an independent process: spawn → stdin → stdout → exit. Nothing is shared
between calls, between sessions, or between repos.

Two facts from the code shape everything below:

  1. Every hook path is bound to the machine holding the checkout.
    format.Check execs a real formatter on a real path. The commit gate runs the
    same work procoder check does, over the working tree and git.
    codeindex.Refresh writes an on-disk index.
  2. There is no server anywhere in the tree. net/http appears only as a
    client, in internal/releases.

The shape

  hook payload
      │
      ▼
 launcher.sh ──exec──▶ procoder (client)
                          │ unix socket, 0600
                          ▼
                   procoder daemon  ← one per machine, serves every repo
                    - runs the real work on the local checkout
                    - warm code index, warm config
                          ┆
                          ┆ HTTPS + token
                          ▼
                   ( team server )   ← FUTURE, not this issue

Hooks have exactly one transport, always: the local socket. A later team
tier sits behind the daemon, not beside it, so the hook path never learns about
it. That is the only reason the third tier is drawn here at all — it is what
keeps this issue's design from having to be redone later.

Scope of this issue

In: the local daemon. Phase 0 and phase 1 below.
Out: the team server, RBAC, shared state, multi-user. See #248.

Decisions

# Decision
D1 Tiered design: binary → local daemon → (future) team server. Hooks only ever talk to the local socket.
D2 Hook↔daemon over a unix socket at ~/.procoder/run/, mode 0600 — file permissions are the auth, no token, no port. Named pipe on Windows.
D3 The SessionStart hook starts the daemon if the socket is dead — single-flight via a lock file, idle timeout after inactivity. No launchd, no systemd, no install step.
D4 Daemon mode is opt-in via .procoder/config.toml. No repository changes behaviour because it upgraded.
D5 Plain CLI commands always run in-process. procoder check behaves identically everywhere, CI included, with zero setup. Only hooks use the daemon.
D6 A failure in a tier the daemon depends on must not cost you the local checks. Local work runs and reports; the part that could not be done says so.
D7 Git owns content (specs, plans, ADRs, backlog); the gitignored .procoder/state/ set is what a later tier could own. The line already exists in .gitignore.
D8 The CLI stays. It does not become a thin client wrapper in any phase.

Corrections to the original proposal

curl in the hook command was wrong. It throws away launcher.sh's
degradation contract (a hook that cannot get its binary warns and exits 0; a
command refuses), loses the JSON envelope the host requires, and adds a curl
dependency on Windows. The launcher keeps exec'ing the binary; the binary picks
its transport. One code path, one place to degrade.

"No auth needed for localhost" was wrong. A loopback port is reachable by
every process and every other user on the box, and gets forwarded out of
devcontainers by accident. Hence D2.

Repo identity cannot be a filesystem path. root is a path today. One daemon
serving many repos needs a stable key, and the same repo lives at different paths
on different machines — normalised remote URL, with the path as fallback for
repos with no remote. This is the single most likely thing to be discovered too
late, which is why it is in phase 0 rather than later.

Concurrency becomes a real bug. There is no locking anywhere in internal/
— only a sync.Mutex in one test. dispatch.json, the claims ledger and the ask
ledger are read-modify-write, safe today only because each hook is its own
short-lived process. One daemon serving several sessions breaks that. Per-root
serialisation is required in phase 1, not later.

Version skew is a debugging trap. A daemon left running from an older release
will serve stale behaviour to a newer binary. The daemon reports its version and
the client refuses (or restarts it) on mismatch.

The latency claim is unmeasured. The original table asserted 10–50ms spawn
versus 2–5ms HTTP. The dominant cost in PostToolUse is very likely the formatter
subprocess, which the daemon does not remove. [learn] record = true already
writes per-run durations — measure before claiming a number. The warm-index and
shared-config argument stands without it.

Phases

Phase 0 — the seam. State access behind an interface, filesystem
implementation, plus stable repo identity. No daemon, no server, no behaviour
change. Zero design risk, and every later phase needs it.

Phase 1 — the local daemon. procoder serve on a unix socket, client
transport for the four hook entrypoints, SessionStart auto-start, idle timeout,
per-root serialisation, version handshake. Parity test: the same payload in
produces byte-identical output, daemon versus spawn.

Nothing beyond phase 1 is scheduled.

Configuration

[service]
mode = "off"            # off | local    (default off — D4)
socket = "~/.procoder/run/procoder.sock"

Out of scope

  • Team mode — the shared server, RBAC, multi-user, multi-repo governance.
    Designed, parked, not scheduled: see Design (parked): team mode — a shared server behind the local daemon, with RBAC #248.
  • novamem integration. Depends on a team tier that does not exist yet.
  • Proactive filesystem watching — the daemon triggering checks on its own,
    outside agent events.
  • Cross-repo gate coordination — "repo A changed X, so update repo B's plan".
    Speculative; there is no evidence anybody needs it yet.
  • Removing the CLI. Not now, not later.

Still open

  • Q1 — Does the daemon hold one code index per repo in memory, or keep using
    the on-disk index and only cache the hot parts? Memory footprint of a daemon
    serving ten repos.
  • Q2 — What does procoder init do about the daemon in a repo that opts in
    — anything, or is D3's auto-start the whole story?
  • Q3 — Idle timeout: how long, and does a daemon with a warm index for ten
    repos exit on the same schedule as one serving a single repo?
  • Q4 — Does the daemon survive a procoder self-upgrade, or is being
    restarted by the version handshake the whole story?

Activity

  1. added
    enhancementNew feature or request
    marketplaceMarketplace submission issue
    and removed
    marketplaceMarketplace submission issue
    on Aug 21, 2026
  2. changed the title [-]Proposal: procoder as a service — hooks calling an API instead of local commands[/-] [+]Proposal: procoder as a service — a local daemon per machine, an optional shared server per team[/+] on Aug 28, 2026
  3. changed the title [-]Proposal: procoder as a service — a local daemon per machine, an optional shared server per team[/-] [+]Proposal: procoder as a service — a local daemon per machine[/+] on Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions