Skip to content

Commit 63c676b

Browse files
scgopiclaude
andcommitted
Add GitHub Pages article on how GraphCode works
Two-layer article (mental model, then internals) served from docs/ on main, with the latest-release link up top and a slot reserved for the demo video. The docs/ gitignore becomes a contents ignore with carve-outs so the site files publish while local design docs stay ignored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8e45bb2 commit 63c676b

4 files changed

Lines changed: 239 additions & 2 deletions

File tree

.gitignore

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,9 @@ xcuserdata/
3434
*.cer
3535
*.certSigningRequest
3636

37-
# Design docs — kept locally for now, not pushed to the remote yet.
38-
/docs/
37+
# Design docs — kept locally for now, not pushed to the remote yet. The GitHub Pages
38+
# site (index.md + _config.yml) is the one carve-out: ignore docs/ contents, not the
39+
# directory, so the site files can be re-included.
40+
/docs/*
41+
!/docs/index.md
42+
!/docs/_config.yml

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ to, watch, and steer — not headless jobs that report back when they're done.
1414

1515
> Early and moving. Expect rough edges, and see [Known limitations](#known-limitations).
1616
17+
**[How GraphCode works →](https://scgopi.github.io/GraphCode/)** — the full article:
18+
the mental model, then the machinery underneath it.
19+
1720
## How it works
1821

1922
Every loop type is "an agent runs repeatedly" — they differ in what *you* stop doing:

docs/_config.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
theme: jekyll-theme-cayman
2+
title: GraphCode
3+
description: Graphs of live, steerable Claude Code sessions on macOS

docs/index.md

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
---
2+
title: How GraphCode Works
3+
description: Graphs of live, steerable Claude Code sessions on macOS — the mental model, and the machinery underneath it.
4+
---
5+
6+
# How GraphCode works
7+
8+
You can run one Claude Code session in a terminal. GraphCode lets you run ten —
9+
connected, unattended, and still yours to attach to and correct mid-run.
10+
11+
**[⬇ Download the latest release](https://github.com/scgopi/GraphCode/releases/latest)**
12+
· [Source on GitHub](https://github.com/scgopi/GraphCode) · Requires macOS 15+ on Apple
13+
Silicon, with Claude Code on your `PATH`.
14+
15+
<!-- VIDEO SLOT: demo video goes here when ready -->
16+
17+
This article is in two parts. The first explains the mental model — what a loop is, what
18+
an edge does, why the sessions are real terminals. The second opens the hood for
19+
contributors: the app/daemon/CLI split, how sessions are created and reattached, and how
20+
edges actually fire.
21+
22+
---
23+
24+
## Part 1 — The mental model
25+
26+
### The problem
27+
28+
A coding agent in a terminal is powerful but *singular*: one session, one task, and it
29+
stops mattering the moment you close the window. The obvious fix — cron jobs firing
30+
`claude -p` headlessly — throws away the thing that made the terminal session good: you
31+
could watch it, interrupt it, and redirect it.
32+
33+
GraphCode's premise is that you shouldn't have to choose. Work is arranged as a **graph
34+
of loops**: each node is a unit of agentic work running inside a real CLI session, and
35+
each edge is a relationship between two of them. The sessions stay real, attachable
36+
terminals the whole time — automation comes from the graph around them, not from taking
37+
the terminal away.
38+
39+
### Loops: what you hand off
40+
41+
Every loop type is "an agent runs repeatedly" — they differ in what *you* stop doing:
42+
43+
| Loop type | You hand off | Runs until |
44+
|---|---|---|
45+
| **Turn-based** | the check | you end it — each turn pauses for your review inside the session |
46+
| **Goal-based** | the stop condition | the goal is met (optionally: a shell command exits 0) |
47+
| **Time-based** | the trigger | you stop it — cadence lives in the prompt (`/loop 1h …`) |
48+
| **Proactive** | the prompt | a composite sub-graph runs it end to end |
49+
50+
The hand-off framing is load-bearing. A turn-based loop exists because *a person is in
51+
the sequence* — its session is told to stop after every turn for review, and the
52+
criterion you wrote travels into the session's opening prompt so you never retype it. A
53+
goal-based loop is handed a statement of "done" (and the creation form refuses an empty
54+
one — a goal without a stop condition is a modelling error, not a style choice). A
55+
time-based loop is handed a trigger, and a proactive node is handed the whole prompt: it
56+
is the orchestrator running a graph *inside* a graph.
57+
58+
### The first design choice: GraphCode schedules nothing
59+
60+
A time-based loop's recurrence lives *inside* its session, written into the prompt with
61+
the agent's own `/loop` or `/schedule` skill. GraphCode holds no interval of its own,
62+
runs no timers, and never fires a headless `claude -p`.
63+
64+
An early version did the opposite — a timer per node, a headless invocation per tick,
65+
output discarded. It worked, and it was worthless: there was nothing to attach to,
66+
nothing to watch, nothing to steer. Moving the cadence into the session is what makes a
67+
running loop an ordinary interactive terminal that happens to be busy, rather than a job
68+
that already finished somewhere else. The daemon's only remaining duty for such a loop is
69+
**liveness**: making sure the session exists.
70+
71+
### The second design choice: sessions outlive everything
72+
73+
Each loop's terminal is a [`zmx`](https://zmx.sh) session — a PTY kept alive by a
74+
session daemon, the same idea as tmux. It survives closing the window, quitting the app,
75+
and restarting GraphCode's own daemon. Reattaching restores full scrollback.
76+
77+
This is also what makes unattended and attended the *same thing*. When the daemon starts
78+
a goal-based loop at 3 a.m., and you click its node at 9, you are not reading a log of
79+
what happened — you are in the session that did it, live, with its history above you and
80+
its process still running.
81+
82+
### Edges: the three ways loops talk
83+
84+
Drag between two nodes and you get an edge. There are three kinds:
85+
86+
| Kind | What it does | Blocks its target? |
87+
|---|---|---|
88+
| **Hand-off** | fires when the source resolves, unblocking the target | yes — the target waits for it |
89+
| **Message** | injects text into a running peer's live session | no — peers run concurrently |
90+
| **Spawn** | instantiates a new node from a template — the one kind allowed to cross into another project's graph | no — a template isn't waiting |
91+
92+
Each edge carries a **condition** — always, on success, or on failure — evaluated
93+
against how the source resolved. An `on failure` hand-off is how you build "if the
94+
nightly build breaks, wake the fixer."
95+
96+
### Cycles are opt-in, and bounded by construction
97+
98+
An unguarded edge fires exactly once. To make a cycle actually loop, you attach a
99+
**cycle guard** — and a guard must carry a bound: a maximum iteration count, an `until`
100+
shell condition, or both. The feature that lets a loop repeat is the same feature that
101+
bounds it, so there is no way to express "loop forever, unattended, spending tokens" by
102+
accident.
103+
104+
### Knowing when a goal is done
105+
106+
A goal-based loop's stop condition has two halves. The **summary** is the human statement
107+
of done, and it is required. The **predicate** is an optional shell command — the machine
108+
version — and the daemon polls it (once a minute by default), resolving the node the
109+
moment it exits 0. The same predicate is written into the session's opening prompt, so
110+
the agent and the daemon are never working to two different definitions of done. Goals
111+
with no honest shell equivalent ("the design doc reads clearly") simply omit the
112+
predicate and resolve when their session exits.
113+
114+
A loop moves through a small lifecycle — *idle → running → succeeded / failed / stalled /
115+
stopped* — and **stopped** is deliberately distinct from **failed**: work someone chose
116+
to end didn't go wrong, and filing it as a failure would bury real failures in noise.
117+
118+
---
119+
120+
## Part 2 — Under the hood
121+
122+
### Four pieces, one socket
123+
124+
| Piece | Role |
125+
|---|---|
126+
| `graphcode.app` | SwiftUI app — sidebar, graph canvas, per-loop terminal workspace (tabs, splits), rendering terminals with GhosttyKit |
127+
| `graphcoded` | The orchestrator daemon, kept alive by launchd. Owns every project's graph and all automation |
128+
| `graphcode` | CLI speaking to the same daemon — `graphcode status <project>`, `graphcode node create …` |
129+
| `GraphcodeKit` | The shared framework all three link — the domain model, persistence, IPC, and session plumbing live here once, so the app and daemon can never disagree about what a loop is |
130+
131+
The app and CLI are both *clients*. They connect to `graphcoded` over a Unix domain
132+
socket (`~/.graphcode/graphcoded.sock`) speaking length-framed JSON. Commands flow in;
133+
after every mutation the daemon broadcasts the updated graph to every connected client,
134+
so two app windows and a CLI watching the same project always agree. Validation happens
135+
in the daemon, not just the app's forms — a rule only one client enforces isn't a rule.
136+
137+
### GraphStore: the automation core
138+
139+
Inside the daemon, one `GraphStore` actor per open project owns that project's graph.
140+
It is the whole of what makes `graphcoded` load-bearing:
141+
142+
- **Edge firing.** When a node resolves, the store walks its outgoing edges, evaluates
143+
each condition against how the node resolved, and fires the ones that pass — unblocking
144+
hand-off targets, delivering messages, instantiating spawns.
145+
- **Goal polling.** One background task per goal predicate, at the node's poll interval.
146+
- **Liveness.** Unattended loops (time-based and goal-based) get their sessions started
147+
at creation and re-ensured when the daemon restarts — nothing else would restart them.
148+
- **Honest failure surfaces.** A message edge whose target has no live session isn't
149+
silently dropped; it's recorded as undelivered and shown.
150+
151+
Notably, `GraphStore` knows nothing about zmx, sockets, or subprocesses — every effect
152+
is an injected closure the daemon wires up at startup. That's why the automation core is
153+
fully unit-testable without spawning a process.
154+
155+
Cross-project spawn edges can't be handled by a store that owns exactly one graph, so
156+
they're handed up to the `ProjectRegistry` — the daemon layer that holds one store per
157+
open project and does the routing.
158+
159+
### How a session starts, and why attaching just works
160+
161+
When the daemon starts an unattended loop, it runs the node's backend command inside a
162+
detached zmx session. Three details carry most of the weight:
163+
164+
- **Shared identity is the mechanism.** The session is named after the node's ID —
165+
exactly the name the app's terminal surface attaches to. `zmx attach` joins an existing
166+
session rather than starting a second one, so "the daemon started it" and "you opened
167+
it" converge on the same live PTY, scrollback intact. There is no attach *protocol*;
168+
there is one session and two doors.
169+
- **The login-shell trick.** launchd gives the daemon a bare `PATH`. Commands are
170+
wrapped in `/bin/zsh -i -l -c` — and `-i` is load-bearing, not decoration: a
171+
developer's real `PATH` is typically set in `~/.zshrc`, which zsh only reads when
172+
interactive. Without it, `claude` is `command not found` for every daemon-started loop.
173+
- **Prompts are arguments, not syntax.** The node's opening prompt rides in as `"$@"`
174+
rather than being interpolated into the shell script, so a goal containing quotes,
175+
`;`, or `$(…)` is one argument and cannot become shell code.
176+
177+
The opening prompt itself is computed in exactly one place (`LoopNode.sessionPrompt`),
178+
shared by the daemon and the app, so the two can never disagree about what a loop starts
179+
with.
180+
181+
### Presence and usage: reported, never estimated
182+
183+
GraphCode cannot see inside a running `claude`. Rather than guess, it reads per-session
184+
labels that the backend's own lifecycle hooks can set (`zmx set "$ZMX_SESSION"
185+
presence=busy`, and similarly for token usage). With hooks installed, readings are
186+
*reported*; without them, the UI says "not reported" instead of showing a zero nobody
187+
measured. GraphCode deliberately doesn't install those hooks itself — that would mean
188+
editing your Claude Code settings behind your back.
189+
190+
### State: one directory, plain JSON
191+
192+
Everything lives in `~/.graphcode/` — per-project graphs, recents, terminal layouts, the
193+
daemon socket and logs, and the installed binaries under `bin/`. **Nothing is ever
194+
written inside a project folder you open.**
195+
196+
It's a dotfile directory rather than `~/Library/Application Support` for two concrete
197+
reasons: the state is plain JSON a developer will want to `cat` and `jq`, and it sits
198+
where developers already look (`~/.claude`, `~/.ssh`); and a Unix socket path is capped
199+
at 104 bytes on Darwin — the Application Support path burns most of that and grows with
200+
the username, while `~/.graphcode/graphcoded.sock` leaves real headroom.
201+
202+
Persistence is defensive by design: every domain type decodes missing fields to
203+
defaults, hand-written, because a decode failure reads to a user as *their project got
204+
wiped*. A graph saved by last month's build always loads in today's.
205+
206+
### What runs today
207+
208+
Claude Code is the only backend wired end to end (Copilot CLI and Codex exist in the
209+
model; the picker refuses pairings it can't host). The full current list of rough edges
210+
lives in the README's
211+
[Known limitations](https://github.com/scgopi/GraphCode#known-limitations).
212+
213+
---
214+
215+
## Try it
216+
217+
1. **[Download the latest release](https://github.com/scgopi/GraphCode/releases/latest)**
218+
drag GraphCode to Applications; that's the whole install. Releases are Developer ID
219+
signed and notarized.
220+
2. Add a folder, create a loop, and connect a second one with an edge.
221+
3. Close the app whenever you like — the loops won't notice.
222+
223+
Building from source, development commands, and limitations are in the
224+
[README](https://github.com/scgopi/GraphCode#readme). GraphCode is
225+
[MIT-licensed](https://github.com/scgopi/GraphCode/blob/main/LICENSE), built on the
226+
independent open-source projects [Ghostty](https://ghostty.org) and
227+
[zmx](https://zmx.sh).

0 commit comments

Comments
 (0)