Nests: team rooms with core crows and borrowing

- Server: nests API, persistence, core-first picking, borrowed flag
  (built by the backend agent against EVENTS.md; tests pass with -race)
- UI: nests in the sidebar, one feed per nest, header shows the core
  team, New nest dialog, borrowed crows labeled, per-nest waiting count
- start.command: one-click local server in controlled mode (mock crows,
  localhost only, state in .crows/)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF
This commit is contained in:
SmokeyandClaude Opus 5.5 committed 2026-10-27 13:45:04 -06:00
1 parent e9f20349a1
commit 2d85cab716
14 files changed
+779 -59

No files matched your search

+1
View File
@@ -5,3 +5,4 @@ node_modules/
dist/
/somanycrows
/web/
/dist/
+15
View File
@@ -38,6 +38,21 @@ curl localhost:8080/api/trust
- UI development: run `crows serve`, then `cd ui && npm run dev` (Vite proxies `/api`). The crow art is `art/crow.svg`; the UI's `CrowView` mirrors `RIVE_CONTRACT.md`, so a Rive-backed view replaces the SVG stand-in later.
- POST bodies are capped at 64 KB. Static files are served from `./web` when that folder exists.
## Nests
A nest is a room where a team of crows works together. Create one with its core crows; jobs in that nest use the core first and borrow from the rest of the flock when they need more (a murder of 3 in a 2-crow nest borrows one). The Sentry and Elder serve every nest.
```
curl localhost:8080/api/nests # {"roster": [...], "nests": [...]}
curl -XPOST localhost:8080/api/nests -d '{"name":"Launch copy","crows":["Pepper","Inky"]}' # 201, nest_id "launch-copy"
curl -XPOST localhost:8080/api/lists -d '{"text":"- [ ] Should we launch on a Tuesday","nest_id":"launch-copy"}'
./crows run todo.txt -nest launch-copy
```
- The default nest is `todo`, with an empty core: crows come from the whole flock and are never "borrowed".
- `crow.flying` carries `borrowed: true` for a crow from outside the nest's core.
- Names are 1–40 characters; `nest_id` is the name as a slug and must be unique (409 on a clash). 1–8 roster crows, no repeats, at most 20 nests. Nests are saved in `.crows/nests.json`.
## How an item flies
1. **Sentry** classifies it: `solo` (one crow), `split` (2–4 subtasks in parallel), or `murder` (3 crows answer independently).
+36 -11
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
@@ -83,23 +84,47 @@ type Flock struct {
sem chan struct{}
mu sync.Mutex
next int
next int // rotation through the whole flock (the todo nest)
core map[string]int // per nest: rotation within its core
borrow map[string]int // per nest: rotation through crows outside its core
}
func NewFlock(brain Brain, g Guardrails, log *FlightLog) *Flock {
return &Flock{brain: brain, g: g, log: log, sem: make(chan struct{}, g.MaxCrowsInFlight)}
return &Flock{
brain: brain, g: g, log: log, sem: make(chan struct{}, g.MaxCrowsInFlight),
core: map[string]int{}, borrow: map[string]int{},
}
}
// pick rotates through the roster so every crow gets a chance to build a record.
func (f *Flock) pick(n int) []string {
// pick chooses n crows for a job in a nest. A nest with no core (todo) rotates
// through the whole roster so every crow gets a chance to build a record.
// Otherwise core crows go first (rotating within the core), and any extra
// crows are borrowed from outside the core (rotating).
func (f *Flock) pick(nest Nest, n int) []string {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]string, n)
for i := range out {
out[i] = roster[f.next%len(roster)]
out := make([]string, 0, n)
if len(nest.Crows) == 0 {
for range n {
out = append(out, roster[f.next%len(roster)])
f.next++
}
return out
}
core := nest.Crows
for range min(n, len(core)) {
out = append(out, core[f.core[nest.ID]%len(core)])
f.core[nest.ID]++
}
outside := slices.DeleteFunc(slices.Clone(roster), func(c string) bool { return slices.Contains(core, c) })
if len(outside) == 0 {
outside = core // the whole flock is the core: nobody left to borrow
}
for len(out) < n {
out = append(out, outside[f.borrow[nest.ID]%len(outside)])
f.borrow[nest.ID]++
}
return out
}
// ask is the only way any crow touches the model: budget, flock cap, timeout, log.
@@ -169,11 +194,11 @@ func (f *Flock) Triage(ctx context.Context, w Watcher, item string, budgets ...*
return t, nil
}
func (f *Flock) Execute(ctx context.Context, w Watcher, item string, t Triage, budgets ...*Budget) (Outcome, error) {
func (f *Flock) Execute(ctx context.Context, w Watcher, item string, t Triage, nest Nest, budgets ...*Budget) (Outcome, error) {
switch t.Mode {
case "split":
// Split depth is 1: subtasks go straight to crows, never back to the Sentry.
crows := f.pick(len(t.Subtasks))
crows := f.pick(nest, len(t.Subtasks))
reports, err := f.fanOut(ctx, w, crows, t.Subtasks, budgets...)
if err != nil {
return Outcome{}, err
@@ -181,7 +206,7 @@ func (f *Flock) Execute(ctx context.Context, w Watcher, item string, t Triage, b
return f.reconcile(ctx, w, item, crows, t.Subtasks, reports, budgets...)
case "murder":
n := min(f.g.MurderSize, f.g.MaxCrowsInFlight)
crows := f.pick(n)
crows := f.pick(nest, n)
tasks := make([]string, n)
for i := range tasks {
tasks[i] = item
@@ -192,7 +217,7 @@ func (f *Flock) Execute(ctx context.Context, w Watcher, item string, t Triage, b
}
return f.reconcile(ctx, w, item, crows, tasks, reports, budgets...)
default:
c := f.pick(1)[0]
c := f.pick(nest, 1)[0]
ans, err := f.ask(ctx, w, c, crowSystem, "TASK: "+item, budgets...)
return Outcome{Answer: ans, Crows: []string{c}}, err
}
+10 -3
View File
@@ -36,7 +36,7 @@ func main() {
func usage() {
fmt.Fprintln(os.Stderr, `So Many Crows
crows run <todo.txt> [-as name] hand the flock a todo list
crows run <todo.txt> [-as name] [-nest id] hand the flock a todo list
crows serve [-addr 127.0.0.1:8080] run the web server (API + SSE)
crows trust show trust ledger
crows log [-n 20] show recent flight log entries`)
@@ -46,6 +46,7 @@ func usage() {
func cmdRun(args []string) {
fs := flag.NewFlagSet("run", flag.ExitOnError)
as := fs.String("as", defaultRequester(), "requester id")
nestID := fs.String("nest", defaultNestID, "nest whose core crows fly first")
var pos []string
for fs.Parse(args); fs.NArg() > 0; fs.Parse(args) {
pos = append(pos, fs.Arg(0))
@@ -62,6 +63,12 @@ func cmdRun(args []string) {
flog, err := OpenFlightLog(filepath.Join(dataDir, "flight.jsonl"))
must(err)
defer flog.Close()
nests, err := LoadNests(filepath.Join(dataDir, "nests.json"))
must(err)
nest, found := nests.Get(*nestID)
if !found {
must(fmt.Errorf("there is no nest called %q", *nestID))
}
p := &Pipeline{Ledger: ledger, Log: flog, G: g, Approver: &StdinApprover{in: bufio.NewReader(os.Stdin)}}
if ok, msg, _ := ledger.Admit(*as, time.Now(), g); !ok {
@@ -86,8 +93,8 @@ func cmdRun(args []string) {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
fmt.Printf("Brain: %s · requester: %s · %d items\n", brain.Name(), *as, len(items))
l := List{ID: "cli", Requester: *as, Source: todoPath, Items: items}
fmt.Printf("Brain: %s · requester: %s · nest: %s · %d items\n", brain.Name(), *as, nest.ID, len(items))
l := List{ID: "cli", Requester: *as, Source: todoPath, Items: items, Nest: nest}
results, listBudget := p.Run(ctx, l, &cliEmitter{items: items, seen: map[string]bool{}})
resultPath := strings.TrimSuffix(todoPath, filepath.Ext(todoPath)) + ".crows.md"
+170
View File
@@ -0,0 +1,170 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"unicode/utf8"
)
// A nest is a room where a team of crows works together. Its core crows are
// picked first; when a job needs more, the Sentry borrows from the rest of the
// flock. The Sentry and Elder serve every nest and are never core members.
type Nest struct {
ID string `json:"nest_id"`
Name string `json:"name"`
Crows []string `json:"crows"`
}
const (
defaultNestID = "todo"
maxNests = 20 // includes the default nest
maxNestName = 40
)
// defaultNest has an empty core: its crows come from the whole flock.
var defaultNest = Nest{ID: defaultNestID, Name: "To do", Crows: []string{}}
// NestError is a rejected nest. Clash means the nest_id is already taken (409);
// anything else is bad input or too many nests (400).
type NestError struct {
Clash bool
Msg string
}
func (e *NestError) Error() string { return e.Msg }
func invalid(format string, a ...any) error {
return &NestError{Msg: fmt.Sprintf(format, a...)}
}
// NestStore holds the nests and saves the ones people create to disk.
type NestStore struct {
path string
mu sync.Mutex
made []Nest // created nests, in creation order; the default nest is not stored
}
func LoadNests(path string) (*NestStore, error) {
s := &NestStore{path: path}
data, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
if err == nil {
if err := json.Unmarshal(data, &s.made); err != nil {
return nil, fmt.Errorf("nests file is unreadable: %w", err)
}
}
return s, nil
}
// All returns the default nest first, then the others in creation order.
func (s *NestStore) All() []Nest {
s.mu.Lock()
defer s.mu.Unlock()
out := []Nest{copyNest(defaultNest)}
for _, n := range s.made {
out = append(out, copyNest(n))
}
return out
}
// Get finds a nest by ID.
func (s *NestStore) Get(id string) (Nest, bool) {
if id == defaultNestID {
return copyNest(defaultNest), true
}
s.mu.Lock()
defer s.mu.Unlock()
for _, n := range s.made {
if n.ID == id {
return copyNest(n), true
}
}
return Nest{}, false
}
// Create checks the guardrails, adds the nest, and saves. A rejection is a
// *NestError; any other error means the save failed.
func (s *NestStore) Create(name string, crows []string) (Nest, error) {
name = strings.TrimSpace(name)
if n := utf8.RuneCountInString(name); n < 1 || n > maxNestName {
return Nest{}, invalid("A nest name is 1 to %d characters.", maxNestName)
}
id := slugify(name)
if id == "" {
return Nest{}, invalid("A nest name needs at least one letter or digit (a-z, 0-9).")
}
if len(crows) < 1 || len(crows) > len(roster) {
return Nest{}, invalid("A nest has 1 to %d crows.", len(roster))
}
for i, c := range crows {
if !slices.Contains(roster, c) {
return Nest{}, invalid("%q is not in the flock. Choose from %s.", c, strings.Join(roster, ", "))
}
if slices.Contains(crows[:i], c) {
return Nest{}, invalid("%s is listed twice.", c)
}
}
s.mu.Lock()
defer s.mu.Unlock()
if id == defaultNestID || slices.ContainsFunc(s.made, func(n Nest) bool { return n.ID == id }) {
return Nest{}, &NestError{Clash: true, Msg: fmt.Sprintf("A nest called %q already exists.", id)}
}
if 1+len(s.made) >= maxNests {
return Nest{}, invalid("The flock keeps at most %d nests.", maxNests)
}
n := Nest{ID: id, Name: name, Crows: slices.Clone(crows)}
s.made = append(s.made, n)
if err := s.save(); err != nil {
s.made = s.made[:len(s.made)-1]
return Nest{}, err
}
return copyNest(n), nil
}
// save writes atomically, like the ledger. The caller holds s.mu.
func (s *NestStore) save() error {
data, err := json.MarshalIndent(s.made, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func copyNest(n Nest) Nest {
n.Crows = append([]string{}, n.Crows...)
return n
}
// slugify lowercases and keeps a-z and 0-9; every other run of characters
// becomes one dash, and dashes at the ends are dropped.
func slugify(name string) string {
var sb strings.Builder
dash := false
for _, r := range strings.ToLower(name) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
if dash && sb.Len() > 0 {
sb.WriteByte('-')
}
sb.WriteRune(r)
dash = false
} else {
dash = true
}
}
return sb.String()
}
+214
View File
@@ -0,0 +1,214 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"slices"
"strings"
"testing"
"time"
)
func TestPickCoreFirstThenBorrow(t *testing.T) {
f := NewFlock(MockBrain{}, DefaultGuardrails(), nil)
nest := Nest{ID: "launch", Crows: []string{"Pepper", "Inky"}}
// Within the core, picks rotate.
if got := f.pick(nest, 1); !slices.Equal(got, []string{"Pepper"}) {
t.Fatalf("first solo pick: %v", got)
}
if got := f.pick(nest, 1); !slices.Equal(got, []string{"Inky"}) {
t.Fatalf("second solo pick should rotate within the core: %v", got)
}
// A murder of 3 needs one more than the core: the whole core flies, plus one borrowed.
got := f.pick(nest, 3)
if !slices.Contains(got, "Pepper") || !slices.Contains(got, "Inky") {
t.Fatalf("core crows should fly first: %v", got)
}
if got[2] != "Hugin" {
t.Fatalf("first borrowed crow should be the first roster crow outside the core: %v", got)
}
// Borrowing rotates too, and never picks a core crow.
if got := f.pick(nest, 3); got[2] != "Munin" {
t.Fatalf("borrowing should rotate: %v", got)
}
// The todo nest keeps the old whole-flock rotation, unaffected by other nests.
if got := f.pick(defaultNest, 3); !slices.Equal(got, roster[:3]) {
t.Fatalf("todo nest should rotate the whole roster: %v", got)
}
}
func TestPickWholeFlockCore(t *testing.T) {
f := NewFlock(MockBrain{}, DefaultGuardrails(), nil)
nest := Nest{ID: "all", Crows: slices.Clone(roster)}
got := f.pick(nest, 3)
if !slices.Equal(got, roster[:3]) {
t.Fatalf("got %v", got)
}
}
func TestNestValidation(t *testing.T) {
s, _ := LoadNests(filepath.Join(t.TempDir(), "nests.json"))
bad := []struct {
name string
crows []string
}{
{" ", []string{"Pepper"}},
{strings.Repeat("a", 41), []string{"Pepper"}},
{"!!!", []string{"Pepper"}},
{"No crows", nil},
{"Too many", append(slices.Clone(roster), "Hugin")},
{"Stranger", []string{"Pepper", "Tweety"}},
{"Sentry inside", []string{"Sentry"}},
{"Twice", []string{"Pepper", "Pepper"}},
}
for _, b := range bad {
_, err := s.Create(b.name, b.crows)
var ne *NestError
if err == nil || !errors.As(err, &ne) || ne.Clash {
t.Errorf("Create(%q, %v): want invalid, got %v", b.name, b.crows, err)
}
}
n, err := s.Create(" Launch -- Copy! ", []string{"Pepper", "Inky"})
if err != nil || n.ID != "launch-copy" || n.Name != "Launch -- Copy!" {
t.Fatalf("got %+v, %v", n, err)
}
if _, err := s.Create(strings.Repeat("a", 40), []string{"Jet"}); err != nil {
t.Fatalf("40 characters should be fine: %v", err)
}
for _, name := range []string{"launch copy", "TODO"} {
_, err := s.Create(name, []string{"Jet"})
var ne *NestError
if !errors.As(err, &ne) || !ne.Clash {
t.Errorf("Create(%q): want clash, got %v", name, err)
}
}
}
func TestNestsPersistAndCap(t *testing.T) {
path := filepath.Join(t.TempDir(), "nests.json")
s, _ := LoadNests(path)
for i := 1; i < maxNests; i++ { // todo is nest number one
if _, err := s.Create(fmt.Sprintf("Nest %d", i), []string{"Soot"}); err != nil {
t.Fatalf("nest %d: %v", i, err)
}
}
if _, err := s.Create("One too many", []string{"Soot"}); err == nil {
t.Fatal("want the nest cap to refuse")
}
reloaded, err := LoadNests(path)
if err != nil {
t.Fatal(err)
}
all := reloaded.All()
if len(all) != maxNests || all[0].ID != "todo" || all[1].ID != "nest-1" || all[maxNests-1].ID != "nest-19" {
t.Fatalf("reload lost order or nests: %d nests, first %v", len(all), all[:2])
}
if n, ok := reloaded.Get("nest-7"); !ok || !slices.Equal(n.Crows, []string{"Soot"}) {
t.Fatalf("Get after reload: %+v %v", n, ok)
}
}
func TestServerNests(t *testing.T) {
ts, _ := newTestServer(t, DefaultGuardrails(), time.Minute)
var got struct {
Roster []string `json:"roster"`
Nests []Nest `json:"nests"`
}
getJSON(t, ts.URL+"/api/nests", &got)
if !slices.Equal(got.Roster, roster) || len(got.Nests) != 1 || got.Nests[0].ID != "todo" || got.Nests[0].Crows == nil {
t.Fatalf("GET /api/nests: %+v", got)
}
code, out := post(t, ts.URL+"/api/nests", `{"name":"Launch copy","crows":["Pepper","Inky"]}`)
if code != http.StatusCreated || out["nest_id"] != "launch-copy" {
t.Fatalf("create: %d %v", code, out)
}
if code, out := post(t, ts.URL+"/api/nests", `{"name":"launch COPY","crows":["Jet"]}`); code != http.StatusConflict || out["message"] == nil {
t.Fatalf("clash: want 409 with message, got %d %v", code, out)
}
if code, out := post(t, ts.URL+"/api/nests", `{"name":"Bad","crows":["Elder"]}`); code != http.StatusBadRequest || out["message"] == nil {
t.Fatalf("bad crow: want 400 with message, got %d %v", code, out)
}
if code, _ := post(t, ts.URL+"/api/nests", `{"name":"x","crows":`+strings.Repeat(" ", maxBody)+`[]}`); code != http.StatusRequestEntityTooLarge {
t.Fatalf("want 413, got %d", code)
}
getJSON(t, ts.URL+"/api/nests", &got)
if len(got.Nests) != 2 || got.Nests[1].ID != "launch-copy" {
t.Fatalf("after create: %+v", got.Nests)
}
if code, out := post(t, ts.URL+"/api/lists", `{"text":"Write a poem","requester":"t","nest_id":"nope"}`); code != http.StatusNotFound || out["message"] == nil {
t.Fatalf("unknown nest: want 404 with message, got %d %v", code, out)
}
}
func TestServerBorrowedEvents(t *testing.T) {
ts, _ := newTestServer(t, DefaultGuardrails(), time.Minute)
post(t, ts.URL+"/api/nests", `{"name":"Launch copy","crows":["Pepper","Inky"]}`)
yes := true
// "should" makes the mock Sentry call a murder of 3: the 2-crow core plus one borrowed.
_, out := post(t, ts.URL+"/api/lists", `{"text":"Should we launch on a Tuesday","requester":"t","nest_id":"launch-copy"}`)
events, _ := stream(t, ts, out["list_id"].(string), &yes)
if events[0].Data["nest_id"] != "launch-copy" {
t.Fatalf("list.started nest_id: %v", events[0].Data)
}
borrowed := flights(t, events)
if !slices.Equal(borrowed["Pepper"], []bool{false}) || !slices.Equal(borrowed["Inky"], []bool{false}) ||
!slices.Equal(borrowed["Hugin"], []bool{true}) || !slices.Equal(borrowed["Sentry"], []bool{false}) ||
!slices.Equal(borrowed["Elder"], []bool{false}) {
t.Fatalf("borrowed flags: %v", borrowed)
}
// The todo nest (the default) never borrows.
_, out = post(t, ts.URL+"/api/lists", `{"text":"Should we launch on a Tuesday","requester":"t"}`)
events, _ = stream(t, ts, out["list_id"].(string), &yes)
if events[0].Data["nest_id"] != "todo" {
t.Fatalf("default nest_id: %v", events[0].Data)
}
for crow, flags := range flights(t, events) {
if slices.Contains(flags, true) {
t.Fatalf("%s borrowed in the todo nest", crow)
}
}
}
// flights maps each crow to the borrowed flag of each of its crow.flying events.
func flights(t *testing.T, events []sseEvent) map[string][]bool {
m := map[string][]bool{}
for _, e := range events {
if e.Name != "crow.flying" {
continue
}
b, ok := e.Data["borrowed"].(bool)
if !ok {
t.Fatalf("crow.flying without a borrowed bool: %v", e.Data)
}
c := e.Data["crow"].(string)
m[c] = append(m[c], b)
}
return m
}
func getJSON(t *testing.T, url string, v any) {
resp, err := http.Get(url)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s: %d", url, resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
t.Fatal(err)
}
}
+13 -4
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"slices"
"strings"
"sync"
)
@@ -30,6 +31,7 @@ type Approver interface {
type List struct {
ID, Requester, Source string
Items []string
Nest Nest // zero value means the todo nest
Parallel bool // fly items at once (web); the command line goes one by one
}
@@ -51,13 +53,16 @@ func itemID(listID string, i int) string { return fmt.Sprintf("%s-%d", listID, i
// Run flies a list and returns per-item results and the list budget.
func (p *Pipeline) Run(ctx context.Context, l List, em Emitter) ([]itemResult, *Budget) {
if l.Nest.ID == "" {
l.Nest = defaultNest
}
listBudget := &Budget{name: "list", limit: p.G.TokenBudgetPerList}
items := make([]map[string]any, len(l.Items))
for i, text := range l.Items {
items[i] = map[string]any{"item_id": itemID(l.ID, i), "text": text}
}
p.Log.Write(Event{Kind: "list", Requester: l.Requester, Note: fmt.Sprintf("%s (%d items)", l.Source, len(l.Items))})
em.Emit("list.started", map[string]any{"items": items, "requester": l.Requester})
em.Emit("list.started", map[string]any{"items": items, "requester": l.Requester, "nest_id": l.Nest.ID})
results := make([]itemResult, len(l.Items))
if l.Parallel {
@@ -89,7 +94,7 @@ func (p *Pipeline) item(ctx context.Context, l List, i int, listBudget *Budget,
em.Emit("item.skipped", map[string]any{"item_id": id, "reason": reason})
return r
}
w := &itemWatcher{em: em, itemID: id}
w := &itemWatcher{em: em, itemID: id, core: l.Nest.Crows}
itemBudget := &Budget{name: "item", limit: p.G.TokenBudgetPerItem}
t, err := p.Flock.Triage(ctx, w, item, itemBudget, listBudget)
@@ -115,7 +120,7 @@ func (p *Pipeline) item(ctx context.Context, l List, i int, listBudget *Budget,
em.Emit("verdict", map[string]any{"item_id": id, "target": "triage", "verdict": "auto"})
}
out, err := p.Flock.Execute(ctx, w, item, t, itemBudget, listBudget)
out, err := p.Flock.Execute(ctx, w, item, t, l.Nest, itemBudget, listBudget)
if err != nil {
return skip("flight failed: " + err.Error())
}
@@ -173,10 +178,14 @@ func (p *Pipeline) save() {
type itemWatcher struct {
em Emitter
itemID string
core []string // the nest's core crows; empty for todo
}
// Flying marks a crow borrowed when the nest has a core and the crow is a
// roster crow outside it. The Sentry and Elder are never borrowed.
func (w *itemWatcher) Flying(crow string) {
w.em.Emit("crow.flying", map[string]any{"item_id": w.itemID, "crow": crow})
borrowed := len(w.core) > 0 && slices.Contains(roster, crow) && !slices.Contains(w.core, crow)
w.em.Emit("crow.flying", map[string]any{"item_id": w.itemID, "crow": crow, "borrowed": borrowed})
}
func (w *itemWatcher) Landed(crow string, tokens int, err error) {
+44 -4
View File
@@ -31,12 +31,13 @@ type Server struct {
p *Pipeline
approver *WebApprover
hub *Hub
nests *NestStore
ctx context.Context // cancelled on shutdown; lists and streams end with it
beat time.Duration
}
func NewServer(ctx context.Context, p *Pipeline, approver *WebApprover) *Server {
return &Server{p: p, approver: approver, hub: NewHub(), ctx: ctx, beat: heartbeat}
func NewServer(ctx context.Context, p *Pipeline, approver *WebApprover, nests *NestStore) *Server {
return &Server{p: p, approver: approver, hub: NewHub(), nests: nests, ctx: ctx, beat: heartbeat}
}
func (s *Server) Handler() http.Handler {
@@ -45,6 +46,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/events", s.events)
mux.HandleFunc("POST /api/verdicts", s.postVerdict)
mux.HandleFunc("GET /api/trust", s.trust)
mux.HandleFunc("GET /api/nests", s.getNests)
mux.HandleFunc("POST /api/nests", s.postNest)
if st, err := os.Stat(webDir); err == nil && st.IsDir() {
mux.Handle("GET /", http.FileServer(http.Dir(webDir)))
} else {
@@ -67,13 +70,15 @@ func cmdServe(args []string) {
flog, err := OpenFlightLog(filepath.Join(dataDir, "flight.jsonl"))
must(err)
defer flog.Close()
nests, err := LoadNests(filepath.Join(dataDir, "nests.json"))
must(err)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
brain := NewBrain()
approver := NewWebApprover(*timeout)
p := &Pipeline{Flock: NewFlock(brain, g, flog), Ledger: ledger, Log: flog, G: g, Approver: approver}
s := NewServer(ctx, p, approver)
s := NewServer(ctx, p, approver, nests)
srv := &http.Server{Addr: *addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second}
go func() {
@@ -120,10 +125,19 @@ func (s *Server) postList(w http.ResponseWriter, r *http.Request) {
var body struct {
Text string `json:"text"`
Requester string `json:"requester"`
NestID string `json:"nest_id"`
}
if !decode(w, r, &body) {
return
}
if body.NestID == "" {
body.NestID = defaultNestID
}
nest, found := s.nests.Get(body.NestID)
if !found {
httpError(w, http.StatusNotFound, fmt.Sprintf("There is no nest called %q.", body.NestID))
return
}
if body.Requester == "" {
body.Requester, _, _ = net.SplitHostPort(r.RemoteAddr)
}
@@ -157,7 +171,7 @@ func (s *Server) postList(w http.ResponseWriter, r *http.Request) {
id := newListID()
s.hub.Open(id, body.Requester)
l := List{ID: id, Requester: body.Requester, Source: "web", Items: items, Parallel: true}
l := List{ID: id, Requester: body.Requester, Source: "web", Items: items, Nest: nest, Parallel: true}
go s.p.Run(s.ctx, l, s.hub.Emitter(id))
writeJSON(w, http.StatusAccepted, map[string]string{"list_id": id})
}
@@ -253,3 +267,29 @@ func (s *Server) trust(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
func (s *Server) getNests(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"roster": roster, "nests": s.nests.All()})
}
func (s *Server) postNest(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Crows []string `json:"crows"`
}
if !decode(w, r, &body) {
return
}
n, err := s.nests.Create(body.Name, body.Crows)
var bad *NestError
switch {
case errors.As(err, &bad) && bad.Clash:
httpError(w, http.StatusConflict, bad.Msg)
case errors.As(err, &bad):
httpError(w, http.StatusBadRequest, bad.Msg)
case err != nil:
httpError(w, http.StatusInternalServerError, "could not save the nest: "+err.Error())
default:
writeJSON(w, http.StatusCreated, n)
}
}
+5 -1
View File
@@ -93,10 +93,14 @@ func newTestServer(t *testing.T, g Guardrails, timeout time.Duration) (*httptest
t.Fatal(err)
}
t.Cleanup(func() { flog.Close() })
nests, err := LoadNests(filepath.Join(dir, "nests.json"))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
approver := NewWebApprover(timeout)
p := &Pipeline{Flock: NewFlock(MockBrain{}, g, flog), Ledger: ledger, Log: flog, G: g, Approver: approver}
s := NewServer(ctx, p, approver)
s := NewServer(ctx, p, approver, nests)
s.beat = 20 * time.Millisecond
ts := httptest.NewServer(s.Handler())
t.Cleanup(func() { cancel(); ts.Close() })
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# So Many Crows: local test server.
# Double-click to start. Close this window (or Ctrl-C) to stop.
#
# Controlled by default:
# - offline mock crows (no API key used, no cost, no network calls)
# - listens on this Mac only (127.0.0.1:8080)
# - all state lives in this folder's .crows/ (delete it to start fresh)
# From Terminal, `./start.command --real` uses real Claude crows (needs ANTHROPIC_API_KEY).
cd "$(dirname "$0")" || exit 1
xattr -d com.apple.quarantine ./crows 2>/dev/null
chmod +x ./crows
if [ "$1" != "--real" ]; then
unset ANTHROPIC_API_KEY
echo "Controlled mode: offline mock crows."
fi
if lsof -iTCP:8080 -sTCP:LISTEN >/dev/null 2>&1; then
echo "Something is already using port 8080. Stop it first."
exit 1
fi
(sleep 1; open "http://127.0.0.1:8080") &
exec ./crows serve -addr 127.0.0.1:8080
+22 -3
View File
@@ -17,6 +17,9 @@
</nav>
<aside class="side" aria-label="The flock">
<div class="brand">So Many Crows</div>
<h2>Nests</h2>
<nav class="nests" id="nests" aria-label="Nests"></nav>
<button class="new-nest" id="new-nest" type="button">+ New nest</button>
<h2>The flock</h2>
<div class="roster" id="roster"></div>
<label class="who">You are <input id="requester" maxlength="40" autocomplete="off" /></label>
@@ -24,11 +27,11 @@
<section class="room">
<header class="room-head">
<h1># todo</h1>
<p class="room-sub">Hand the flock a list. The Sentry sorts it, crows fly, you judge.</p>
<h1 id="room-title"># todo</h1>
<p class="room-sub" id="room-sub">The whole flock roosts here. Hand it a list: the Sentry sorts it, crows fly, you judge.</p>
</header>
<ol class="feed" id="feed" aria-live="polite"></ol>
<div class="feeds" id="feeds"></div>
<p class="typing" id="typing" aria-live="polite"></p>
<form class="composer" id="composer">
@@ -39,6 +42,22 @@
</form>
</section>
</div>
<dialog class="nest-dialog inked" id="nest-dialog">
<form method="dialog" id="nest-form">
<h2>New nest</h2>
<p class="dialog-sub">A room where a team of crows works together. The Sentry can borrow other crows when a job needs more hands.</p>
<label class="dialog-field">Name <input id="nest-name" maxlength="40" required autocomplete="off" placeholder="Launch copy" /></label>
<fieldset class="pick">
<legend>Core crows</legend>
<div class="pick-grid" id="nest-crows"></div>
</fieldset>
<p class="err" id="nest-err"></p>
<div class="dialog-actions">
<button class="btn btn-no inked" value="cancel" formnovalidate>Cancel</button>
<button class="btn btn-go inked" id="nest-create" value="create">Build the nest</button>
</div>
</form>
</dialog>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+34 -2
View File
@@ -5,11 +5,11 @@ export type PostListResult =
| { ok: false; pranked: true; message: string; cooldownS: number }
| { ok: false; pranked: false; message: string };
export async function postList(text: string, requester: string): Promise<PostListResult> {
export async function postList(text: string, requester: string, nestId: string): Promise<PostListResult> {
const res = await fetch("/api/lists", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, requester }),
body: JSON.stringify({ text, requester, nest_id: nestId }),
});
const body = await res.json().catch(() => ({}));
if (res.ok) return { ok: true, listId: String(body.list_id) };
@@ -43,3 +43,35 @@ export async function getTrust(): Promise<TrustLevels> {
export function listEvents(listId: string): EventSource {
return new EventSource(`/api/events?list_id=${encodeURIComponent(listId)}`);
}
// ---------- nests ----------
export type Nest = { nestId: string; name: string; crows: string[] };
export type Nests = { roster: string[]; nests: Nest[] };
const DEFAULT_ROSTER = ["Hugin", "Munin", "Corvina", "Soot", "Pepper", "Inky", "Edgar", "Jet"];
export async function getNests(): Promise<Nests> {
try {
const res = await fetch("/api/nests");
if (!res.ok) throw new Error(String(res.status));
const body = await res.json();
return {
roster: (body.roster ?? DEFAULT_ROSTER).map(String),
nests: (body.nests ?? []).map((n: any) => ({ nestId: String(n.nest_id), name: String(n.name), crows: (n.crows ?? []).map(String) })),
};
} catch {
// A server without nests still has the default room.
return { roster: DEFAULT_ROSTER, nests: [{ nestId: "todo", name: "todo", crows: [] }] };
}
}
export async function createNest(name: string, crows: string[]): Promise<Nest> {
const res = await fetch("/api/nests", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, crows }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(String(body.message ?? `Couldn't build that nest (${res.status}).`));
return { nestId: String(body.nest_id), name: String(body.name), crows: (body.crows ?? []).map(String) };
}
+150 -31
View File
@@ -1,11 +1,12 @@
import "./style.css";
import { getTrust, listEvents, postList, postVerdict, type TrustLevels } from "./api";
import { createNest, getNests, getTrust, listEvents, postList, postVerdict, type Nest, type TrustLevels } from "./api";
import { crowAvatar, svgCrow, type CrowView, type Role } from "./crow";
// ---------- page ----------
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const roster = $<HTMLDivElement>("roster");
const feed = $<HTMLOListElement>("feed");
const feedsBox = $<HTMLDivElement>("feeds");
const nestNav = $<HTMLElement>("nests");
const typing = $<HTMLParagraphElement>("typing");
const form = $<HTMLFormElement>("composer");
const listInput = $<HTMLTextAreaElement>("list");
@@ -110,16 +111,70 @@ function fmtTime(ts?: string) {
return isNaN(+d) ? "" : d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
function append(li: HTMLLIElement) {
// ---------- nests: one room (and one feed) per team ----------
type NestRoom = Nest & { feed: HTMLOListElement; link: HTMLButtonElement; count: HTMLSpanElement };
const nests = new Map<string, NestRoom>();
let flockNames: string[] = [];
let current = "todo";
const roomTitle = $<HTMLHeadingElement>("room-title");
const roomSub = $<HTMLParagraphElement>("room-sub");
const roomTab = $<HTMLButtonElement>("tab-room");
function addNest(n: Nest): NestRoom {
const feed = el("ol", "feed");
feed.setAttribute("aria-live", "polite");
feed.hidden = true;
feedsBox.append(feed);
const link = el("button", "nest-link");
link.type = "button";
const count = el("span", "nest-count");
link.append(el("span", "nest-name", `# ${n.name}`), count);
link.addEventListener("click", () => { selectNest(n.nestId); showView("room"); });
nestNav.append(link);
const room = { ...n, feed, link, count };
nests.set(n.nestId, room);
return room;
}
function nest(id: string): NestRoom {
return nests.get(id) ?? nests.get("todo")!;
}
function selectNest(id: string) {
current = nest(id).nestId;
for (const r of nests.values()) {
r.feed.hidden = r.nestId !== current;
r.link.setAttribute("aria-current", String(r.nestId === current));
}
const r = nest(current);
roomTitle.textContent = `# ${r.name}`;
roomSub.textContent = r.crows.length
? `Core crows: ${r.crows.join(", ")}. The Sentry borrows others when a job needs more hands.`
: "The whole flock roosts here. Hand it a list: the Sentry sorts it, crows fly, you judge.";
roomTab.textContent = `# ${r.name}`;
r.feed.scrollTop = r.feed.scrollHeight;
renderTyping();
}
// Red count on each nest: questions waiting on you in that room.
function renderNestCounts() {
for (const r of nests.values()) {
const n = r.feed.querySelectorAll(".msg.asking").length;
r.count.textContent = n ? String(n) : "";
}
}
function append(li: HTMLLIElement, nestId: string) {
const feed = nest(nestId).feed;
const stick = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120;
feed.append(li);
if (stick) feed.scrollTop = feed.scrollHeight;
}
function post(author: string, parts: Node[], ts?: string): Msg {
function post(author: string, parts: Node[], ts: string | undefined, nestId: string): Msg {
const li = el("li", "msg");
li.dataset.author = author;
const last = feed.lastElementChild as HTMLElement | null;
const last = nest(nestId).feed.lastElementChild as HTMLElement | null;
if (last?.dataset.author === author) li.classList.add("cont");
let avatar: Element;
if (author === "you") {
@@ -141,14 +196,14 @@ function post(author: string, parts: Node[], ts?: string): Msg {
const main = el("div", "msg-main");
main.append(head, body, actions, reactions);
li.append(avatar, main);
append(li);
append(li, nestId);
return { li, body, actions, reactions };
}
function note(text: string, kind = "") {
function note(text: string, nestId: string, kind = "") {
const li = el("li", `msg system ${kind}`.trim());
li.append(el("p", "", text));
append(li);
append(li, nestId);
}
function re(text: string) {
@@ -160,7 +215,7 @@ function react(m: Msg, text: string, kind: string) {
}
// ---------- per-item state ----------
type Item = { text: string; crows: string[]; taskType: string; triage?: Msg; answer?: Msg };
type Item = { text: string; nest: string; crows: string[]; borrowed: Set<string>; taskType: string; triage?: Msg; answer?: Msg };
const items = new Map<string, Item>();
function ask(m: Msg, itemId: string, target: "triage" | "output") {
@@ -183,12 +238,14 @@ function ask(m: Msg, itemId: string, target: "triage" | "output") {
});
}
m.actions.append(yes, no, err);
renderNestCounts();
}
// ---------- who's flying ----------
const flying = new Set<string>();
let flyingNest = "";
function renderTyping() {
const names = [...flying];
const names = flyingNest === current ? [...flying] : [];
if (names.length === 0) { typing.textContent = ""; return; }
const who = names.length === 1 ? names[0]
: names.length === 2 ? `${names[0]} and ${names[1]}`
@@ -203,12 +260,14 @@ const LEVELS = ["stranger", "regular", "trusted"];
const handlers: Record<string, (d: Payload) => void> = {
"list.started": (d) => {
const nestId = nests.has(d.nest_id) ? d.nest_id : sendingNest;
flyingNest = nestId;
const ol = el("ol", "list");
for (const it of d.items ?? []) {
items.set(it.item_id, { text: it.text, crows: [], taskType: "other" });
items.set(it.item_id, { text: it.text, nest: nestId, crows: [], borrowed: new Set(), taskType: "other" });
ol.append(el("li", "", it.text));
}
post("you", [ol], d.ts);
post("you", [ol], d.ts, nestId);
crow("Sentry").setPose("perched");
const n = (d.items ?? []).length;
update("Sentry", `Got a list of ${n} item${n === 1 ? "" : "s"}`, d.ts);
@@ -225,7 +284,7 @@ const handlers: Record<string, (d: Payload) => void> = {
for (const s of d.subtasks) ul.append(el("li", "", s));
parts.push(ul);
}
it.triage = post("Sentry", parts, d.ts);
it.triage = post("Sentry", parts, d.ts, it.nest);
showTrust("Sentry", d.task_type);
if (d.needs_approval) {
crow("Sentry").setPose("waiting");
@@ -242,8 +301,10 @@ const handlers: Record<string, (d: Payload) => void> = {
if (it && d.crow !== "Sentry") showTrust(d.crow, it.taskType);
crow(d.crow).setPose("flying");
if (it) {
if (d.borrowed) it.borrowed.add(d.crow);
const doing = d.crow === "Sentry" ? "Sorting" : d.crow === "Elder" ? "Merging reports on" : "Flying";
update(d.crow, `${doing}${doing === "Flying" ? ":" : ""} ${q(it.text)}`, d.ts);
const where = d.borrowed ? ` (borrowed for # ${nest(it.nest).name})` : "";
update(d.crow, `${doing}${where}${doing === "Flying" ? ":" : ""} ${q(it.text)}`, d.ts);
}
flying.add(d.crow);
renderTyping();
@@ -265,8 +326,9 @@ const handlers: Record<string, (d: Payload) => void> = {
const author = it.crows.includes("Elder") ? "Elder" : it.crows[0] ?? "Elder";
const parts: Node[] = [re(it.text), el("p", "answer", d.answer)];
const helpers = it.crows.filter((n) => n !== "Elder");
if (author === "Elder" && helpers.length) parts.push(el("p", "with", `from ${helpers.join(", ")}`));
it.answer = post(author, parts, d.ts);
const named = helpers.map((n) => (it.borrowed.has(n) ? `${n} (borrowed)` : n));
if (author === "Elder" && helpers.length) parts.push(el("p", "with", `from ${named.join(", ")}`));
it.answer = post(author, parts, d.ts, it.nest);
if (d.needs_approval) {
for (const n of it.crows) {
crow(n).setPose("waiting");
@@ -292,6 +354,7 @@ const handlers: Record<string, (d: Payload) => void> = {
};
const [text, kind] = label[d.verdict] ?? [d.verdict, ""];
react(m, text, kind);
renderNestCounts();
}
if (d.target === "triage" && d.verdict === "auto") return; // a trusted Sentry just keeps going
const word: Record<string, string> = { approved: "Approved", rejected: "Rejected", auto: "Accepted (trusted)", timeout: "Timed out" };
@@ -308,19 +371,20 @@ const handlers: Record<string, (d: Payload) => void> = {
const what = String(d.task_type).replace("triage/", "sorting ");
const up = d.to > d.from;
if (d.holder === "flock") {
note(`The flock ${up ? "earned" : "dropped to"} “${LEVELS[d.to]}” on ${what}.`, up ? "sparkle" : "");
note(`The flock ${up ? "earned" : "dropped to"} “${LEVELS[d.to]}” on ${what}.`, flyingNest || current, up ? "sparkle" : "");
return;
}
const c = crow(d.holder);
c.setTrust(d.to);
c.fire(up ? "levelUp" : "levelDown");
update(d.holder, `${up ? "Leveled up to" : "Dropped to"} ${LEVELS[d.to]} on ${what}`, d.ts, up ? "good" : "bad");
note(`${d.holder} ${up ? "leveled up to" : "dropped to"} “${LEVELS[d.to]}” on ${what}.`, up ? "sparkle" : "");
note(`${d.holder} ${up ? "leveled up to" : "dropped to"} “${LEVELS[d.to]}” on ${what}.`, flyingNest || current, up ? "sparkle" : "");
},
"item.skipped": (d) => {
const it = items.get(d.item_id);
note(`Skipped “${it?.text ?? d.item_id}”: ${d.reason}.`);
note(`Skipped “${it?.text ?? d.item_id}”: ${d.reason}.`, it?.nest ?? flyingNest ?? current);
renderNestCounts();
for (const n of it?.crows ?? []) crow(n).setPose("perched");
update("Sentry", `Skipped ${q(it?.text ?? d.item_id)}`, d.ts, "bad");
},
@@ -331,7 +395,8 @@ const handlers: Record<string, (d: Payload) => void> = {
for (const t of tiles.values()) { t.view.setPose("perched"); t.view.fire("cheer"); }
flying.clear();
renderTyping();
note(`List done. The flock used ${Number(d.tokens_used).toLocaleString()} of ${Number(d.token_budget).toLocaleString()} tokens.`);
note(`List done. The flock used ${Number(d.tokens_used).toLocaleString()} of ${Number(d.token_budget).toLocaleString()} tokens.`, flyingNest || current);
flyingNest = "";
source?.close();
source = null;
sendBtn.disabled = false;
@@ -343,7 +408,7 @@ let cooldownTimer = 0;
const sendLabel = sendBtn.textContent ?? "Send";
function prank(message: string, cooldownS: number) {
crow("Sentry").fire("prank");
post("Sentry", [el("p", "", message)]);
post("Sentry", [el("p", "", message)], undefined, current);
update("Sentry", "Left a gift on someone's windshield", undefined, "bad");
let left = Math.max(0, Math.round(cooldownS));
clearInterval(cooldownTimer);
@@ -362,6 +427,7 @@ function prank(message: string, cooldownS: number) {
}
// ---------- send a list ----------
let sendingNest = "todo";
form.addEventListener("submit", async (e) => {
e.preventDefault();
const text = listInput.value.trim();
@@ -369,13 +435,14 @@ form.addEventListener("submit", async (e) => {
const requester = requesterInput.value.trim() || "anonymous";
store.set("requester", requester);
sendBtn.disabled = true;
sendingNest = current;
const res = await postList(text, requester).catch(() => ({ ok: false as const, pranked: false as const, message: "Can't reach the flock. Is `crows serve` running?" }));
const res = await postList(text, requester, current).catch(() => ({ ok: false as const, pranked: false as const, message: "Can't reach the flock. Is `crows serve` running?" }));
if (!res.ok) {
if (res.pranked) {
prank(res.message, res.cooldownS); // re-enables the button when the cooldown ends
} else {
note(res.message);
note(res.message, current);
sendBtn.disabled = false;
}
return;
@@ -392,16 +459,68 @@ form.addEventListener("submit", async (e) => {
}
});
// Phone tabs: the channel or the flock.
// ---------- phone tabs: the room or the roost (nests + flock) ----------
const app = document.getElementById("app")!;
for (const [id, view] of [["tab-room", "room"], ["tab-flock", "flock"]] as const) {
document.getElementById(id)!.addEventListener("click", () => {
function showView(view: "room" | "flock") {
app.dataset.view = view;
for (const t of document.querySelectorAll<HTMLButtonElement>(".tab")) t.setAttribute("aria-pressed", String(t.id === id));
if (view === "room") feed.scrollTop = feed.scrollHeight;
});
for (const t of document.querySelectorAll<HTMLButtonElement>(".tab")) {
t.setAttribute("aria-pressed", String(t.id === (view === "room" ? "tab-room" : "tab-flock")));
}
if (view === "room") nest(current).feed.scrollTop = nest(current).feed.scrollHeight;
}
roomTab.addEventListener("click", () => showView("room"));
$<HTMLButtonElement>("tab-flock").addEventListener("click", () => showView("flock"));
// The Sentry is always on watch, and says hello.
// ---------- new nest dialog ----------
const dialog = $<HTMLDialogElement>("nest-dialog");
const nestForm = $<HTMLFormElement>("nest-form");
const nestName = $<HTMLInputElement>("nest-name");
const nestCrows = $<HTMLDivElement>("nest-crows");
const nestErr = $<HTMLParagraphElement>("nest-err");
$<HTMLButtonElement>("new-nest").addEventListener("click", () => {
nestErr.textContent = "";
nestName.value = "";
nestCrows.replaceChildren();
for (const name of flockNames) {
const label = el("label", "pick-crow");
const box = el("input");
box.type = "checkbox";
box.value = name;
label.append(box, crowAvatar("crow"), el("span", "", name));
nestCrows.append(label);
}
dialog.showModal();
nestName.focus();
});
nestForm.addEventListener("submit", async (e) => {
const submitter = (e as SubmitEvent).submitter as HTMLButtonElement | null;
if (submitter?.value !== "create") return; // Cancel closes the dialog
e.preventDefault();
const name = nestName.value.trim();
const crows = [...nestCrows.querySelectorAll<HTMLInputElement>("input:checked")].map((b) => b.value);
if (!name) { nestErr.textContent = "Give the nest a name."; return; }
if (!crows.length) { nestErr.textContent = "Pick at least one crow for the core team."; return; }
try {
const n = await createNest(name, crows);
addNest(n);
dialog.close();
selectNest(n.nestId);
showView("room");
post("Sentry", [el("p", "", `New nest. Core crows: ${n.crows.join(", ")}. I'll borrow others when a job needs more hands.`)], undefined, n.nestId);
} catch (err) {
nestErr.textContent = (err as Error).message;
}
});
// ---------- start: load the nests, open # todo, the Sentry says hello ----------
crow("Sentry");
post("Sentry", [el("p", "", "Caw. Send me a todo list, one item per line. I'll sort it, the flock will fly it, and you judge the results.")]);
(async () => {
const data = await getNests();
flockNames = data.roster;
if (!data.nests.some((n) => n.nestId === "todo")) data.nests.unshift({ nestId: "todo", name: "todo", crows: [] });
for (const n of data.nests) addNest(n);
selectNest("todo");
post("Sentry", [el("p", "", "Caw. Send me a todo list, one item per line. I'll sort it, the flock will fly it, and you judge the results.")], undefined, "todo");
})();
+43
View File
@@ -73,6 +73,47 @@ body { margin: 0; background: var(--paper); color: var(--ink); font: 500 17px/1.
}
.who input:focus { outline: none; border-bottom-width: 3px; }
/* ---------- nests: rooms, like channels ---------- */
.nests { display: flex; flex-direction: column; gap: 2px; }
.nest-link {
display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%;
padding: 8px 10px; border: 0; border-radius: var(--corner); background: none; cursor: pointer;
font: 400 17px/1.2 var(--display); color: var(--muted); text-align: left;
}
.nest-link[aria-current="true"] { color: var(--ink); background: rgb(28 25 21 / 0.06); }
.nest-link:focus-visible { outline: none; text-decoration: underline 2px; text-underline-offset: 4px; }
.nest-count:not(:empty) {
padding: 0 7px; border-radius: 999px; background: var(--red); color: var(--paper); font: 700 12px/1.4 var(--body);
}
.new-nest {
align-self: flex-start; margin: 2px 4px 6px; padding: 4px 6px; border: 0; background: none; cursor: pointer;
font: italic 500 15px/1.2 var(--body); color: var(--muted);
}
.new-nest:hover { color: var(--ink); }
.new-nest:focus-visible { outline: none; text-decoration: underline 2px; text-underline-offset: 4px; }
/* ---------- new nest dialog: a card on the page, no outline ---------- */
.nest-dialog { width: min(440px, calc(100vw - 32px)); padding: 0; color: var(--ink); overflow: visible; }
.nest-dialog::backdrop { background: rgb(28 25 21 / 0.25); }
.nest-dialog form { padding: 22px 22px 18px; }
.nest-dialog h2 { margin: 0; font: 400 24px/1.1 var(--display); }
.dialog-sub { margin: 6px 0 16px; color: var(--muted); font-style: italic; font-size: 15px; }
.dialog-field { display: block; color: var(--muted); font-style: italic; font-size: 15px; }
.dialog-field input {
display: block; width: 100%; margin-top: 4px; padding: 2px 0; font: 500 18px var(--body); color: var(--ink);
background: transparent; border: 0; border-bottom: 2px solid var(--ink);
}
.dialog-field input:focus { outline: none; border-bottom-width: 3px; }
.pick { margin: 18px 0 0; padding: 0; border: 0; }
.pick legend { padding: 0; color: var(--muted); font-style: italic; font-size: 15px; }
.pick-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; margin-top: 8px; }
.pick-crow { display: flex; align-items: center; gap: 10px; padding: 6px 8px; border-radius: var(--corner); cursor: pointer; font: 400 16px var(--display); }
.pick-crow:has(input:checked) { background: rgb(28 25 21 / 0.07); }
.pick-crow input { accent-color: var(--ink); width: 16px; height: 16px; margin: 0; }
.pick-crow .avatar { width: 30px; height: 28px; margin: 0; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 18px; }
.nest-dialog .err { margin: 10px 0 0; }
/* ---------- roster: one list tile per crow, newest update on top ---------- */
.roster { display: flex; flex-direction: column; gap: 6px; }
.tile {
@@ -146,7 +187,9 @@ body { margin: 0; background: var(--paper); color: var(--ink); font: 500 17px/1.
-webkit-mask-image: linear-gradient(rgb(0 0 0 / 0.55), transparent); mask-image: linear-gradient(rgb(0 0 0 / 0.55), transparent);
}
.feeds { display: grid; min-height: 0; }
.feed { list-style: none; margin: 0; padding: 20px 0 10px; overflow-y: auto; min-height: 0; }
.feed[hidden] { display: none; }
.msg { display: grid; grid-template-columns: 40px 1fr; gap: 0 14px; padding: 14px 28px; }
@media (hover: hover) { .msg:hover { background: rgb(28 25 21 / 0.03); } }
/* Consecutive messages from one author group under a single name, Slack style. */