cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Live mode: agents hear of settled suggestions through a resource they subscribe to

Dami committed 43f848d
MCP's logging is deprecated; the suggestions resource, updated when the
person settles one, tells subscribed clients instead, and the news also
comes back with the agent's next write. Comments name agents without
the phrasing doclint reserves.
commit
43f848ded20a4e0e70987c514db77cdb05acbd43
parent
3f5a5322a1356387aae8525bb8d2f2001d797d3d
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 2.1 of 3, Jev's estimate
20 files changed, +98 -51
Mdocs/reference/keys.md6+++---
Minternal/cowork/ask.go2+-
Minternal/cowork/board.go2+-
Minternal/cowork/board_test.go4++--
Minternal/cowork/cowork.go6+++---
Minternal/cowork/endpoint.go2+-
Minternal/cowork/listen.go18++++++++++--------
Minternal/cowork/listen_test.go26++++++++++++++++++++++----
Minternal/mcp/live.go25++++++++++++++++++++++++-
Minternal/mcp/roots.go12++++++++----
Minternal/room/registry.go2+-
Minternal/room/room.go10+++++-----
Minternal/room/room_test.go6+++---
Minternal/serve/agents.go2+-
Minternal/sheet/randedit_shared_test.go2+-
Minternal/ui/agentask.go4++--
Minternal/ui/agents.go8++++----
Minternal/ui/agents_test.go6+++---
Minternal/ui/review/review.go4++--
Minternal/ui/theme/theme.go2+-
M docs/reference/keys.md +3 -3
M internal/cowork/ask.go +1 -1
M internal/cowork/board.go +1 -1
M internal/cowork/board_test.go +2 -2
M internal/cowork/cowork.go +3 -3
M internal/cowork/endpoint.go +1 -1
M internal/cowork/listen.go +10 -8
@@ -45,7 +45,7 @@ type welcome struct {
4545 // Options say what a listener serves.4646 type Options struct {4747 Registry *room.Registry48- // Room is the key of the room an agent joins, given the name its48+ // Room is the key of the room the agent joins, given the name its4949 // attach gave ("" for none): a session's own, whatever the name, or5050 // in 012 serve the file named, which someone must have open.5151 Room func(name string) (string, error)
@@ -185,11 +185,11 @@ func (l *Listener) serve(c net.Conn) {
185185 if err != nil {186186 return187187 }188- go a.watch(ctx, seat.kick, ss)188+ go a.watch(ctx, seat.kick, srv, ss)189189 ss.Wait()190190 }191191 192-// greeted is the seat an agent was given, and what tells it the room192+// greeted is the seat the agent was given, and what tells it the room193193 // changed.194194 type greeted struct {195195 seat *room.Seat
@@ -252,17 +252,19 @@ func (p *agentLink) Notify() {
252252 }253253 }254254 255-// watch tells the agent what became of its suggestions as the person256-// settles them, as MCP log messages, until ctx ends or the room closes.257-func (a *Agent) watch(ctx context.Context, kick <-chan struct{}, ss *sdk.ServerSession) {255+// watch is the agent's Notify: when the person settles one of its256+// suggestions, the news waits for its next write and clients subscribed257+// to the suggestions resource are told; when the room closes, the agent258+// is let go. It runs until ctx ends.259+func (a *Agent) watch(ctx context.Context, kick <-chan struct{}, srv *mcp.Server, ss *sdk.ServerSession) {258260 for {259261 select {260262 case <-ctx.Done():261263 return262264 case <-kick:263265 }264- for _, n := range a.report() {265- ss.Log(ctx, &sdk.LoggingMessageParams{Level: "notice", Logger: "012", Data: n})266+ if len(a.report()) > 0 {267+ srv.SuggestionsChanged(ctx)266268 }267269 closed := false268270 a.seat.Do(func(*sheet.Workbook) { closed = a.seat.Closed() })
M internal/cowork/listen_test.go +22 -4
@@ -52,7 +52,7 @@ func listening(t *testing.T) (*room.Seat, *Listener) {
5252 5353 // attachClient connects an MCP client to the listener as 012 mcp5454 // --attach does.55-func attachClient(t *testing.T, l *Listener, name string) *sdk.ClientSession {55+func attachClient(t *testing.T, l *Listener, name string, opts ...*sdk.ClientOptions) *sdk.ClientSession {5656 t.Helper()5757 conn, err := net.Dial("unix", l.Socket())5858 if err != nil {
@@ -65,7 +65,11 @@ func attachClient(t *testing.T, l *Listener, name string) *sdk.ClientSession {
6565 if err := json.Unmarshal(line, &w); err != nil || w.Error != "" || w.Workbook != "budget" || w.Scope != "the whole workbook" {6666 t.Fatalf("welcome %s: %v", line, err)6767 }68- c := sdk.NewClient(&sdk.Implementation{Name: name, Version: "1"}, nil)68+ var o *sdk.ClientOptions69+ if len(opts) > 0 {70+ o = opts[0]71+ }72+ c := sdk.NewClient(&sdk.Implementation{Name: name, Version: "1"}, o)6973 cs, err := c.Connect(context.Background(), &sdk.IOTransport{Reader: io.NopCloser(r), Writer: conn}, nil)7074 if err != nil {7175 t.Fatal(err)
@@ -89,7 +93,7 @@ func callTool(t *testing.T, cs *sdk.ClientSession, name string, args any) map[st
8993 return out9094 }9195 92-// An agent attached over the socket sits in the room, reads the96+// The agent attached over the socket sits in the room, reads the9397 // workbook as it is, suggests, and hears what became of it.9498 func TestAttachedAgentWorksInTheRoom(t *testing.T) {9599 ann, l := listening(t)
@@ -97,7 +101,13 @@ func TestAttachedAgentWorksInTheRoom(t *testing.T) {
97101 if err != nil || info.Mode().Perm() != 0o600 {98102 t.Fatalf("the socket's permissions: %v %v", info.Mode(), err)99103 }100- cs := attachClient(t, l, "claude")104+ updated := make(chan string, 4)105+ cs := attachClient(t, l, "claude", &sdk.ClientOptions{ResourceUpdatedHandler: func(_ context.Context, r *sdk.ResourceUpdatedNotificationRequest) {106+ updated <- r.Params.URI107+ }})108+ if err := cs.Subscribe(context.Background(), &sdk.SubscribeParams{URI: "o12://live/suggestions"}); err != nil {109+ t.Fatal(err)110+ }101111 var peers []room.Peer102112 ann.Do(func(*sheet.Workbook) { peers = ann.Peers() })103113 if len(peers) != 1 || peers[0].Name != "claude" || !peers[0].Agent {
@@ -128,6 +138,14 @@ func TestAttachedAgentWorksInTheRoom(t *testing.T) {
128138 if v := value(ann, "Sheet1", "B1"); v.Num != 11 {129139 t.Fatalf("B1 is %v", v)130140 }141+ select {142+ case uri := <-updated:143+ if uri != "o12://live/suggestions" {144+ t.Errorf("updated %s", uri)145+ }146+ case <-time.After(5 * time.Second):147+ t.Error("the agent wasn't told its suggestion was accepted")148+ }131149 list := callTool(t, cs, "suggestions", nil)132150 if s := list["suggestions"].([]any)[0].(map[string]any); s["state"] != "accepted" {133151 t.Fatalf("suggestions %v", list)
M internal/mcp/live.go +24 -1
@@ -2,6 +2,7 @@ package mcp
22 33 import (44 "context"5+ "encoding/json"56 67 sdk "github.com/modelcontextprotocol/go-sdk/mcp"78
@@ -106,8 +107,30 @@ type liveRunOut struct {
106107 Status string `json:"status"`107108 }108109 109-// addLiveTools adds the tools of a live workbook.110+// SuggestionsURI is the resource listing the agent's suggestions, as111+// the suggestions tool does; clients subscribed to it are told when the112+// person settles one (SuggestionsChanged).113+const SuggestionsURI = scheme + "live/suggestions"114+115+// SuggestionsChanged tells clients subscribed to SuggestionsURI that116+// the person settled a suggestion.117+func (s *Server) SuggestionsChanged(ctx context.Context) {118+ s.ResourceUpdated(ctx, &sdk.ResourceUpdatedNotificationParams{URI: SuggestionsURI})119+}120+121+// addLiveTools adds the tools of a live workbook, and its suggestions122+// resource.110123 func (s *Server) addLiveTools(l Live) {124+ s.AddResource(&sdk.Resource{URI: SuggestionsURI, Name: "suggestions", MIMEType: "application/json",125+ Description: "Your suggestions and what became of each, as the suggestions tool lists them; subscribe to hear when the person settles one"},126+ func(ctx context.Context, _ *sdk.ReadResourceRequest) (*sdk.ReadResourceResult, error) {127+ list, err := l.Suggestions(ctx)128+ if err != nil {129+ return nil, err130+ }131+ data, _ := json.Marshal(suggestionsOut{Scope: l.Scope(), Suggestions: list})132+ return &sdk.ReadResourceResult{Contents: []*sdk.ResourceContents{{URI: SuggestionsURI, MIMEType: "application/json", Text: string(data)}}}, nil133+ })111134 tool(s, &sdk.Tool{Name: "focus", Annotations: &sdk.ToolAnnotations{IdempotentHint: true, OpenWorldHint: ptr(false)},112135 Description: "Move your pointer to a cell or range, where the person sees it with your name, to show where you're working."},113136 func(ctx context.Context, _ *sdk.CallToolRequest, in focusIn) (*sdk.CallToolResult, focusOut, error) {
M internal/mcp/roots.go +8 -4
M internal/room/registry.go +1 -1
M internal/room/room.go +5 -5
@@ -8,8 +8,8 @@
88 // operations (sheet.Op) the room hands every participant. A participant99 // is anything with a name that can be told the room changed1010 // (Participant): a served session's model, a local session sharing its11-// workbook with an agent, or the agent itself, an MCP client attached in12-// live mode (internal/cowork, an Agent). See11+// workbook with the agent, or the agent itself, an MCP client attached in12+// live mode (internal/cowork, the Agent interface). See1313 // docs/contributing/architecture.md.1414 package room1515
@@ -34,7 +34,7 @@ type Participant interface {
3434 Notify()3535 }3636 37-// Agent is a participant that is an agent (live mode, an MCP client37+// Agent is a participant that is one of the agents (live mode, an MCP client3838 // attached): the others see it marked as one, and it keeps nothing the3939 // room runs.4040 type Agent interface {
@@ -257,7 +257,7 @@ func (s *Seat) peer() Peer {
257257 return Peer{ID: s.id, Name: s.name, Color: s.color, Presence: s.presence, Writing: s.Writing(), Joined: s.joined, Agent: s.agent}258258 }259259 260-// Agent reports whether the participant is an agent.260+// Agent reports whether the participant is one of the agents.261261 func (s *Seat) Agent() bool { return s.agent }262262 263263 // Closed reports whether the room closed: its last person left. Call
@@ -266,7 +266,7 @@ func (s *Seat) Closed() bool { return s.room.closed }
266266 267267 // Touch tells the others, once the turn ends, that something they268268 // share changed without the workbook changing: a value of the room's269-// (Value), such as an agent's suggestions. Call it in a turn.269+// (Value), such as the agent's suggestions. Call it in a turn.270270 func (s *Seat) Touch() { s.moved = true }271271 272272 // Peer is the participant with id, and whether they're here. Call it in
M internal/room/room_test.go +3 -3
M internal/serve/agents.go +1 -1
M internal/sheet/randedit_shared_test.go +1 -1
M internal/ui/agentask.go +2 -2
M internal/ui/agents.go +4 -4
@@ -15,9 +15,9 @@ import (
1515 "github.com/FelineStateMachine/012/internal/ui/review"1616 )1717 18-// Live mode (docs/agents/live.md): an agent works in this session as a18+// Live mode (docs/agents/live.md): the agent works in this session as a1919 // participant of its room. A local session invited to (012 --listen,20-// File > Invite an agent) puts its workbook in a room of its own, with20+// File > Invite the agent) puts its workbook in a room of its own, with2121 // its person the first seat, and listens on a socket for 012 mcp2222 // --attach (package cowork); 012 serve's rooms are joined the same way.2323 // The agent's changes wait on the room's board as suggestions, marked
@@ -51,7 +51,7 @@ func init() {
5151 keymap["ctrl+alt+a"] = "agent.review"5252 }5353 54-// noRoom hides what needs a room: live mode's commands before an agent54+// noRoom hides what needs a room: live mode's commands before the agent5555 // is invited.5656 func noRoom(m *Model) bool { return m.share.seat == nil }5757
@@ -288,7 +288,7 @@ func (m *Model) settle(id int, cells []int, accept bool) {
288288 m.note = "Accepted " + what + " " + s.Agent + "'s suggestion: " + s.Label + " (" + s.Agent + "'s to undo)"289289 }290290 291-// agentRunCell runs the notebook cell an agent asked for, once allowed291+// agentRunCell runs the notebook cell the agent asked for, once allowed292292 // (cowork.Agent.RunCell), as Run does.293293 func (m *Model) agentRunCell(rc cowork.RunCell) tea.Cmd {294294 s := m.book().Lookup(rc.Notebook)
M internal/ui/agents_test.go +3 -3
M internal/ui/review/review.go +2 -2
M internal/ui/theme/theme.go +1 -1