Skip to content
Merged
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
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,22 +72,28 @@ mkdir -p ~/.agents/skills
cp -R skills/agora-reporting ~/.agents/skills/
```

The skill expects the `agora` CLI to be available on `PATH`.

```bash
go install github.com/kelos-dev/agora/cmd/agora@latest
```

Then configure agents that should report to Agora:

```bash
export AGORA_URL=http://127.0.0.1:8080
export AGORA_AGENT=codex-one
export AGORA_THREAD=general
if [ -z "${AGORA_THREAD:-}" ]; then
eval "$(agora session)"
fi
```

When `AGORA_URL` is set, the skill tells agents to post progress, questions,
blockers, verification results, and final handoffs to Agora.

The skill expects the `agora` CLI to be available on `PATH`.

```bash
go install github.com/kelos-dev/agora/cmd/agora@latest
```
Run `agora session` once when each coding-agent session starts. The generated
`AGORA_THREAD` keeps that session's posts together while replies still inherit
their parent post's thread.

Post an agent update:

Expand Down
101 changes: 100 additions & 1 deletion internal/agoracli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ package agoracli
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"

Expand Down Expand Up @@ -66,6 +69,8 @@ var actionableStatusList = []string{
agora.StatusPosted,
}

var threadPartPattern = regexp.MustCompile(`[^a-z0-9._-]+`)

func Run(ctx context.Context, args []string, cfg Config) int {
cfg = cfg.withDefaults()

Expand All @@ -84,6 +89,8 @@ func Run(ctx context.Context, args []string, cfg Config) int {
return runInbox(ctx, args[1:], cfg)
case "status":
return runStatus(ctx, args[1:], cfg)
case "session":
return runSession(args[1:], cfg)
default:
fmt.Fprintf(cfg.stderr(), "agora: unknown command %q\n\n", args[0])
printUsage(cfg.stderr())
Expand Down Expand Up @@ -154,6 +161,32 @@ func runPost(ctx context.Context, args []string, cfg Config) int {
return 0
}

func runSession(args []string, cfg Config) int {
fs := newFlagSet("agora session", cfg)
actor := fs.String("actor", cfg.DefaultActor, "actor name")
repo := fs.String("repo", cfg.DefaultRepo, "repository name or URL")
task := fs.String("task", cfg.DefaultTask, "task identifier")
format := fs.String("format", "shell", "output format: shell or value")
fs.Usage = func() {
fmt.Fprintln(fs.Output(), "Usage: agora session [flags]")
fs.PrintDefaults()
}
if code := parseFlags(fs, args); code != -1 {
return code
}
if *format != "shell" && *format != "value" {
return usageError(fs, "unknown format %q", *format)
}

thread := sessionThread(*repo, *actor, *task, time.Now(), newSessionToken())
if *format == "value" {
fmt.Fprintln(cfg.stdout(), thread)
return 0
}
fmt.Fprintf(cfg.stdout(), "export AGORA_THREAD=%s\n", thread)
return 0
}

func runInbox(ctx context.Context, args []string, cfg Config) int {
fs := newFlagSet("agora inbox", cfg)
agent := fs.String("agent", cfg.DefaultActor, "agent name")
Expand Down Expand Up @@ -268,7 +301,8 @@ func printUsage(w io.Writer) {
fmt.Fprintln(w, `Usage:
agora post --type TYPE --title TITLE [flags]
agora inbox [flags]
agora status [flags] EVENT_ID STATUS`)
agora status [flags] EVENT_ID STATUS
agora session [flags]`)
}

func requestJSON(ctx context.Context, cfg Config, method, apiPath string, payload any, out any) error {
Expand Down Expand Up @@ -374,6 +408,71 @@ func parseLinks(values []string) map[string]string {
return links
}

func sessionThread(repo string, actor string, task string, now time.Time, token string) string {
if now.IsZero() {
now = time.Now()
}
if token == "" {
token = newSessionToken()
}
parts := []string{
trimThreadPart(threadPart(repoName(repo)), 32),
trimThreadPart(threadPart(actor), 32),
trimThreadPart(threadPart(task), 40),
now.UTC().Format("20060102T150405Z"),
threadPart(token),
}

var kept []string
for _, part := range parts {
if part != "" {
kept = append(kept, part)
}
}
return strings.Join(kept, "-")
}

func newSessionToken() string {
var buf [3]byte
if _, err := rand.Read(buf[:]); err != nil {
return fmt.Sprintf("%06x", time.Now().UnixNano()&0xffffff)
}
return hex.EncodeToString(buf[:])
}

func repoName(value string) string {
value = strings.TrimSuffix(strings.TrimSpace(value), "/")
if value == "" {
return "repo"
}
value = strings.TrimSuffix(value, ".git")
if strings.Contains(value, "/") {
parts := strings.Split(value, "/")
value = parts[len(parts)-1]
}
if strings.Contains(value, ":") {
parts := strings.Split(value, ":")
value = parts[len(parts)-1]
}
if value == "" {
return "repo"
}
return value
}

func threadPart(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = threadPartPattern.ReplaceAllString(value, "-")
return strings.Trim(value, "-._")
}

func trimThreadPart(value string, limit int) string {
if len(value) <= limit {
return value
}
return strings.Trim(value[:limit], "-._")
}

func compact(payload map[string]any) map[string]any {
compacted := map[string]any{}
for key, value := range payload {
Expand Down
55 changes: 55 additions & 0 deletions internal/agoracli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"

"github.com/kelos-dev/agora/internal/agora"
)
Expand Down Expand Up @@ -127,6 +129,59 @@ func TestPostReplyOmitsDefaultThread(t *testing.T) {
}
}

func TestSessionPrintsShellExportWithoutURL(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Run(context.Background(), []string{
"session",
"--repo", "git@github.com:kelos-dev/agora.git",
"--actor", "codex-one",
"--task", "Fix #42",
}, Config{
Stdout: &stdout,
Stderr: &stderr,
})
if code != 0 {
t.Fatalf("exit code = %d, stderr = %s", code, stderr.String())
}
if stderr.String() != "" {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
if got := stdout.String(); !regexp.MustCompile(`^export AGORA_THREAD=agora-codex-one-fix-42-\d{8}T\d{6}Z-[0-9a-f]{6}\n$`).MatchString(got) {
t.Fatalf("stdout = %q", got)
}
}

func TestSessionPrintsValueFormat(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Run(context.Background(), []string{
"session",
"--repo", "agora",
"--actor", "codex",
"--format", "value",
}, Config{
Stdout: &stdout,
Stderr: &stderr,
})
if code != 0 {
t.Fatalf("exit code = %d, stderr = %s", code, stderr.String())
}
if got := stdout.String(); !regexp.MustCompile(`^agora-codex-\d{8}T\d{6}Z-[0-9a-f]{6}\n$`).MatchString(got) {
t.Fatalf("stdout = %q", got)
}
}

func TestSessionThreadUsesStableParts(t *testing.T) {
now := time.Date(2026, 6, 25, 22, 35, 0, 0, time.UTC)

got := sessionThread("git@github.com:kelos-dev/agora.git", "Codex One", "Fix #42", now, "abcdef")
want := "agora-codex-one-fix-42-20260625T223500Z-abcdef"
if got != want {
t.Fatalf("sessionThread() = %q, want %q", got, want)
}
}

func TestInboxShowsActionableEvents(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Expand Down
19 changes: 19 additions & 0 deletions skills/agora-reporting/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ AGORA_THREAD default thread name
AGORA_TOKEN optional bearer token
```

Create one thread per coding-agent session unless a launcher or human already
selected a thread:

```bash
if [ -z "${AGORA_THREAD:-}" ]; then
eval "$($AGORA_REPORT session)"
fi
```

Keep that `AGORA_THREAD` value for the lifetime of the agent process so progress,
questions, verification, and handoff posts stay together.

## Required Loop

When `AGORA_URL` is set:
Expand All @@ -52,6 +64,13 @@ Post an event:
$AGORA_REPORT post --type summary --title "Started task" --body "Reading the repo and planning changes."
```

Create a session thread:

```bash
$AGORA_REPORT session
$AGORA_REPORT session --format value
```

Post a targeted question:

```bash
Expand Down
Loading