Everything is plain JSON and JSONL under ~/.conduit. No database, no migrations. You can
read the whole state of a project with cat, which is the point — when something is wrong,
you can see it.
~/.conduit/
├── .env the only config the desktop app sees
├── api-keys.json voice provider keys
├── supervisor-log.jsonl every classification, across all projects
├── brain-private/ the Keeper's own state, deliberately outside any cwd
│ ├── state.json
│ └── codex-home/
├── shared_content/<project>/ the folder agents pass files through
├── wiki/<project>/ long-term project memory
└── projects/<id>/
├── project.json the project, its agents, and pending plans
├── groupchat.jsonl one line per message
└── audit.jsonl one line per decision you made
brain-private is outside any project working directory on purpose. It used to live in the
Keeper's cwd, where the Keeper would grep its own history and answer questions about
projects you had deleted.
From src/types.ts.
Project {
id: string;
name: string; // also the folder name under shared_content/ and wiki/
description?: string;
cwd: string; // where the agents actually run
createdAt: string;
}Renaming a project moves those folders. That is why rename is a real operation and not a label change.
Agent {
id: string;
projectId: string;
name: string;
role?: string; // free text, shown as a chip
cli: CliId; // claude | codex | gemini | opencode | gpt | nemotron
cwd: string;
status: AgentStatus; // derived live, never written by hand
pid?: number;
codexThreadId?: string;
flags?: string[];
pendingGate?: Gate;
}Plan {
id: string;
projectId: string;
description: string;
targetAgent: string;
targetProject: string;
proposedMessage: string; // the exact text that would be sent
createdAt: string;
}Pending plans live in project.json. Decisions go to audit.jsonl and the plan is removed.
GroupChatEntry {
id: string;
ts: string;
role: 'user' | 'supervisor' | 'agent';
sender: string;
text: string;
classification?: 'progress' | 'blocker' | 'question' | 'risky_action';
} start ──────► running ──────► awaiting_input ──► idle
▲ │ │ │
│ │ └────────────┘
│ ▼ (a message sent here
stopped ◄─── stop / exit puts it back to running)
| Status | Means |
|---|---|
running |
Actively working |
awaiting_input |
Finished a turn, or asked something — needs you |
idle |
awaiting_input for a while with no attention |
stopped |
Not running |
awaiting_input and idle are ready states, not stuck states. They are exactly when
you, the Keeper, or an approved plan sends the next instruction. Only a pendingGate means
the agent is waiting on a specific answer.
Where each status comes from:
| CLI | Source |
|---|---|
| Claude Code | Lifecycle hooks — Conduit passes --settings pointing at its own hook server |
| Codex | codex app-server events |
| Gemini, OpenCode, GPT-OSS, Nemotron | Process liveness only |
The four on the bottom row report running or stopped and nothing between. The UI does
not smooth that over — a dot that claims to know more than it does is worse than one that
admits it.
agent prints something
│
├─ regex match ──────────────────► gate raised (source: "regex")
│ │
└─ Supervisor says risky_action ──► gate raised (source: "supervisor")
│
agent is FROZEN, pendingGate set
│
┌────────────────────────────┼────────────────────────────┐
▼ ▼ ▼
approve reject custom
keystrokes typed in Escape, then "stop" your own text typed in
│ │ │
└────────────────────────────┴────────────────────────────┘
│
pendingGate cleared, gate:resolved broadcast,
decision appended to audit.jsonl
Two things are true of every path:
- Resolution has one implementation (
src/gate-resolve.ts). The REST route and the voice path both call it. Two copies would drift, and the half that drifts is the one answering a destructive prompt. - Approving and rejecting are equally one click. A safety valve that is tedious to refuse gets approved by reflex.
While a gate is open, /org/inject and /org/broadcast return 400. A message delivered
then would be typed in as the answer to that prompt.
Supervisor wants an agent to do something
│
└─ plan_action ──► Plan created, shown to you with the exact message
│
┌────────────┴────────────┐
▼ ▼
approve reject
message sent to the agent nothing sent, reason recorded
│ │
└────────────┬────────────┘
▼
removed from project.json,
appended to audit.jsonl
plan_action is the Supervisor's only write tool. It cannot reach an agent any other
way. Rejected plans are included in the context of later turns, so it does not re-propose
something you already refused unless the situation changed.
project.json is written by two processes. mutateProjectData takes a lock with
fs.openSync(file, 'wx') — atomic create, fails if it exists — around read-modify-write.
scripts/test-storage-concurrency.mjs runs two processes doing 150 writes each and asserts
the file is valid JSON at every read, never observed empty, and loses under 1% of writes.
Measured: 0 lost out of 300.
The lock does not call ensureDir. It did once, and that recreated the directory of a
project you had just deleted — resurrecting it on the next write.