Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .opencode/skills/live-preview/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
name: live-preview
description: Expose a dev server running in this worktree via the Sprite's public URL
user-invocable: false
allowed-tools: Bash(socat *), Bash(kill *), Bash(lsof *), Bash(nohup *), Bash(npm *), Bash(pnpm *), Bash(yarn *), Bash(npx *), Bash(python *), Bash(mix *)
---

## Live Preview

When this project has a dev server (web frontend, API, etc.), you can expose it
to the user via the Sprite's public URL. The Sprite routes all HTTP traffic
arriving at port **8080** to `https://<sprite-name>-<org>.sprites.dev/`.

Your preview URL is available in `$SPRITE_PREVIEW_URL` (empty when running locally).

### How it works

Multiple worktrees share a single Sprite. Each runs its dev server on a
**different port**. A `socat` forwarder on port 8080 points to whichever
worktree the user wants to preview. You control which one is active.

### Step-by-step

1. **Start the dev server** on any free port (not 8080):

```bash
# Pick a deterministic port from the issue id to avoid collisions
PORT=$((30000 + ($RANDOM % 30000)))
# Example: Next.js
nohup npm run dev -- --port $PORT > /tmp/dev-server.log 2>&1 &
DEV_PID=$!
echo "Dev server PID=$DEV_PID on port $PORT"
```

Adapt the command to the project's framework (vite, next, mix phx.server, etc.).

2. **Wait for the server to be ready** before forwarding:

```bash
for i in $(seq 1 30); do
curl -s http://localhost:$PORT > /dev/null && break
sleep 1
done
```

3. **Forward port 8080 → your dev server port**:

```bash
# Kill any existing forwarder on 8080
lsof -ti :8080 | xargs -r kill -9 2>/dev/null || true
# Start socat forwarder
nohup socat TCP-LISTEN:8080,fork,reuseaddr TCP:localhost:$PORT > /tmp/socat.log 2>&1 &
SOCAT_PID=$!
echo "Forwarding 8080 → $PORT (socat PID=$SOCAT_PID)"
```

4. **Report the preview URL** so the user can open it:

```bash
echo "Preview: $SPRITE_PREVIEW_URL"
```

Also include the URL in your heartbeat messages so it shows in the dashboard:

```bash
curl -s -X POST "$SYNKADE_API_URL/heartbeat" \
-H "Authorization: Bearer $SYNKADE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"issue_id":"<issue_id>","status":"working","message":"Preview live at '$SPRITE_PREVIEW_URL'"}'
```

5. **Cleanup** when done — stop the forwarder and dev server:

```bash
kill $SOCAT_PID $DEV_PID 2>/dev/null || true
```

### Important notes

- **Only one worktree can own port 8080 at a time.** Your `lsof | kill` in step 3
takes over from whoever had it before. This is expected — the user requested
your branch.
- If `socat` is not installed, install it: `apt-get update && apt-get install -y socat`
- If `$SPRITE_PREVIEW_URL` is empty, you are running on the local backend —
just print `http://localhost:$PORT` instead.
- The Sprite URL requires authentication by default (org members + API tokens).
The user can make it public from the Synkade settings if needed.
93 changes: 93 additions & 0 deletions .opencode/skills/synkade/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
name: synkade
description: Git workflow, Synkade API, status reporting, and pull-based agent protocol
user-invocable: false
allowed-tools: Bash(git *), Bash(gh *), Bash(curl *)
---

## Git & Pull Requests

You have a `GITHUB_TOKEN` environment variable. After making changes, commit and open a PR:

```bash
git checkout -b fix/short-description
git add -A && git commit -m "Description of changes"
gh pr create --title "Short title" --body "Description"
```

Always create a PR with your changes so they can be reviewed.

## Synkade API

Environment variables: `$SYNKADE_API_URL`, `$SYNKADE_API_TOKEN`.

```bash
# List issues for this project
curl -s -H "Authorization: Bearer $SYNKADE_API_TOKEN" \
"$SYNKADE_API_URL/issues?project_id=aafde329-f689-4d47-9df9-15457b4a86f3"

# Create an issue
curl -s -X POST "$SYNKADE_API_URL/issues" \
-H "Authorization: Bearer $SYNKADE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"project_id":"aafde329-f689-4d47-9df9-15457b4a86f3","body":"# Title\n\nDetails"}'

# Update an issue
curl -s -X PATCH "$SYNKADE_API_URL/issues/<issue_id>" \
-H "Authorization: Bearer $SYNKADE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"state":"done"}'

# Read issue history
curl -s -H "Authorization: Bearer $SYNKADE_API_TOKEN" \
"$SYNKADE_API_URL/issues/<issue_id>"
```

## Status Reporting

Send heartbeats every 2-3 minutes during long tasks to prevent stall detection:

```bash
curl -s -X POST "$SYNKADE_API_URL/heartbeat" \
-H "Authorization: Bearer $SYNKADE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"issue_id":"<issue_id>","status":"working","message":"Brief status"}'
```

Valid statuses: `working`, `error`, `blocked`.

## Follow-Up Issues

When you discover out-of-scope work (bugs, tech debt, follow-ups), create issues rather than scope-creeping:

```bash
curl -s -X POST "$SYNKADE_API_URL/issues" \
-H "Authorization: Bearer $SYNKADE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"project_id":"aafde329-f689-4d47-9df9-15457b4a86f3","body":"# Issue Title\n\nDescription"}'
```

## Pull-Based Protocol

For persistent agents: discover and claim work between tasks.

```bash
# Who am I?
curl -s -H "Authorization: Bearer $SYNKADE_API_TOKEN" "$SYNKADE_API_URL/me"

# Find queued work assigned to me
curl -s -H "Authorization: Bearer $SYNKADE_API_TOKEN" \
"$SYNKADE_API_URL/issues?state=queued&assigned_to=me"

# Claim an issue (409 if already claimed)
curl -s -X POST -H "Authorization: Bearer $SYNKADE_API_TOKEN" \
"$SYNKADE_API_URL/issues/<issue_id>/checkout"

# Mark complete
curl -s -X PATCH "$SYNKADE_API_URL/issues/<issue_id>" \
-H "Authorization: Bearer $SYNKADE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"state":"awaiting_review","agent_output":"Summary of work"}'
```

Workflow: poll → checkout → heartbeat → complete.
40 changes: 31 additions & 9 deletions lib/synkade/issues.ex
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,31 @@ defmodule Synkade.Issues do
end

def get_issue!(id) do
Issue
|> Repo.get!(id)
|> Repo.preload(children: children_preload_query())
issue = get_issue(id) |> Repo.preload(children: children_preload_query())

unless issue do
raise Ecto.NoResultsError, queryable: Issue
end

issue
end

def get_issue(id) when is_binary(id) do
if String.length(id) == 36 and String.contains?(id, "-") do
Repo.get(Issue, id)
else
get_issue_by_short_id(id)
end
end

def get_issue(id) do
Repo.get(Issue, id)
def get_issue(_), do: nil

defp get_issue_by_short_id(short_id) when is_binary(short_id) do
short_id_pattern = "#{short_id}%"

Issue
|> where([i], like(i.id, ^short_id_pattern))
|> Repo.one()
end

def create_issue(attrs) do
Expand Down Expand Up @@ -259,7 +277,9 @@ defmodule Synkade.Issues do
def dispatch_issue(%Issue{} = issue, dispatch_message, assigned_agent_id \\ nil) do
{agent_name, agent_kind} =
case assigned_agent_id do
nil -> {nil, nil}
nil ->
{nil, nil}

id ->
try do
agent = Synkade.Settings.get_agent!(id)
Expand All @@ -269,7 +289,8 @@ defmodule Synkade.Issues do
end
end

messages = (issue.metadata["messages"] || [])
messages = issue.metadata["messages"] || []

new_entry = %{
"type" => "dispatch",
"agent_name" => agent_name,
Expand Down Expand Up @@ -313,7 +334,8 @@ defmodule Synkade.Issues do

@doc "Appends an agent output entry to the issue message history."
def append_agent_output(%Issue{} = issue, agent_output, agent_name \\ nil, agent_kind \\ nil) do
messages = (issue.metadata["messages"] || [])
messages = issue.metadata["messages"] || []

new_entry = %{
"type" => "agent",
"agent_name" => agent_name,
Expand Down Expand Up @@ -364,7 +386,7 @@ defmodule Synkade.Issues do

@doc "Cycles a recurring issue from done back to queued, appending a system message."
def cycle_recurring_issue(%Issue{state: "done", recurring: true} = issue) do
messages = (issue.metadata["messages"] || [])
messages = issue.metadata["messages"] || []

new_entry = %{
"type" => "system",
Expand Down
5 changes: 4 additions & 1 deletion lib/synkade_web/components/layouts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,11 @@ defmodule SynkadeWeb.Layouts do

<div class="divider my-1 px-2 before:bg-base-300 after:bg-base-300"></div>

<div class="px-3 mb-1">
<div class="group/projects-header px-3 mb-1 flex items-center justify-between">
<span class="ops-label text-primary/70">Projects</span>
<.link navigate="/projects" class="hidden group-hover/projects-header:inline-flex hover:text-primary" title="Add project">
<.icon name="hero-plus" class="size-3" />
</.link>
</div>
<div :if={map_size(@projects) == 0} class="px-3 text-base-content/30 text-xs">
No projects loaded
Expand Down
Loading