Workstation daemon that spawns Claude Code sessions into per-task Discord channels.
Find a file
Claude 8aea8215bc ccc tail <spawn-id>: print Discord channel transcript
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>
2026-05-17 12:48:38 -04:00
cmd ccc tail <spawn-id>: print Discord channel transcript 2026-05-17 12:48:38 -04:00
docs ccc-orchestrator v0.1 — spawn → Discord channel → claude session 2026-05-17 11:39:05 -04:00
internal ccc tail <spawn-id>: print Discord channel transcript 2026-05-17 12:48:38 -04:00
systemd systemd: install template for cccd as a user service 2026-05-17 12:44:21 -04:00
.gitignore ccc-orchestrator v0.1 — spawn → Discord channel → claude session 2026-05-17 11:39:05 -04:00
go.mod ccc-orchestrator v0.1 — spawn → Discord channel → claude session 2026-05-17 11:39:05 -04:00
go.sum ccc-orchestrator v0.1 — spawn → Discord channel → claude session 2026-05-17 11:39:05 -04:00
README.md ccc polish: version-mismatch warning + cccd plugin overlay auto-sync 2026-05-17 12:40:57 -04:00

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:

  1. Creating a Discord channel in the right private category
  2. Editing the Discord MCP config to point at the new channel
  3. Picking a working dir (worktree? scratch? current?)
  4. Launching claude-code with the right flags
  5. 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 session
  • list → daemon reads systemd --user unit list + state DB
  • kill → daemon stops the systemd unit (which kills screen which kills claude)
  • attach → CLI execs screen -r claude-spawn-<id> directly (no daemon round-trip)
  • logs → CLI execs journalctl --user-unit=claude-spawn@<id> -f
  • gc → daemon reads state DB, finds completed spawns older than threshold, rm -rf their 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 runs git worktree add ~/scratch/ccc/<spawn-id>/<repo>/ <branch> from ~/Projects/Networking/<repo>/. Default branch is the repo's main. 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 live
  • ccc-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-name if 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 in ccc-spawns
  • On ccc kill or natural completion: move channel to ccc-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: TBDccc-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

  1. 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
    
  2. 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-reload
    

    The 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.

  3. 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).

  4. Token + IDs on disk at ~/.config/ccc/discord.env (chmod 600):

    DISCORD_BOT_TOKEN=<token>
    DISCORD_GUILD_ID=<guild>
    DISCORD_CATEGORY_ID=<programming-category>
    
  5. 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) picks DispatcherTransport when DISCORD_TRANSPORT=dispatcher (set per-spawn by ccc-runner), and falls back to DirectTransport otherwise — so non-ccc claude sessions keep their existing direct-mode behaviour unchanged.

    After initial setup, cccd will detect drift between the source tree and the cache on every startup and warn. Set CCCD_AUTO_OVERLAY=1 in 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
    
  6. cccd itself: start under systemd (recommended) or in a shell:

    set -a; source ~/.config/ccc/discord.env; set +a
    ~/bin/cccd
    
  7. 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's access.json must use dmPolicy: "allowlist" with empty allowFrom to drop DMs while still allowing guild-group messages through. internal/spawn writes 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/discord matches your own (host) claude session's discord plugin too. Use precise PIDs when cleaning up duplicate spawn plugins.
  • The --dangerously-load-development-channels flag 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 spawn to 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-search approach). 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 spawn could hit channel-creation rate limits. Worry about it if it happens.