No description
  • Shell 59.3%
  • HTML 20.8%
  • TypeScript 13.7%
  • Smarty 3.9%
  • Dockerfile 2.3%
Find a file
2026-06-03 20:04:54 -04:00
.forgejo/workflows feat: add web UI for key management and sync status 2026-04-13 15:41:29 -04:00
bin fix: batch config rsync into single call + show git commit output 2026-04-14 09:08:28 -04:00
chart chore(chart): replicated -> nfs-chonk-app (wave 2 LINSTOR shrink-down) 2026-06-03 20:04:54 -04:00
config Replace bare-metal server with Helm chart for Talos k8s + ArgoCD 2026-04-13 14:59:01 -04:00
scripts fix: resolve SCRIPT_DIR through symlinks so init finds config template 2026-04-14 07:33:39 -04:00
systemd init 2026-04-13 14:31:32 -04:00
webui feat: add web UI for key management and sync status 2026-04-13 15:41:29 -04:00
README.md Replace bare-metal server with Helm chart for Talos k8s + ArgoCD 2026-04-13 14:59:01 -04:00

cowork-canvas

A sync system that keeps Claude session state (Cowork, Code, Dispatch) consistent across multiple devices, using a Kubernetes-hosted server as the source of truth.

Architecture

┌──────────┐     ┌──────────┐     ┌──────────────────────────┐
│ Laptop   │     │ Desktop  │     │  Kubernetes (Talos)      │
│          │     │          │     │  ┌────────────────────┐  │
│ ~/.claude│     │ ~/.claude│     │  │ cowork-canvas pod  │  │
│ sessions │     │ sessions │     │  │  sshd + git + rsync│  │
│          │     │          │     │  │  /data/canvas (PVC)│  │
└────┬─────┘     └────┬─────┘     │  └────────────────────┘  │
     │                │           │  LoadBalancer :22         │
     └──── push/pull ─┴───────────┴──────────────────────────┘
             (every 5 min via systemd timer)

Two sync layers work together:

Git layer — syncs ~/.claude/ (config, history, commands, skills, todos, project metadata). The container hosts a bare git repo at /data/canvas/claude-config.git. Each client auto-commits and pushes/pulls on every sync cycle.

Rsync layer — syncs ~/.config/Claude/local-agent-mode-sessions/ (session audit logs, spaces, plugin state) and other large/binary data. Uses exclusion lists to skip browser caches, credentials, and device-specific state.

What gets synced

Data Method Location
Settings, history, commands git ~/.claude/
Custom skills & plugins config git ~/.claude/skills/, settings.json
Todos & project metadata git ~/.claude/todos/, projects/
Session audit logs rsync ~/.config/Claude/local-agent-mode-sessions/
Spaces & workspace state rsync ~/.config/Claude/local-agent-mode-sessions/
Desktop config rsync ~/.config/Claude/claude_desktop_config.json
Claude Code state rsync ~/.config/Claude/claude-code/

Excluded: browser caches, credentials/tokens, GPU caches, IndexedDB, VM state, lock files.

Server deployment (Kubernetes)

The server runs as a single pod with an SSH server, backed by a PVC for persistent storage. Deploy via ArgoCD or any GitOps tool.

Helm chart

chart/cowork-canvas/
├── Chart.yaml
├── values.yaml
├── templates/
│   ├── _helpers.tpl
│   ├── configmap.yaml        # sshd config, init/entrypoint/backup scripts
│   ├── deployment.yaml       # SSH server pod
│   ├── service.yaml          # LoadBalancer or NodePort
│   ├── pvc.yaml              # data + optional backup volumes
│   ├── cronjob.yaml          # daily backup tar
│   └── argocd-application.yaml
└── Dockerfile                # optional custom image build

Minimal values override

ssh:
  authorizedKeys: |
    ssh-ed25519 AAAA... laptop
    ssh-ed25519 AAAA... desktop
    ssh-ed25519 AAAA... workstation

persistence:
  data:
    storageClass: "your-drbd-class"
    size: 5Gi

service:
  type: LoadBalancer
  annotations:
    metallb.universe.tf/loadBalancerIPs: "10.0.0.50"

Using sealed secrets for SSH keys

Instead of putting authorized keys in values (which end up in a ConfigMap), reference an existing Secret:

ssh:
  existingAuthorizedKeysSecret: "cowork-canvas-ssh-keys"
  existingHostKeysSecret: "cowork-canvas-host-keys"

ArgoCD Application

The chart includes an optional ArgoCD Application template. For an app-of-apps pattern, set argocd.enabled: false and create the Application in your gitops repo:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: cowork-canvas
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://git.your-domain.com/your-org/cowork-canvas.git
    targetRevision: main
    path: chart/cowork-canvas
    helm:
      valueFiles:
        - values.yaml
        - values-production.yaml
  destination:
    server: https://kubernetes.default.svc
    namespace: cowork-canvas
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

What the init container does

On first boot (or whenever the PVC is empty), the init container:

  1. Creates the directory structure (sessions/, desktop-config/, claude-code/, etc.)
  2. Initializes a bare git repo at /data/canvas/claude-config.git
  3. Generates SSH host keys (persisted to PVC so they survive pod restarts)

If you provide host keys via a Secret (ssh.existingHostKeysSecret), the generated keys are ignored.

Client setup (each device)

Quick start

git clone <this-repo> ~/.local/share/cowork-canvas/repo
~/.local/share/cowork-canvas/repo/scripts/client-bootstrap.sh

The bootstrap script will:

  1. Symlink canvas-sync into ~/.local/bin
  2. Create ~/.config/cowork-canvas/canvas.conf
  3. Walk you through server connection details
  4. Optionally install + enable the systemd timer

Manual usage

canvas-sync status        # show sync state & connectivity
canvas-sync server-check  # verify the k8s server is ready
canvas-sync sync          # full pull + push
canvas-sync push          # push local → server
canvas-sync pull          # pull server → local
canvas-sync bootstrap     # clone all server state to a new device
canvas-sync log           # tail the sync log

Configuration

Config lives at ~/.config/cowork-canvas/canvas.conf:

CANVAS_SERVER="canvas@10.0.0.50"     # SSH target (LoadBalancer IP)
CANVAS_PORT="22"
CANVAS_SERVER_PATH="/data/canvas"     # path inside the container
CANVAS_DEVICE_NAME=""                 # auto from hostname
CANVAS_SYNC_INTERVAL="300"            # 5 minutes
CANVAS_SYNC_MODE="auto"               # auto | pull-only | push-only
CANVAS_CONFLICT_STRATEGY="server"     # server | local | newest

Systemd integration

The timer syncs every 5 minutes with a 30-second jitter to avoid thundering herd across devices:

systemctl --user enable --now canvas-sync.timer
systemctl --user list-timers canvas-sync.timer
journalctl --user -u canvas-sync.service -f

Conflict resolution

Git conflicts use the configured strategy (default: server wins). Rsync uses --delete-after so the last push wins for session data. The state directory records which device last pushed for auditability.

File structure

cowork-canvas/
├── bin/
│   └── canvas-sync              # main CLI tool
├── chart/
│   ├── Dockerfile               # custom SSH server image (optional)
│   └── cowork-canvas/           # Helm chart
│       ├── Chart.yaml
│       ├── values.yaml
│       └── templates/
├── config/
│   ├── canvas.conf.example      # client config template
│   ├── rsync-exclude.conf       # rsync exclusion rules
│   └── git-exclude.conf         # git exclusion rules
├── scripts/
│   └── client-bootstrap.sh      # per-device setup
├── systemd/
│   ├── canvas-sync.service      # oneshot sync unit
│   ├── canvas-sync.timer        # 5-min periodic timer
│   └── canvas-sync-watch.service # daemon mode (alternative)
└── README.md