- Go 100%
New verb. Fetches up to --limit messages from the spawn's channel
via the discord REST API and renders them oldest-first as
[<timestamp>] <author>: <content> lines, with newlines collapsed to
the same ⏎ glyph used by fetch_messages.
Saves the operator from opening Discord just to peek at progress, and
makes it easy to capture a spawn transcript for logs / handoff
without leaving the terminal.
internal/discord.Client.FetchMessages — GET /channels/{id}/messages
internal/spawn.Manager.Tail — channel lookup + fetch
api.{TailRequest,TailResponse,TailMessage}
cmd/cccd handleTail
cmd/ccc tailCmd
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|---|---|---|
| cmd | ||
| docs | ||
| internal | ||
| systemd | ||
| .gitignore | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
ccc-orchestrator
Workstation daemon that spawns Claude Code sessions into their own Discord channels, with managed lifecycle and pre-wired tooling.
One-line mission:
ccc spawn— atomic Discord channel + Claude Code instance, isolated per task, observable via screen, controllable via systemd.
Status: design — pre-code. This README is the spec; the code follows once the README stops surprising us.
Why it exists
The ccc alias is the daily on-ramp to Claude Code sessions. Today,
starting a new session requires manually:
- Creating a Discord channel in the right private category
- Editing the Discord MCP config to point at the new channel
- Picking a working dir (worktree? scratch? current?)
- Launching
claude-codewith the right flags - Pasting the task description as the first message
The friction is enough that parallel tasks ("run an investigation in the background while engineering continues here") rarely happen.
ccc spawn collapses this to one command. The result: a new Discord
channel exists, a Claude Code instance is alive in it, and the user
joins from their phone.
Operator command surface
ccc spawn --task "<one-line description>"
[--worktree <repo>[:branch]] # default: empty scratch dir
[--channel-name <slug>] # default: derived from task
[--writable-wiki] # default: read-only wiki access
[--no-preseed] # skip CLAUDE.md tool-awareness block
ccc list # active spawns
ccc kill <spawn-id> # stop a spawn
ccc attach <spawn-id> # screen -r convenience
ccc logs <spawn-id> [--follow] # journalctl convenience
ccc gc [--older-than 7d] [--dry-run] # clean up completed-spawn workdirs
The CLI talks to the daemon over a Unix socket. Verbs map roughly to:
spawn→ daemon creates channel + workdir + systemd unit + screen sessionlist→ daemon reads systemd--userunit list + state DBkill→ daemon stops the systemd unit (which kills screen which kills claude)attach→ CLI execsscreen -r claude-spawn-<id>directly (no daemon round-trip)logs→ CLI execsjournalctl --user-unit=claude-spawn@<id> -fgc→ daemon reads state DB, finds completed spawns older than threshold,rm -rftheir workdirs
Architecture
WORKSTATION (max desktop, where everything runs)
├── ccc CLI (~/bin/ccc)
│ ↓ Unix socket
├── cccd (orchestrator daemon)
│ - listens on $XDG_RUNTIME_DIR/ccc.sock (mode 0600)
│ - state DB: ~/.local/share/ccc/state.db (sqlite)
│ - speaks: Discord HTTP API (channel creation), systemctl --user (spawn control)
│ ↓ systemctl --user start claude-spawn@<id>
├── systemd user unit: claude-spawn@<id>.service
│ ExecStart=screen -dmS claude-spawn-<id> /home/max/bin/ccc-runner <id>
│ - systemd owns lifecycle (Restart=on-failure, journal logging)
│ - screen owns the interactive PTY (user can screen -r to attach)
│ ↓ ccc-runner reads spawn metadata, execs claude-code
└── claude-code (the actual spawn)
- workdir: ~/scratch/ccc/<spawn-id>/[<repo>/]
- MCP config: ~/.local/share/ccc/spawns/<id>/mcp.json
- initial CLAUDE.md: ~/scratch/ccc/<spawn-id>/[<repo>/]CLAUDE.md
Why systemd + screen
systemd handles lifecycle (start, stop, restart-on-crash, list-units,
journal). Screen handles the interactive session — the user can
screen -r claude-spawn-<id> to see raw terminal output, useful when
Discord coverage of the conversation is incomplete (errors, stderr,
raw tool output).
This split matches Fedora-native patterns and the existing flotilla.service / maxclaw-safety.service style of management already in use on the workstation.
Why a daemon (and not "just exec from the CLI")
Without a daemon, there's no shared state across terminals. You can't
ccc list from a different shell, ccc kill would have nothing to
target, the state DB has no central writer. Daemon owns the contract.
The daemon itself is small — it doesn't run claude-code, it templates
systemd units and writes state. If the daemon crashes, spawns keep
running (systemd owns them); restart of the daemon rebuilds in-memory
state from systemctl list-units + the state DB.
Filesystem isolation (workdir + worktree)
Two scenarios:
- Repo-bound task: "implement feature X in Flotilla-Core" wants a fresh worktree on a branch.
- Non-repo task: "investigate lab-hex-01 backup failures" doesn't tie to one repo; wants an empty workdir to clone or shell from.
Scheme:
- Default (no
--worktree): workdir is~/scratch/ccc/<spawn-id>/(fresh empty dir). Spawn can clone repos, shell out, write artifacts. --worktree <repo>[:branch]: daemon runsgit worktree add ~/scratch/ccc/<spawn-id>/<repo>/ <branch>from~/Projects/Networking/<repo>/. Default branch is the repo'smain. Workdir is the new worktree.--workdir <abs-path>(advanced, hidden-ish flag): override entirely. For "I want the spawn working in this specific existing dir." No isolation guarantees.
After spawn ends, workdir stays on disk for --older-than grace period
(default 7 days), then ccc gc reaps. Git worktrees are removed via
git worktree remove; non-worktree dirs via rm -rf.
Auth
Daemon binds a Unix socket at $XDG_RUNTIME_DIR/ccc.sock (mode 0600,
owner max). Workstation-local trust only — no network exposure. Same
trust boundary as the rest of the suite (Forgejo, knowledge-stack,
labctl, etc. all on LAN-only or localhost-only).
Pairing tokens / multi-user auth deferred until remote-spawn is on the table (not currently planned).
Lifecycle + state
State DB at ~/.local/share/ccc/state.db (sqlite). Schema:
CREATE TABLE spawns (
id TEXT PRIMARY KEY, -- e.g. "a3f2c1"
task TEXT NOT NULL,
workdir TEXT NOT NULL, -- absolute path
worktree_repo TEXT, -- null if no worktree
worktree_branch TEXT,
discord_channel_id TEXT NOT NULL,
discord_channel_name TEXT NOT NULL,
started_at INTEGER NOT NULL, -- unix epoch
ended_at INTEGER, -- null while active
exit_status TEXT -- 'killed' | 'completed' | null
);
Daemon writes on spawn / kill / completion (the latter via a wrapper
script invoked when claude-code exits). ccc list joins state DB
against systemctl list-units 'claude-spawn@*' for current state.
ccc gc reads spawns where ended_at < (now - 7d) and reaps the
workdir, leaving the DB row for history.
Discord channel mechanics
Categories (one-time manual setup)
You manually create two categories in the guild:
ccc-spawns— where active spawn channels liveccc-archive— where killed/completed spawn channels move
Both categories have permission overrides:
@everyone→ View Channel = DENY<your user>→ View Channel = ALLOW- bot user → View Channel = ALLOW, Manage Channels = ALLOW
Channels created under these categories inherit the deny-everyone perm
automatically. The bot only needs View Channels + Send Messages +
Manage Channels (no Manage Roles required).
Channel naming
{slug}-{short-id} where:
- slug = sanitized
--channel-nameif provided, else derived from--task(lowercase, kebab-case, truncated to 40 chars) - short-id = first 6 chars of the spawn-id (prevents collision when the same slug is reused)
Example: lab-hex-backup-investigation-a3f2c1
Lifecycle moves
- On
ccc spawn: create channel inccc-spawns - On
ccc killor natural completion: move channel toccc-archive, post a final message ("Spawn ended at , exit: ") - Channels are NEVER deleted automatically — conversation history matters. User manually cleans up archived channels when wanted.
Knowledge-stack integration (the payoff)
Spawns don't get pre-fetched search results injected (staleness risk —
docs in ~/Projects/Networking/Docs/ can lag behind code/config).
Instead, each spawn gets a small CLAUDE.md written to its workdir on
creation, advertising the tools and warning about staleness:
# Spawned via ccc — task context
**Task**: {one-line from --task}
## Available tools
- `lab-search "<query>"` — fuzzy search the knowledge base at
`~/Projects/Networking/Docs/`. Returns absolute paths + snippets +
relevance scores. Useful for prior research, lab notes, reference
configs. Flags: `--limit N`, `--prefix <path>`, `--source ideas`,
`--recent --since 7d`, `--json`.
- Web UI: `http://localhost:8081/`
## Important
- Docs in `~/Projects/Networking/Docs/` can be stale. **Verify against
current code / config / live state before relying on them.** Use
`lab-search --json` to see last-modified timestamps.
- Discord MCP is configured for #{channel-name}; route conversational
output there.
- If you produce new knowledge worth keeping, write it to
`~/Projects/Networking/Docs/<namespace>/<topic>.md` — the indexer
picks it up within 5 minutes.
The spawn's CLAUDE.md is loaded at session start via the standard Claude Code mechanism. The spawn knows the tools exist, knows to be skeptical of staleness, picks when to query.
--no-preseed flag skips this entirely (rare — for tasks that don't
benefit, e.g. pure compute work).
Repo layout (planned)
~/Projects/ccc-orchestrator/
├── README.md ← this file
├── go.mod / go.sum
├── cmd/
│ ├── ccc/ ← the CLI
│ │ └── main.go
│ ├── cccd/ ← the daemon
│ │ └── main.go
│ └── ccc-runner/ ← per-spawn wrapper invoked by systemd unit
│ └── main.go
├── internal/
│ ├── api/ ← Unix socket protocol (CLI ↔ daemon)
│ ├── discord/ ← Discord HTTP API client (channel create / move)
│ ├── spawn/ ← spawn lifecycle: state DB, systemd unit templating
│ └── worktree/ ← git worktree add/remove helpers
├── systemd/
│ └── claude-spawn@.service.template
└── docs/
└── (design docs as they arise)
Implementation language: Go. Matches Flotilla-Core, mactelnet-go, pve-lab, lab-search. Single binary per command. Strong typing for the state DB + Discord API surfaces.
Forgejo home: TBD — ccc-orchestrator is cross-cutting tooling,
not Networking-suite-specific, so the eventual git.home.widrick.net:2222/<org>/ccc-orchestrator.git
URL is parked until the org is named (could be a new Tools org, a
personal namespace, or the existing Networking org — undecided
2026-05-17).
Setup steps
-
Build (embed the commit sha so the CLI can warn on stale-cccd):
cd ~/Projects/ccc-orchestrator SHA=$(git rev-parse --short HEAD) go build -ldflags "-X main.buildSHA=$SHA" -o ~/bin/cccd ./cmd/cccd go build -ldflags "-X main.buildSHA=$SHA" -o ~/bin/ccc ./cmd/ccc go build -o ~/bin/ccc-runner ./cmd/ccc-runner -
Install the per-spawn systemd unit template + post-start helper:
cp systemd/claude-spawn@.service.template \ ~/.config/systemd/user/claude-spawn@.service systemctl --user daemon-reloadThe post-start helper (
~/bin/ccc-spawn-poststart) auto-dismisses the trust-this-folder, bypass-permissions, and dev-channels dialogs that claude shows when launched into a fresh workdir without a TTY operator. Already on disk from this repo. -
Discord side (one-time): create a
Programming(or whatever) category with permissions DENY @everyone, ALLOW you, ALLOW the bot (View Channel + Manage Channels). Set up a bot, enable the Message Content Intent in the Developer Portal (privileged intent, required for the dispatcher to see message text). -
Token + IDs on disk at
~/.config/ccc/discord.env(chmod 600):DISCORD_BOT_TOKEN=<token> DISCORD_GUILD_ID=<guild> DISCORD_CATEGORY_ID=<programming-category> -
Overlay the discord-spawn fork into the official-plugin cache (this is what makes claude's channels-allowlist gate accept the plugin — the gate matches on
discord@claude-plugins-official, the upstream identifier):CACHE=~/.claude/plugins/cache/claude-plugins-official/discord/0.0.4 cp $CACHE/server.ts $CACHE/server.ts.upstream-bak cp ~/Projects/discord-spawn/{server,transport,transport-direct,transport-dispatcher,wire-types}.ts $CACHE/The fork's
createTransport(env)picksDispatcherTransportwhenDISCORD_TRANSPORT=dispatcher(set per-spawn byccc-runner), and falls back toDirectTransportotherwise — so non-ccc claude sessions keep their existing direct-mode behaviour unchanged.After initial setup,
cccdwill detect drift between the source tree and the cache on every startup and warn. SetCCCD_AUTO_OVERLAY=1in the env to have it copy automatically:CCCD_AUTO_OVERLAY=1 CCCD_DISCORD_SPAWN_SRC=/home/max/Projects/discord-spawn # default CCCD_DISCORD_SPAWN_CACHE=~/.claude/plugins/cache/... # default -
cccditself: start under systemd (recommended) or in a shell:set -a; source ~/.config/ccc/discord.env; set +a ~/bin/cccd -
ccc spawn --task "smoke test"→ first end-to-end.
Gotchas
dmPolicy: "disabled"is a global kill switch in the upstream plugin gate, not a DM-only filter. A spawn'saccess.jsonmust usedmPolicy: "allowlist"with emptyallowFromto drop DMs while still allowing guild-group messages through.internal/spawnwrites the right value automatically; mentioning it because the upstream plugin's behavior is non-obvious from the field name alone.pkill -f bun.*cache/claude-plugins-official/discordmatches your own (host) claude session's discord plugin too. Use precise PIDs when cleaning up duplicate spawn plugins.- The
--dangerously-load-development-channelsflag is not a workable substitute for the cache-overlay — it bypasses the load-time check but the notification-dispatch gate still drops every channel message. The overlay is the only working path on claude-code 2.1.143.
What's not in scope (deferred)
- Remote spawn (orchestrate a spawn on a different host). Not needed for current single-workstation pattern. Would require pairing tokens, network auth, remote systemd, transport.
- Multi-user (multiple operators sharing one orchestrator). Same deferral reason.
- Spawn → spawn invocation (a running Claude Code instance calls
ccc spawnto fork sub-tasks). Theoretically possible since the CLI is just a Unix socket client; in practice deferred until a real use case shows up. - MCP wiki tool for spawns (vs the current Bash +
lab-searchapproach). Per the knowledge-stack-plan, this is deferred until BM25 search through Bash stops being good enough.
Open questions (to resolve during implementation, not blocking)
- Daemon log location:
journalctl --user-unit=cccd(free) vs dedicated~/.local/state/ccc/cccd.log(more structured). Lean journalctl-only for v1. - Spawn ID format: 6-char base32 from random bytes (
a3f2c1) vs human-readable timestamp (20260517-030400). Lean random; shorter. - Discord rate limits: bursts of
ccc spawncould hit channel-creation rate limits. Worry about it if it happens.