You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Proposal: procoder as a service — a local daemon per machine #117
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:
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.
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"
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?
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
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
What runs today
Five hook wirings, three entrypoints, all through one chokepoint —
hooks/launcher.sh:launcher.sh principles --hooklauncher.sh hook stoplauncher.sh hook pre-tool-use— interceptsgit commit, runs the gatelauncher.sh hook post-tool-use— format, drift, secrets, lint, index refresh, ask queueEach 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:
format.Checkexecs a real formatter on a real path. The commit gate runs thesame work
procoder checkdoes, over the working tree and git.codeindex.Refreshwrites an on-disk index.net/httpappears only as aclient, in
internal/releases.The shape
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
~/.procoder/run/, mode 0600 — file permissions are the auth, no token, no port. Named pipe on Windows..procoder/config.toml. No repository changes behaviour because it upgraded.procoder checkbehaves identically everywhere, CI included, with zero setup. Only hooks use the daemon..procoder/state/set is what a later tier could own. The line already exists in.gitignore.Corrections to the original proposal
curlin the hook command was wrong. It throws awaylauncher.sh'sdegradation 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.
rootis a path today. One daemonserving 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.Mutexin one test.dispatch.json, the claims ledger and the askledger 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 = truealreadywrites 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 serveon a unix socket, clienttransport 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
Out of scope
Designed, parked, not scheduled: see Design (parked): team mode — a shared server behind the local daemon, with RBAC #248.
outside agent events.
Speculative; there is no evidence anybody needs it yet.
Still open
the on-disk index and only cache the hot parts? Memory footprint of a daemon
serving ten repos.
procoder initdo about the daemon in a repo that opts in— anything, or is D3's auto-start the whole story?
repos exit on the same schedule as one serving a single repo?
procoder self-upgrade, or is beingrestarted by the version handshake the whole story?