From 0623df6a116427ed40597156a31c37015c8ca25f Mon Sep 17 00:00:00 2001 From: Gunju Kim Date: Fri, 26 Jun 2026 10:10:49 +0900 Subject: [PATCH] Add per-session Agora thread command --- README.md | 18 ++++-- internal/agoracli/cli.go | 101 +++++++++++++++++++++++++++++++- internal/agoracli/cli_test.go | 55 +++++++++++++++++ skills/agora-reporting/SKILL.md | 19 ++++++ 4 files changed, 186 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a850df8..6fe68ac 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/internal/agoracli/cli.go b/internal/agoracli/cli.go index f676f66..aa8e7cb 100644 --- a/internal/agoracli/cli.go +++ b/internal/agoracli/cli.go @@ -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" @@ -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() @@ -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()) @@ -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") @@ -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 { @@ -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 { diff --git a/internal/agoracli/cli_test.go b/internal/agoracli/cli_test.go index 523ed84..8c3bd05 100644 --- a/internal/agoracli/cli_test.go +++ b/internal/agoracli/cli_test.go @@ -6,8 +6,10 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "regexp" "strings" "testing" + "time" "github.com/kelos-dev/agora/internal/agora" ) @@ -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 { diff --git a/skills/agora-reporting/SKILL.md b/skills/agora-reporting/SKILL.md index 5ac020d..6e514c1 100644 --- a/skills/agora-reporting/SKILL.md +++ b/skills/agora-reporting/SKILL.md @@ -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: @@ -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