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
| M | docs/ | 6 | +++--- |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 4 | ++-- |
| M | internal/ | 6 | +++--- |
| M | internal/ | 2 | +- |
| M | internal/ | 18 | ++++++++++-------- |
| M | internal/ | 26 | ++++++++++++++++++++++---- |
| M | internal/ | 25 | ++++++++++++++++++++++++- |
| M | internal/ | 12 | ++++++++---- |
| M | internal/ | 2 | +- |
| M | internal/ | 10 | +++++----- |
| M | internal/ | 6 | +++--- |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 4 | ++-- |
| M | internal/ | 8 | ++++---- |
| M | internal/ | 6 | +++--- |
| M | internal/ | 4 | ++-- |
| M | internal/ | 2 | +- |
@@ -133,7 +133,7 @@ palette) lists the others and goes to one's cell, and **File > Hand over
133133 writing** passes writing on in a one-writer room; neither has a key of134134 its own ([Sharing a workbook](../terminal/ssh.md#sharing-a-workbook)).135135 136-With an agent working in the session ([Live mode](../agents/live.md)):136+With agents working in the session ([Live mode](../agents/live.md)):137137 138138 | Key | Action |139139 |---|---|@@ -142,8 +142,8 @@ With an agent working in the session ([Live mode](../agents/live.md)):
142142 | Enter, A (in the panel) | Accept the suggestion, or the cell, highlighted; click ✓ |143143 | R, Del (in the panel) | Reject it; click ✗ |144144 | Shift+A, Shift+R (in the panel) | Accept or reject every suggestion waiting |145-| Enter, N, Esc (an agent's question) | Yes, no, cancel; a choice's number, or a value typed and Enter |146-| Enter, A, Esc (an agent's request) | Allow once, allow for the session, deny |145+| Enter, N, Esc (the agent's question) | Yes, no, cancel; a choice's number, or a value typed and Enter |146+| Enter, A, Esc (the agent's request) | Allow once, allow for the session, deny |147147 148148 ## Macros149149 @@ -9,7 +9,7 @@ import (
99 "strings"1010 )1111 12-// Questions: an agent asks the person something with the ask tool,12+// Questions: the agent asks the person something with the ask tool,1313 // shaped as an MCP elicitation request (a message, and a flat object1414 // schema of what to answer), and the person answers on the context line1515 // of their session, one field at a time. Elicitation goes from a server@@ -48,7 +48,7 @@ const (
4848 CellRejected4949 )5050 51-// Suggestion is an agent's change waiting for a person: the proposal,51+// Suggestion is one of the agents's change waiting for a person: the proposal,5252 // who made it and why, and what became of each of its cells.5353 type Suggestion struct {5454 ID int@@ -22,13 +22,13 @@ type person struct{ name string }
2222 func (p *person) Name() string { return p.name }2323 func (p *person) Notify() {}2424 25-// bot is an agent participant without a connection.25+// bot is one of the agents participant without a connection.2626 type bot struct{ person }2727 2828 func (*bot) Agent() {}2929 3030 // liveRoom is a room with ann in it, on a workbook of 1, 2, 3 in A1:A331-// and their sum in A4, and an agent attached.31+// and their sum in A4, and the agent attached.3232 func liveRoom(t *testing.T) (ann *room.Seat, agent *Agent) {3333 t.Helper()3434 g := room.NewRegistry(room.Edit)@@ -1,4 +1,4 @@
1-// Package cowork is live mode (docs/agents/live.md): an agent working in1+// Package cowork is live mode (docs/agents/live.md): the agent working in22 // a running 012 session, as one of the participants of the session's33 // room (package room). A session listens on a socket only its user can44 // reach (Listen); 012 mcp --attach connects to it (Attach) and carries@@ -22,7 +22,7 @@ import (
2222 "github.com/FelineStateMachine/012/internal/sheet"2323 )2424 25-// ScopeKind is how much of the workbook an agent may change.25+// ScopeKind is how much of the workbook the agent may change.2626 type ScopeKind int2727 2828 const (@@ -59,7 +59,7 @@ func (s Scope) String() string {
5959 return "the whole workbook"6060 }6161 62-// ErrReadOnly refuses any change of an agent invited read only.62+// ErrReadOnly refuses any change of the agent invited read only.6363 var ErrReadOnly = errors.New("you were invited read only: you can read the workbook, point at cells and ask the person, but not change anything; ask them to invite you again with a scope that lets you")6464 6565 // Allows reports whether the agent may point at r of sheet s.@@ -100,7 +100,7 @@ func alive(socket string) bool {
100100 return true101101 }102102 103-// Pick is the endpoint target names among eps, and the file an agent103+// Pick is the endpoint target names among eps, and the file the agent104104 // of 012 serve joins: "" when only one session listens, a workbook's105105 // name, a pid, a socket's path, or with one 012 serve listening, the106106 // file to join.@@ -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() })@@ -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)@@ -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) {@@ -57,12 +57,16 @@ func (s *Server) forgetRoots(_ context.Context, req *sdk.RootsListChangedRequest
5757 // serverOptions are the SDK's options for the server: its instructions,5858 // its MCP Apps extension, and forgetRoots.5959 func (s *Server) serverOptions() *sdk.ServerOptions {60- text := instructions60+ o := &sdk.ServerOptions{Instructions: instructions, RootsListChangedHandler: s.forgetRoots,61+ Capabilities: &sdk.ServerCapabilities{Extensions: map[string]any{uiExtension: map[string]any{}}}}6162 if s.opts.Live != nil {62- text = liveInstructions63+ // Clients subscribe to the suggestions resource to hear what64+ // became of theirs (SuggestionsChanged).65+ o.Instructions = liveInstructions66+ o.SubscribeHandler = func(context.Context, *sdk.SubscribeRequest) error { return nil }67+ o.UnsubscribeHandler = func(context.Context, *sdk.UnsubscribeRequest) error { return nil }6368 }64- return &sdk.ServerOptions{Instructions: text, RootsListChangedHandler: s.forgetRoots,65- Capabilities: &sdk.ServerCapabilities{Extensions: map[string]any{uiExtension: map[string]any{}}}}69+ return o6670 }6771 6872 // rootsOf are the folders of a client's file:// roots that exist.@@ -80,7 +80,7 @@ func (g *Registry) join(key string, p Participant, book func() *sheet.Workbook)
8080 }8181 8282 // JoinOpen seats p in the room with key if it's open, reporting false83-// when it isn't: an agent joins only a workbook someone has open.83+// when it isn't: the agent joins only a workbook someone has open.8484 func (g *Registry) JoinOpen(key string, p Participant) (*Seat, bool) {8585 s, _ := g.join(key, p, nil)8686 return s, s != nil@@ -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@@ -168,12 +168,12 @@ func TestRoomTurnsAtOnce(t *testing.T) {
168168 })169169 }170170 171-// bot is an agent participant.171+// bot is one of the agents participant.172172 type bot struct{ who }173173 174174 func (*bot) Agent() {}175175 176-// An agent is marked as one to the others, keeps nothing the room runs176+// The agent is marked as one to the others, keeps nothing the room runs177177 // even when it was there first, and a touch tells the others.178178 func TestRoomAgents(t *testing.T) {179179 g := NewRegistry(Edit)@@ -214,6 +214,6 @@ func TestRoomAgents(t *testing.T) {
214214 }215215 })216216 if _, ok := g.JoinOpen("k", &bot{who{name: "late"}}); ok {217- t.Error("an agent joined a closed room")217+ t.Error("the agent joined a closed room")218218 }219219 }@@ -21,7 +21,7 @@ func (s *Server) ListenForAgents(version string) (*cowork.Listener, error) {
2121 return cowork.Listen(cowork.Options{Registry: s.rooms, Room: s.agentRoom, Kind: "serve", Workbook: s.Dir(), Version: version})2222 }2323 24-// agentRoom is the room of the file an agent names, which someone must24+// agentRoom is the room of the file the agent names, which someone must2525 // have open.2626 func (s *Server) agentRoom(name string) (string, error) {2727 if name == "" {@@ -11,7 +11,7 @@ import (
1111 // Random edits by several participants: the edits of TestRandomEdits,1212 // each made by one of three authors drawn from the seed, interleaved in1313 // one workbook whose history is shared, as a room of 012 serve orders14-// them, and among them an agent's (live mode): its edits are proposals14+// them, and among them the agent's (live mode): its edits are proposals1515 // (Propose), accepted whole, cell by cell or not at all, as drawn from1616 // the seed. Then the authors, the agent among them, undo, in turns drawn from the seed, each only1717 // their own steps, from under the others' where nothing later overlaps@@ -8,7 +8,7 @@ import (
88 "github.com/FelineStateMachine/012/internal/cowork"99 )1010 11-// An agent's questions (the ask tool) and requests (to run notebook11+// The agent's questions (the ask tool) and requests (to run notebook1212 // cells, to enter JEV formulas directly) wait on the room's board until1313 // the person it asks is free: in READY, nothing open. Then the question1414 // takes the context line, a field at a time: a yes or no, a choice, or@@ -130,7 +130,7 @@ func (m *Model) askField(q *cowork.Ask, i int, content map[string]any) {
130130 }131131 }132132 133-// askGrant asks leave for what an agent wants to do beyond changing133+// askGrant asks leave for what the agent wants to do beyond changing134134 // cells, once or for the session.135135 func (m *Model) askGrant(q *cowork.Ask) {136136 answer := func(ans cowork.Answer) func(m *Model) tea.Cmd {@@ -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)@@ -17,14 +17,14 @@ import (
1717 "github.com/FelineStateMachine/012/internal/sheet"1818 )1919 20-// liveSession is a local session listening for agents, and an agent in20+// liveSession is a local session listening for agents, and the agent in2121 // its room, as 012 --listen and 012 mcp --attach make them.2222 type liveSession struct {2323 *seshion2424 agent *cowork.Agent2525 }2626 27-// testAgent is an agent in the room without a connection.27+// testAgent is one of the agents in the room without a connection.2828 type testAgent struct{ kick chan struct{} }2929 3030 func (a *testAgent) Name() string { return "claude" }@@ -37,7 +37,7 @@ func (a *testAgent) Notify() {
3737 }3838 3939 // startLive opens a session on a workbook of 40, 2 and their sum in40-// A1:A3, listening for agents, with an agent attached.40+// A1:A3, listening for agents, with the agent attached.4141 func startLive(t *testing.T) *liveSession {4242 t.Helper()4343 run, err := os.MkdirTemp("", "o12")@@ -87,7 +87,7 @@ func New(h Host) *Panel {
8787 8888 func (p *Panel) Indicator() string { return "REVIEW" }8989 90-// refresh lists the suggestions as they are now.90+// refresh lists the suggestions as the host has them.9191 func (p *Panel) refresh() {9292 p.items = p.h.Suggestions()9393 p.rows = p.rows[:0]@@ -199,7 +199,7 @@ func (p *Panel) line(th *theme.Theme, r row, inner int, sel bool) string {
199199 return theme.Cells(base, text, room) + chips200200 }201201 202-// agentMark is an agent's name with the mark agents have.202+// agentMark is one of the agents's name with the mark agents have.203203 func agentMark(name string) string { return "◆ " + name }204204 205205 // cellName is a cell's address, with its sheet when the suggestion sets@@ -183,7 +183,7 @@ type Theme struct {
183183 // its top-left corner in their color (PeerMark).184184 Peer [Peers]lipgloss.Style185185 PeerMark [Peers]lipgloss.Style186- // Suggested marks a cell an agent's suggestion would set, waiting186+ // Suggested marks a cell the agent's suggestion would set, waiting187187 // for the person to accept or reject it (live mode): a ◇ in the188188 // cell's top-left corner, so it reads without color.189189 Suggested lipgloss.Style