internal/cowork listens on a Unix socket only the user can reach and serves 012 mcp's tools on the room's workbook to an attached agent, which sits in the room as a participant marked an agent. Its changes are proposals on the room's board, checked against the scope the person gave, waiting to be accepted or rejected whole or by cell, or its own steps when direct edits are allowed. focus, undo, ask (an elicitation turned toward the person) and suggestions are live mode's tools; notebook runs and JEV formulas made directly ask the person first. Rooms close when their last person leaves, and agents keep nothing the room runs.
- commit
- 8115302f5531080b5826d903c0f3c51ba108b436
- parent
- 6f104fba135597a813c12aa7213e878c21227410
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 2.0 of 3, Jev's estimate
| A | internal/ | 363 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 284 | +++++++++++++++++++++++ |
| A | internal/ | 125 | ++++++++++ |
| A | internal/ | 250 | +++++++++++++++++++++ |
| A | internal/ | 337 | ++++++++++++++++++++++++++++ |
| A | internal/ | 109 | +++++++++ |
| A | internal/ | 158 | +++++++++++++ |
| A | internal/ | 274 | +++++++++++++++++++++++ |
| A | internal/ | 202 | +++++++++++++++++ |
| A | internal/ | 15 | + |
| A | internal/ | 13 | + |
| A | internal/ | 12 | + |
| A | internal/ | 56 | +++++ |
| M | internal/ | 13 | + |
| A | internal/ | 147 | ++++++++++++ |
| M | internal/ | 2 | +- |
| M | internal/ | 6 | +- |
| M | internal/ | 11 | +- |
| M | internal/ | 45 | +++- |
| M | internal/ | 22 | ++- |
| M | internal/ | 78 | +++++- |
| M | internal/ | 50 | ++++ |
@@ -0,0 +1,363 @@
1+package cowork2+3+import (4+ "bytes"5+ "context"6+ "errors"7+ "fmt"8+ "path/filepath"9+ "strings"10+ "sync"11+12+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"13+14+ "github.com/FelineStateMachine/012/internal/diff"15+ "github.com/FelineStateMachine/012/internal/headless"16+ "github.com/FelineStateMachine/012/internal/mcp"17+ "github.com/FelineStateMachine/012/internal/room"18+ "github.com/FelineStateMachine/012/internal/sheet"19+)20+21+// Agent is an attached agent's side of the room: its seat, and the22+// mcp.Live its tools stand on. Every call is a turn of its seat, so its23+// reads and changes take their place among the people's, and its steps24+// are its own.25+type Agent struct {26+ seat *room.Seat27+ book string // the workbook's name, for the tools28+29+ mu sync.Mutex30+ notices []string // news of its suggestions, for its next write31+}32+33+var _ mcp.Live = (*Agent)(nil)34+35+// NewAgent is the agent sitting at seat.36+func NewAgent(seat *room.Seat) *Agent {37+ a := &Agent{seat: seat}38+ seat.Do(func(*sheet.Workbook) {39+ name := seat.Saved().Name40+ a.book = strings.TrimSuffix(filepath.Base(name), filepath.Ext(name))41+ })42+ if a.book == "" || a.book == "." {43+ a.book = "Untitled"44+ }45+ return a46+}47+48+// Name is the workbook's.49+func (a *Agent) Name() string { return a.book }50+51+// Seat is the agent's seat.52+func (a *Agent) Seat() *room.Seat { return a.seat }53+54+// View runs fn on the room's workbook in a turn.55+func (a *Agent) View(_ context.Context, fn func(*sheet.Workbook) error) error {56+ var err error57+ a.seat.Do(func(w *sheet.Workbook) { err = fn(w) })58+ return err59+}60+61+// Scratch runs fn on a copy of the workbook taken in a turn.62+func (a *Agent) Scratch(_ context.Context, fn func(*sheet.Workbook) error) error {63+ var data bytes.Buffer64+ var err error65+ a.seat.Do(func(w *sheet.Workbook) { err = w.Write(&data) })66+ if err != nil {67+ return err68+ }69+ s, err := sheet.Read(&data)70+ if err != nil {71+ return err72+ }73+ return fn(s.Book())74+}75+76+// Change is Make without a message.77+func (a *Agent) Change(ctx context.Context, label string, dryRun bool, fn func(*sheet.Workbook) error) ([]diff.Change, error) {78+ made, err := a.Make(ctx, mcp.Making{Label: label, DryRun: dryRun, Fn: fn})79+ return made.Changes, err80+}81+82+// Make proposes the change on a copy of the workbook, checks it against83+// the scope, and then suggests it, or with direct edits allowed makes84+// it as the agent's step; a formula asking JEV made directly asks the85+// person first.86+func (a *Agent) Make(ctx context.Context, mk mcp.Making) (mcp.Made, error) {87+ var made mcp.Made88+ var p *sheet.Proposal89+ var err error90+ needJEV := false91+ a.seat.Do(func(w *sheet.Workbook) {92+ b := BoardOf(a.seat)93+ if !a.seat.Writing() {94+ err = fmt.Errorf("%s writes here and the others follow: nobody else can change the workbook", a.seat.Writer())95+ return96+ }97+ if p, err = sheet.Propose(w, mk.Label, mk.Fn); err != nil || p.Empty() {98+ return99+ }100+ if err = b.Scope.Check(p); err != nil {101+ return102+ }103+ if made.Changes, err = changesOf(w, p.Result()); err != nil || mk.DryRun {104+ return105+ }106+ switch {107+ case !b.Direct:108+ made.Suggestion = b.Add(&Suggestion{Author: a.seat.ID(), Agent: a.seat.Name(), Color: a.seat.Color(), Message: mk.Message, Proposal: p})109+ a.seat.Touch()110+ case p.Remote && !b.Grants.JEV:111+ needJEV = true112+ return113+ default:114+ err = p.Apply(w, nil)115+ made.Applied = err == nil116+ }117+ a.point(w, p)118+ })119+ if needJEV {120+ err = a.grant(ctx, "jev", a.seat.Name()+" enters formulas that ask JEV, over the network, with your key")121+ if err == nil {122+ a.seat.Do(func(w *sheet.Workbook) {123+ err = p.Apply(w, nil)124+ made.Applied = err == nil125+ a.point(w, p)126+ })127+ }128+ }129+ made.Notices = a.takeNotices()130+ return made, err131+}132+133+// changesOf is what the copy changes of w, as 012 diff lists it.134+func changesOf(w, copy *sheet.Workbook) ([]diff.Change, error) {135+ var before, after bytes.Buffer136+ if err := w.Write(&before); err != nil {137+ return nil, err138+ }139+ if err := copy.Write(&after); err != nil {140+ return nil, err141+ }142+ x, err := diff.Read(before.Bytes())143+ if err != nil {144+ return nil, err145+ }146+ y, err := diff.Read(after.Bytes())147+ if err != nil {148+ return nil, err149+ }150+ return diff.Compare(x, y), nil151+}152+153+// point moves the agent's pointer to the cells a proposal sets.154+func (a *Agent) point(w *sheet.Workbook, p *sheet.Proposal) {155+ if len(p.Cells) == 0 {156+ return157+ }158+ first := p.Cells[0]159+ s := w.Lookup(first.Sheet)160+ if s == nil {161+ return162+ }163+ r := sheet.Rect{From: first.At, To: first.At}164+ for _, c := range p.Cells {165+ if c.Sheet == first.Sheet {166+ r.From.Row, r.From.Col = min(r.From.Row, c.At.Row), min(r.From.Col, c.At.Col)167+ r.To.Row, r.To.Col = max(r.To.Row, c.At.Row), max(r.To.Col, c.At.Col)168+ }169+ }170+ a.seat.Move(room.Presence{Sheet: s, Cursor: first.At, Selection: r})171+}172+173+// Focus moves the agent's pointer to ref.174+func (a *Agent) Focus(_ context.Context, ref string) (string, error) {175+ var at string176+ var err error177+ a.seat.Do(func(w *sheet.Workbook) {178+ var t headless.Target179+ if t, err = headless.Resolve(w, ref); err != nil {180+ return181+ }182+ scope := BoardOf(a.seat).Scope183+ if !scope.Allows(t.Sheet, t.Range) {184+ err = fmt.Errorf("%s is outside what the person let you work on (%s)", ref, scope)185+ return186+ }187+ a.seat.Move(room.Presence{Sheet: t.Sheet, Cursor: t.Range.From, Selection: t.Range})188+ at = sheet.Qualified(t.Sheet.Name(), t.Range)189+ })190+ return at, err191+}192+193+// Undo takes back the agent's latest step.194+func (a *Agent) Undo(context.Context) (string, error) {195+ var label string196+ var err error197+ a.seat.Do(func(w *sheet.Workbook) {198+ switch b, blocked := w.UndoBlocked(); {199+ case !w.CanUndo():200+ err = errors.New("you have no change of yours to undo: a suggestion is one once the person accepts it; the suggestions tool lists them")201+ case blocked:202+ who := "someone who left"203+ if p, ok := a.seat.Peer(b.Author); ok {204+ who = p.Name205+ }206+ err = fmt.Errorf("%s has changed what your latest change (%s) changed since: undo takes back only your own changes, never over theirs", who, w.UndoLabel())207+ default:208+ label = w.UndoLabel()209+ w.Undo()210+ }211+ })212+ return label, err213+}214+215+// Ask puts the question on the board and waits for the person.216+func (a *Agent) Ask(ctx context.Context, p *sdk.ElicitParams) (*sdk.ElicitResult, error) {217+ q, err := NewAsk(p.Message, p.RequestedSchema)218+ if err != nil {219+ return nil, err220+ }221+ ans, err := a.wait(ctx, q)222+ if err != nil {223+ return nil, err224+ }225+ return &sdk.ElicitResult{Action: ans.Action, Content: ans.Content}, nil226+}227+228+// grant asks leave for what, unless the person gave it for the session.229+func (a *Agent) grant(ctx context.Context, what, message string) error {230+ ans, err := a.wait(ctx, NewGrant(what, message))231+ if err != nil {232+ return err233+ }234+ if ans.Action != "accept" {235+ return errors.New("the person didn't allow it")236+ }237+ return nil238+}239+240+// wait asks q and waits for the answer, withdrawing the question when241+// ctx ends first.242+func (a *Agent) wait(ctx context.Context, q *Ask) (Answer, error) {243+ q.Author, q.Agent, q.Color = a.seat.ID(), a.seat.Name(), a.seat.Color()244+ a.seat.Do(func(*sheet.Workbook) {245+ BoardOf(a.seat).AddAsk(q)246+ a.seat.Touch()247+ })248+ select {249+ case ans := <-q.Reply():250+ return ans, nil251+ case <-ctx.Done():252+ a.seat.Do(func(*sheet.Workbook) {253+ BoardOf(a.seat).Withdraw(q)254+ a.seat.Touch()255+ })256+ return Answer{}, ctx.Err()257+ }258+}259+260+// Suggestions lists the agent's suggestions.261+func (a *Agent) Suggestions(context.Context) ([]mcp.Suggestion, error) {262+ var out []mcp.Suggestion263+ a.seat.Do(func(*sheet.Workbook) {264+ for _, s := range BoardOf(a.seat).Of(a.seat.ID()) {265+ n := len(s.Cells)266+ acc, rej := s.Count(CellAccepted), s.Count(CellRejected)267+ if s.Whole() {268+ acc, rej = acc*n, rej*n269+ }270+ out = append(out, mcp.Suggestion{ID: s.ID, Label: s.Label, Message: s.Message, State: s.Outcome(), Cells: n, Accepted: acc, Rejected: rej})271+ }272+ })273+ return out, nil274+}275+276+// Scope is the board's scope in words.277+func (a *Agent) Scope() string {278+ var s string279+ a.seat.Do(func(*sheet.Workbook) { s = BoardOf(a.seat).Scope.String() })280+ return s281+}282+283+// RunCell is a request for the keeper's session to run a notebook cell,284+// which it takes from the room (room.Seat.Post).285+type RunCell struct {286+ Notebook string287+ Cell int // from 0288+ Agent string289+}290+291+// RunCell asks leave to run a notebook cell, unless the person gave it292+// for the session, and has the room's keeper run it.293+func (a *Agent) RunCell(ctx context.Context, notebook string, cell int) (string, error) {294+ var err error295+ granted := false296+ a.seat.Do(func(w *sheet.Workbook) {297+ b := BoardOf(a.seat)298+ granted = b.Grants.Notebooks299+ if b.Scope.Kind != Workbook {300+ err = fmt.Errorf("running a notebook cell changes its outputs and the sheets they're sent to, outside what the person let you change (%s)", b.Scope)301+ return302+ }303+ s := w.Lookup(notebook)304+ switch {305+ case s == nil || !s.IsNotebook():306+ err = fmt.Errorf("there's no notebook tab named %s: describe lists them", notebook)307+ case cell < 1 || cell > len(s.NotebookCells()):308+ err = fmt.Errorf("%s has %d cells", notebook, len(s.NotebookCells()))309+ }310+ })311+ if err != nil {312+ return "", err313+ }314+ if !granted {315+ if err := a.grant(ctx, "notebooks", fmt.Sprintf("%s asks to run cell %d of %s with nu, as you", a.seat.Name(), cell, notebook)); err != nil {316+ return "", err317+ }318+ }319+ a.seat.Post(RunCell{Notebook: notebook, Cell: cell - 1, Agent: a.seat.Name()})320+ return fmt.Sprintf("cell %d of %s runs on the person's screen: its output arrives in the workbook as when they run it; read it with describe or read_range", cell, notebook), nil321+}322+323+// report takes the news of the agent's settled suggestions, keeping it324+// for its next write, and returns it.325+func (a *Agent) report() []string {326+ var news []string327+ a.seat.Do(func(*sheet.Workbook) {328+ for _, s := range BoardOf(a.seat).Resolved(a.seat.ID()) {329+ news = append(news, s.Notice())330+ }331+ })332+ if len(news) > 0 {333+ a.mu.Lock()334+ a.notices = append(a.notices, news...)335+ a.mu.Unlock()336+ }337+ return news338+}339+340+// takeNotices is the news kept since the agent last wrote.341+func (a *Agent) takeNotices() []string {342+ a.report()343+ a.mu.Lock()344+ defer a.mu.Unlock()345+ n := a.notices346+ a.notices = nil347+ return n348+}349+350+// Leave takes the agent's questions off the board and gives up its351+// seat. Its suggestions stay for the person to settle.352+func (a *Agent) Leave() {353+ a.seat.Do(func(*sheet.Workbook) {354+ b := BoardOf(a.seat)355+ for _, q := range b.Asks() {356+ if q.Author == a.seat.ID() {357+ b.Withdraw(q)358+ }359+ }360+ a.seat.Touch()361+ })362+ a.seat.Leave()363+}@@ -0,0 +1,284 @@
1+package cowork2+3+import (4+ "encoding/json"5+ "errors"6+ "fmt"7+ "slices"8+ "strconv"9+ "strings"10+)11+12+// Questions: an agent asks the person something with the ask tool,13+// shaped as an MCP elicitation request (a message, and a flat object14+// schema of what to answer), and the person answers on the context line15+// of their session, one field at a time. Elicitation goes from a server16+// to its host's user; in live mode the person is on the server's side,17+// so the agent asks through a tool, and the answer comes back as an18+// elicitation's result: accept with the values, decline or cancel.19+// Grants (running notebook cells, JEV) are asked the same way.20+21+// AskKind is what a question is for.22+type AskKind int23+24+const (25+ // Question is the agent's own question (the ask tool).26+ Question AskKind = iota27+ // Grant asks leave to run notebook cells or enter JEV formulas.28+ Grant29+)30+31+// FieldKind is the type of what a field asks for.32+type FieldKind int33+34+const (35+ Text FieldKind = iota36+ Number37+ Integer38+ Boolean39+ Choice40+)41+42+// Field is one thing a question asks for.43+type Field struct {44+ Name, Title, Description string45+ Kind FieldKind46+ Choices []string // a Choice's values47+ Labels []string // and how they're shown, when the schema names them48+ Required bool49+}50+51+// Label is how the field is named to the person.52+func (f Field) Label() string {53+ if f.Title != "" {54+ return f.Title55+ }56+ return f.Name57+}58+59+// Ask is a question waiting for a person's answer.60+type Ask struct {61+ ID int62+ Author int // the agent's seat63+ Agent string64+ Color int65+ Kind AskKind66+ Message string67+ Fields []Field68+ // Grant is what a Grant asks for: "notebooks" or "jev".69+ Grant string70+71+ reply chan Answer72+ taken int // the seat showing it, 0 for none yet73+ done bool74+}75+76+// Answer is the person's answer: the elicitation's action (accept,77+// decline, cancel) and values by field, and for a grant whether it78+// holds for the rest of the session.79+type Answer struct {80+ Action string81+ Content map[string]any82+ Always bool83+}84+85+// NewAsk makes a question, its fields read from an elicitation's86+// requested schema (nil for a yes or no question).87+func NewAsk(message string, schema any) (*Ask, error) {88+ if strings.TrimSpace(message) == "" {89+ return nil, errors.New("ask needs a message: the question shown to the person")90+ }91+ fields, err := Fields(schema)92+ if err != nil {93+ return nil, err94+ }95+ return &Ask{Kind: Question, Message: message, Fields: fields, reply: make(chan Answer, 1)}, nil96+}97+98+// NewGrant asks leave for what ("notebooks" or "jev").99+func NewGrant(what, message string) *Ask {100+ return &Ask{Kind: Grant, Grant: what, Message: message, reply: make(chan Answer, 1)}101+}102+103+// Reply is where the answer arrives, once.104+func (a *Ask) Reply() <-chan Answer { return a.reply }105+106+// Done reports whether the question was answered or withdrawn.107+func (a *Ask) Done() bool { return a.done }108+109+// schemaProp is a property of an elicitation's schema, as far as110+// questions read it.111+type schemaProp struct {112+ Type string `json:"type"`113+ Title string `json:"title"`114+ Description string `json:"description"`115+ Enum []string `json:"enum"`116+ EnumNames []string `json:"enumNames"`117+ OneOf []struct {118+ Const string `json:"const"`119+ Title string `json:"title"`120+ } `json:"oneOf"`121+}122+123+// Fields reads an elicitation's requested schema: an object of124+// top-level string, number, integer and boolean properties, strings with125+// enum (or oneOf consts) being a choice, as MCP allows.126+func Fields(schema any) ([]Field, error) {127+ if schema == nil {128+ return nil, nil129+ }130+ data, err := json.Marshal(schema)131+ if err != nil {132+ return nil, err133+ }134+ var s struct {135+ Type string `json:"type"`136+ Properties map[string]json.RawMessage `json:"properties"`137+ Required []string `json:"required"`138+ }139+ if err := json.Unmarshal(data, &s); err != nil {140+ return nil, fmt.Errorf("requestedSchema: %w", err)141+ }142+ if s.Type != "" && s.Type != "object" {143+ return nil, errors.New("requestedSchema must be an object schema")144+ }145+ names := make([]string, 0, len(s.Properties))146+ for n := range s.Properties {147+ names = append(names, n)148+ }149+ slices.Sort(names) // JSON objects are unordered; required ones first below150+ slices.SortStableFunc(names, func(a, b string) int {151+ return boolRank(slices.Contains(s.Required, b)) - boolRank(slices.Contains(s.Required, a))152+ })153+ var out []Field154+ for _, n := range names {155+ f, err := field(n, s.Properties[n])156+ if err != nil {157+ return nil, err158+ }159+ f.Required = slices.Contains(s.Required, n)160+ out = append(out, f)161+ }162+ return out, nil163+}164+165+func boolRank(b bool) int {166+ if b {167+ return 1168+ }169+ return 0170+}171+172+// field reads one property.173+func field(name string, raw json.RawMessage) (Field, error) {174+ var p schemaProp175+ if err := json.Unmarshal(raw, &p); err != nil {176+ return Field{}, fmt.Errorf("requestedSchema property %s: %w", name, err)177+ }178+ f := Field{Name: name, Title: p.Title, Description: p.Description}179+ switch {180+ case p.Type == "string" && len(p.Enum) > 0:181+ f.Kind, f.Choices, f.Labels = Choice, p.Enum, p.EnumNames182+ case p.Type == "string" && len(p.OneOf) > 0:183+ f.Kind = Choice184+ for _, o := range p.OneOf {185+ f.Choices, f.Labels = append(f.Choices, o.Const), append(f.Labels, o.Title)186+ }187+ case p.Type == "string":188+ f.Kind = Text189+ case p.Type == "number":190+ f.Kind = Number191+ case p.Type == "integer":192+ f.Kind = Integer193+ case p.Type == "boolean":194+ f.Kind = Boolean195+ default:196+ return Field{}, fmt.Errorf("requestedSchema property %s is a %q: questions take strings, numbers, integers, booleans and choices", name, p.Type)197+ }198+ if len(f.Choices) > 9 {199+ return Field{}, fmt.Errorf("requestedSchema property %s has %d choices: at most 9, each a key", name, len(f.Choices))200+ }201+ return f, nil202+}203+204+// Parse reads what the person typed for a Text, Number or Integer205+// field.206+func (f Field) Parse(text string) (any, error) {207+ switch f.Kind {208+ case Number:209+ n, err := strconv.ParseFloat(strings.TrimSpace(text), 64)210+ if err != nil {211+ return nil, fmt.Errorf("%s takes a number", f.Label())212+ }213+ return n, nil214+ case Integer:215+ n, err := strconv.Atoi(strings.TrimSpace(text))216+ if err != nil {217+ return nil, fmt.Errorf("%s takes a whole number", f.Label())218+ }219+ return n, nil220+ }221+ return text, nil222+}223+224+// ChoiceLabel is how choice i is shown.225+func (f Field) ChoiceLabel(i int) string {226+ if i < len(f.Labels) && f.Labels[i] != "" {227+ return f.Labels[i]228+ }229+ return f.Choices[i]230+}231+232+// AddAsk puts a question on the board for a person to answer.233+func (b *Board) AddAsk(a *Ask) {234+ b.nextAsk++235+ a.ID = b.nextAsk236+ b.asks = append(b.asks, a)237+}238+239+// NextAsk is the first question waiting that the person at seat should240+// answer: the host's, or in a room without one, anyone's not already241+// shown to another who is here (present). It is then seat's to show.242+func (b *Board) NextAsk(seat int, present func(id int) bool) *Ask {243+ if b.Host != 0 && b.Host != seat && present(b.Host) {244+ return nil245+ }246+ for _, a := range b.asks {247+ if a.taken == seat || a.taken == 0 || !present(a.taken) {248+ a.taken = seat249+ return a250+ }251+ }252+ return nil253+}254+255+// Answer answers a question and takes it off the board.256+func (b *Board) Answer(a *Ask, ans Answer) {257+ if a.done {258+ return259+ }260+ a.done = true261+ b.asks = slices.DeleteFunc(b.asks, func(x *Ask) bool { return x == a })262+ if a.Kind == Grant && ans.Action == "accept" && ans.Always {263+ switch a.Grant {264+ case "notebooks":265+ b.Grants.Notebooks = true266+ case "jev":267+ b.Grants.JEV = true268+ }269+ }270+ a.reply <- ans271+}272+273+// Withdraw takes a question off the board unanswered: the agent gave274+// up waiting, or left.275+func (b *Board) Withdraw(a *Ask) {276+ if a.done {277+ return278+ }279+ a.done = true280+ b.asks = slices.DeleteFunc(b.asks, func(x *Ask) bool { return x == a })281+}282+283+// Asks are the questions waiting, oldest first.284+func (b *Board) Asks() []*Ask { return slices.Clone(b.asks) }@@ -0,0 +1,125 @@
1+package cowork2+3+import (4+ "bufio"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "io"9+ "net"10+ "path/filepath"11+)12+13+// Attach is 012 mcp --attach: it finds the session target names (see14+// Pick), refusing a socket that isn't the user's alone, and carries the15+// MCP client's messages from in to it and its answers to out until16+// either side ends. The client's first message, its initialize request,17+// names it to the others. When the session can't be reached, the18+// initialize request is answered with the reason, which hosts show, and19+// Attach returns it.20+func Attach(target string, in io.Reader, out io.Writer) error {21+ br := bufio.NewReader(in)22+ first, err := br.ReadBytes('\n')23+ if err != nil && len(first) == 0 {24+ return err25+ }26+ c, err := dial(target, clientName(first))27+ if err != nil {28+ refuse(out, first, err)29+ return err30+ }31+ defer c.conn.Close()32+ if _, err := c.conn.Write(first); err != nil {33+ return err34+ }35+ go func() {36+ io.Copy(c.conn, br)37+ if uc, ok := c.conn.(*net.UnixConn); ok {38+ uc.CloseWrite()39+ }40+ }()41+ _, err = io.Copy(out, c.r)42+ return err43+}44+45+// attached is a connection past the welcome.46+type attached struct {47+ conn net.Conn48+ r *bufio.Reader49+ w welcome50+}51+52+// dial connects to the session target names, as client.53+func dial(target, client string) (attached, error) {54+ dir, err := SocketDir()55+ if err != nil {56+ return attached{}, err57+ }58+ eps, err := Endpoints(dir)59+ if err != nil {60+ return attached{}, err61+ }62+ ep, roomName, err := Pick(eps, target)63+ if err != nil {64+ return attached{}, err65+ }66+ if err := checkOwner(filepath.Dir(ep.Socket)); err != nil {67+ return attached{}, err68+ }69+ if err := checkOwner(ep.Socket); err != nil {70+ return attached{}, err71+ }72+ conn, err := net.Dial("unix", ep.Socket)73+ if err != nil {74+ return attached{}, fmt.Errorf("the session doesn't answer: %w", err)75+ }76+ if err := json.NewEncoder(conn).Encode(hello{Version: 1, Room: roomName, Client: client}); err != nil {77+ conn.Close()78+ return attached{}, err79+ }80+ a := attached{conn: conn, r: bufio.NewReader(conn)}81+ line, err := a.r.ReadBytes('\n')82+ if err == nil {83+ err = json.Unmarshal(line, &a.w)84+ }85+ switch {86+ case err != nil:87+ conn.Close()88+ return attached{}, fmt.Errorf("the session refused: %w", err)89+ case a.w.Error != "":90+ conn.Close()91+ return attached{}, errors.New(a.w.Error)92+ }93+ return a, nil94+}95+96+// clientName is the MCP client's name from its initialize request: its97+// title, else its name.98+func clientName(initialize []byte) string {99+ var req struct {100+ Params struct {101+ ClientInfo struct {102+ Name string `json:"name"`103+ Title string `json:"title"`104+ } `json:"clientInfo"`105+ } `json:"params"`106+ }107+ json.Unmarshal(initialize, &req)108+ if t := req.Params.ClientInfo.Title; t != "" {109+ return t110+ }111+ return req.Params.ClientInfo.Name112+}113+114+// refuse answers the client's first request with err, as a JSON-RPC115+// error its host shows.116+func refuse(out io.Writer, first []byte, err error) {117+ var req struct {118+ ID json.RawMessage `json:"id"`119+ }120+ if json.Unmarshal(first, &req) != nil || len(req.ID) == 0 {121+ return122+ }123+ resp, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": req.ID, "error": map[string]any{"code": -32000, "message": "012: " + err.Error()}})124+ out.Write(append(resp, '\n'))125+}@@ -0,0 +1,250 @@
1+package cowork2+3+import (4+ "errors"5+ "fmt"6+ "slices"7+ "time"8+9+ "github.com/FelineStateMachine/012/internal/room"10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// BoardKey is the room value (room.Seat.Value) holding its Board.14+const BoardKey = "agents"15+16+// BoardOf is the Board of seat's room, made the first time. Call it in17+// a turn.18+func BoardOf(seat *room.Seat) *Board {19+ return seat.Value(BoardKey, func() any { return &Board{} }).(*Board)20+}21+22+// Board is what a room shares of its agents: their suggestions, their23+// questions to the people, and what the people let them do.24+type Board struct {25+ // Scope is what agents may change, as the person who invited them26+ // chose; the whole workbook in 012 serve.27+ Scope Scope28+ // Direct lets agents' changes be made at once, as their own steps,29+ // rather than suggested ("Let agents edit directly").30+ Direct bool31+ Grants Grants32+ // Host is the seat of the person who invited the agents, who is33+ // asked their questions; 0 in 012 serve, where any person is.34+ Host int35+36+ list []*Suggestion // oldest first37+ next int38+ asks []*Ask39+ nextAsk int40+}41+42+// CellState is what became of one cell of a suggestion.43+type CellState int44+45+const (46+ CellPending CellState = iota47+ CellAccepted48+ CellRejected49+)50+51+// Suggestion is an agent's change waiting for a person: the proposal,52+// who made it and why, and what became of each of its cells.53+type Suggestion struct {54+ ID int55+ Author int // the agent's seat56+ Agent string // its name57+ Color int58+ Message string59+ At time.Time60+ *sheet.Proposal61+ // States are its cells', in the order of Proposal.Cells; a change62+ // made whole only has one, for all of it.63+ States []CellState64+ // Failed is why accepting it failed, when it did.65+ Failed string66+ reported bool67+}68+69+// Pending reports whether any of it waits for the person.70+func (s *Suggestion) Pending() bool { return slices.Contains(s.States, CellPending) }71+72+// Count is how many of its cells are in state st.73+func (s *Suggestion) Count(st CellState) int {74+ n := 075+ for _, x := range s.States {76+ if x == st {77+ n++78+ }79+ }80+ return n81+}82+83+// Outcome is what became of it in words: pending, accepted, accepted in84+// part or rejected.85+func (s *Suggestion) Outcome() string {86+ switch acc, rej := s.Count(CellAccepted), s.Count(CellRejected); {87+ case s.Pending():88+ return "pending"89+ case rej == 0:90+ return "accepted"91+ case acc == 0:92+ return "rejected"93+ }94+ return "accepted in part"95+}96+97+// Add puts a suggestion on the board and numbers it.98+func (b *Board) Add(s *Suggestion) int {99+ b.next++100+ s.ID = b.next101+ if s.At.IsZero() {102+ s.At = time.Now()103+ }104+ n := len(s.Cells)105+ if s.Whole() || n == 0 {106+ n = 1107+ }108+ s.States = make([]CellState, n)109+ b.list = append(b.list, s)110+ return s.ID111+}112+113+// Pending are the suggestions still waiting, oldest first.114+func (b *Board) Pending() []*Suggestion {115+ var out []*Suggestion116+ for _, s := range b.list {117+ if s.Pending() {118+ out = append(out, s)119+ }120+ }121+ return out122+}123+124+// Get is the suggestion numbered id.125+func (b *Board) Get(id int) *Suggestion {126+ for _, s := range b.list {127+ if s.ID == id {128+ return s129+ }130+ }131+ return nil132+}133+134+// Of are the suggestions agent's seat made, oldest first.135+func (b *Board) Of(author int) []*Suggestion {136+ var out []*Suggestion137+ for _, s := range b.list {138+ if s.Author == author {139+ out = append(out, s)140+ }141+ }142+ return out143+}144+145+// At is the newest pending suggestion setting the cell a of the sheet146+// named name, and the index of the cell in it.147+func (b *Board) At(name string, a sheet.Addr) (*Suggestion, int, bool) {148+ for i := len(b.list) - 1; i >= 0; i-- {149+ s := b.list[i]150+ for j, c := range s.Cells {151+ if c.Sheet == name && c.At == a && s.state(j) == CellPending {152+ return s, j, true153+ }154+ }155+ }156+ return nil, 0, false157+}158+159+// state is cell i's state; all of a whole change's cells are its one.160+func (s *Suggestion) state(i int) CellState {161+ if s.Whole() {162+ return s.States[0]163+ }164+ return s.States[i]165+}166+167+// ErrGone is accepting or rejecting a suggestion no longer waiting.168+var ErrGone = errors.New("that suggestion isn't waiting any more")169+170+// Accept makes the cells of suggestion id that are picked (nil: all it171+// has pending) on w, as one step in the agent's name, and marks them172+// accepted. A change made whole only is accepted whole.173+func (b *Board) Accept(w *sheet.Workbook, id int, cells []int) error {174+ s := b.Get(id)175+ if s == nil || !s.Pending() {176+ return ErrGone177+ }178+ if s.Whole() && cells != nil {179+ return sheet.ErrWhole180+ }181+ pick := b.picked(s, cells)182+ author := w.Author()183+ w.SetAuthor(s.Author)184+ err := s.Apply(w, func(i int) bool { return pick[i] })185+ w.SetAuthor(author)186+ if err != nil {187+ s.Failed = err.Error()188+ return fmt.Errorf("accepting %s's suggestion: %w", s.Agent, err)189+ }190+ b.mark(s, pick, CellAccepted)191+ return nil192+}193+194+// Reject marks the cells of suggestion id picked (nil: all it has195+// pending) rejected, changing nothing.196+func (b *Board) Reject(id int, cells []int) error {197+ s := b.Get(id)198+ if s == nil || !s.Pending() {199+ return ErrGone200+ }201+ b.mark(s, b.picked(s, cells), CellRejected)202+ return nil203+}204+205+// picked are the pending cells of s among cells, all of them for nil.206+func (b *Board) picked(s *Suggestion, cells []int) map[int]bool {207+ pick := map[int]bool{}208+ for i := range s.Cells {209+ if s.state(i) == CellPending && (cells == nil || slices.Contains(cells, i)) {210+ pick[i] = true211+ }212+ }213+ return pick214+}215+216+// mark sets the picked cells' state.217+func (b *Board) mark(s *Suggestion, pick map[int]bool, st CellState) {218+ if s.Whole() || len(s.Cells) == 0 {219+ s.States[0] = st220+ return221+ }222+ for i := range pick {223+ s.States[i] = st224+ }225+}226+227+// Resolved are the agent's suggestions settled since it last asked,228+// which it hasn't been told of.229+func (b *Board) Resolved(author int) []*Suggestion {230+ var out []*Suggestion231+ for _, s := range b.list {232+ if s.Author == author && !s.Pending() && !s.reported {233+ s.reported = true234+ out = append(out, s)235+ }236+ }237+ return out238+}239+240+// Notice is what the agent is told of a settled suggestion.241+func (s *Suggestion) Notice() string {242+ what := fmt.Sprintf("Suggestion %d (%s) was %s", s.ID, s.Label, s.Outcome())243+ if n := len(s.Cells); n > 1 && s.Outcome() == "accepted in part" {244+ what += fmt.Sprintf(": %d of its %d cells", s.Count(CellAccepted), n)245+ }246+ if s.Failed != "" {247+ what += "; accepting it failed: " + s.Failed248+ }249+ return what250+}@@ -0,0 +1,337 @@
1+package cowork2+3+import (4+ "context"5+ "errors"6+ "strings"7+ "testing"8+ "time"9+10+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"11+12+ "github.com/FelineStateMachine/012/internal/headless"13+ "github.com/FelineStateMachine/012/internal/mcp"14+ "github.com/FelineStateMachine/012/internal/notebook"15+ "github.com/FelineStateMachine/012/internal/room"16+ "github.com/FelineStateMachine/012/internal/sheet"17+)18+19+// person is a participant standing for a session.20+type person struct{ name string }21+22+func (p *person) Name() string { return p.name }23+func (p *person) Notify() {}24+25+// bot is an agent participant without a connection.26+type bot struct{ person }27+28+func (*bot) Agent() {}29+30+// 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.32+func liveRoom(t *testing.T) (ann *room.Seat, agent *Agent) {33+ t.Helper()34+ g := room.NewRegistry(room.Edit)35+ ann, _ = g.Join("k", &person{"ann"}, func() *sheet.Workbook {36+ w := sheet.New().Book()37+ for i, in := range []string{"1", "2", "3", "=SUM(A1:A3)"} {38+ w.Sheet(0).Set(sheet.Addr{Row: i}, in)39+ }40+ w.AddSheet("Q3", 1)41+ return w42+ })43+ ann.Do(func(*sheet.Workbook) { ann.SetSaved(room.Saved{Name: "budget.012"}) })44+ seat, ok := g.JoinOpen("k", &bot{person{"claude"}})45+ if !ok {46+ t.Fatal("the agent couldn't join")47+ }48+ return ann, NewAgent(seat)49+}50+51+func set(entries ...string) func(*sheet.Workbook) error {52+ return func(w *sheet.Workbook) error {53+ var es []headless.Entry54+ for i := 0; i+1 < len(entries); i += 2 {55+ es = append(es, headless.Entry{Ref: entries[i], Input: entries[i+1]})56+ }57+ _, err := headless.Set(w, es, headless.SetOptions{})58+ return err59+ }60+}61+62+func value(s *room.Seat, sheetName, ref string) sheet.Value {63+ var v sheet.Value64+ s.Do(func(w *sheet.Workbook) {65+ a, _ := sheet.ParseAddr(ref)66+ v = w.Lookup(sheetName).Value(a)67+ })68+ return v69+}70+71+func TestSuggestionWaitsForThePerson(t *testing.T) {72+ ann, a := liveRoom(t)73+ made, err := a.Make(context.Background(), mcp.Making{Label: "set", Message: "fix the totals", Fn: set("A1", "10", "A2", "20")})74+ if err != nil {75+ t.Fatal(err)76+ }77+ if made.Applied || made.Suggestion != 1 || len(made.Changes) == 0 {78+ t.Fatalf("made %+v", made)79+ }80+ if v := value(ann, "Sheet1", "A4"); v.Num != 6 {81+ t.Fatalf("the suggestion was made: A4 is %v", v)82+ }83+ ann.Do(func(*sheet.Workbook) {84+ b := BoardOf(ann)85+ s, i, ok := b.At("Sheet1", sheet.Addr{Row: 1})86+ if !ok || s.ID != 1 || i != 1 || s.Message != "fix the totals" || s.Agent != "claude" {87+ t.Errorf("A2's suggestion: %+v %d %v", s, i, ok)88+ }89+ })90+}91+92+func TestSuggestionAcceptedIsTheAgentsStep(t *testing.T) {93+ ann, a := liveRoom(t)94+ a.Make(context.Background(), mcp.Making{Label: "set", Fn: set("A1", "10", "A2", "20")})95+ ann.Do(func(w *sheet.Workbook) {96+ if err := BoardOf(ann).Accept(w, 1, nil); err != nil {97+ t.Fatal(err)98+ }99+ if w.CanUndo() {100+ t.Error("ann can undo the agent's step")101+ }102+ })103+ if v := value(ann, "Sheet1", "A4"); v.Num != 33 {104+ t.Fatalf("A4 is %v after accepting", v)105+ }106+ news := a.report()107+ if len(news) != 1 || !strings.Contains(news[0], "was accepted") {108+ t.Fatalf("news %v", news)109+ }110+ if label, err := a.Undo(context.Background()); err != nil || label != "set" {111+ t.Fatalf("the agent's undo: %q %v", label, err)112+ }113+ if v := value(ann, "Sheet1", "A4"); v.Num != 6 {114+ t.Fatalf("A4 is %v after the agent's undo", v)115+ }116+}117+118+func TestSuggestionByCell(t *testing.T) {119+ ann, a := liveRoom(t)120+ a.Make(context.Background(), mcp.Making{Label: "set", Fn: set("A1", "10", "A2", "20", "A3", "30")})121+ ann.Do(func(w *sheet.Workbook) {122+ b := BoardOf(ann)123+ if err := b.Accept(w, 1, []int{0}); err != nil {124+ t.Fatal(err)125+ }126+ if err := b.Reject(1, []int{1}); err != nil {127+ t.Fatal(err)128+ }129+ if !b.Get(1).Pending() {130+ t.Error("A3 isn't pending")131+ }132+ b.Accept(w, 1, nil) // the rest: A3133+ })134+ if v := value(ann, "Sheet1", "A4"); v.Num != 42 {135+ t.Fatalf("A4 is %v: 10 + 2 + 30", v)136+ }137+ list, _ := a.Suggestions(context.Background())138+ if len(list) != 1 || list[0].State != "accepted in part" || list[0].Accepted != 2 || list[0].Rejected != 1 {139+ t.Fatalf("suggestions %+v", list)140+ }141+}142+143+func TestSuggestionRejected(t *testing.T) {144+ ann, a := liveRoom(t)145+ a.Make(context.Background(), mcp.Making{Label: "set", Fn: set("A1", "10")})146+ ann.Do(func(*sheet.Workbook) { BoardOf(ann).Reject(1, nil) })147+ made, _ := a.Make(context.Background(), mcp.Making{Label: "set", Fn: set("B1", "x")})148+ if len(made.Notices) != 1 || !strings.Contains(made.Notices[0], "Suggestion 1 (set) was rejected") {149+ t.Fatalf("notices %v", made.Notices)150+ }151+ if v := value(ann, "Sheet1", "A1"); v.Num != 1 {152+ t.Fatal("a rejected suggestion changed A1")153+ }154+ ann.Do(func(w *sheet.Workbook) {155+ if err := BoardOf(ann).Accept(w, 1, nil); !errors.Is(err, ErrGone) {156+ t.Errorf("accepting a rejected suggestion: %v", err)157+ }158+ })159+}160+161+func TestDirectEdits(t *testing.T) {162+ ann, a := liveRoom(t)163+ ann.Do(func(*sheet.Workbook) { BoardOf(ann).Direct = true })164+ made, err := a.Make(context.Background(), mcp.Making{Label: "set", Fn: set("A1", "10")})165+ if err != nil || !made.Applied || made.Suggestion != 0 {166+ t.Fatalf("made %+v, %v", made, err)167+ }168+ if v := value(ann, "Sheet1", "A4"); v.Num != 15 {169+ t.Fatalf("A4 is %v", v)170+ }171+ var where room.Presence172+ ann.Do(func(*sheet.Workbook) { where = ann.Peers()[0].Presence })173+ if where.Cursor != (sheet.Addr{}) || where.Sheet == nil {174+ t.Errorf("the agent's pointer isn't on its change: %+v", where)175+ }176+}177+178+func TestScopeRefuses(t *testing.T) {179+ for _, tc := range []struct {180+ name string181+ scope func(w *sheet.Workbook) Scope182+ fn func(*sheet.Workbook) error183+ want string184+ }{185+ {"read only", func(*sheet.Workbook) Scope { return Scope{Kind: ReadOnly} }, set("A1", "5"), "read only"},186+ {"another sheet", func(w *sheet.Workbook) Scope { return Scope{Kind: OneSheet, Sheet: w.Lookup("Q3")} }, set("A1", "5"), "Sheet1!A1, outside what the person let you change (sheet Q3)"},187+ {"outside the range", func(w *sheet.Workbook) Scope {188+ return Scope{Kind: OneRange, Sheet: w.Sheet(0), Range: sheet.Rect{To: sheet.Addr{Row: 2}}}189+ }, set("B1", "5"), "Sheet1!B1, outside what the person let you change (Sheet1!A1:A3)"},190+ {"rows in a range", func(w *sheet.Workbook) Scope {191+ return Scope{Kind: OneRange, Sheet: w.Sheet(0), Range: sheet.Rect{To: sheet.Addr{Row: 2}}}192+ }, func(w *sheet.Workbook) error { return w.Sheet(0).InsertRows(0, 1) }, "inserts or deletes rows"},193+ {"a sheet added", func(w *sheet.Workbook) Scope { return Scope{Kind: OneSheet, Sheet: w.Sheet(0)} },194+ func(w *sheet.Workbook) error { _, err := w.AddSheet("New", 2); return err }, "adds, deletes, renames or moves sheets"},195+ } {196+ t.Run(tc.name, func(t *testing.T) {197+ ann, a := liveRoom(t)198+ ann.Do(func(w *sheet.Workbook) { BoardOf(ann).Scope = tc.scope(w) })199+ _, err := a.Make(context.Background(), mcp.Making{Label: "x", Fn: tc.fn})200+ if err == nil || !strings.Contains(err.Error(), tc.want) {201+ t.Fatalf("got %v, want %q", err, tc.want)202+ }203+ })204+ }205+ ann, a := liveRoom(t)206+ ann.Do(func(w *sheet.Workbook) { BoardOf(ann).Scope = Scope{Kind: OneSheet, Sheet: w.Sheet(0)} })207+ if _, err := a.Make(context.Background(), mcp.Making{Label: "x", Fn: set("C3", "5")}); err != nil {208+ t.Fatalf("inside the sheet: %v", err)209+ }210+ if _, err := a.Focus(context.Background(), "Q3!A1"); err == nil {211+ t.Fatal("focus outside the scope")212+ }213+ if at, err := a.Focus(context.Background(), "B2:C3"); err != nil || at != "Sheet1!B2:C3" {214+ t.Fatalf("focus: %q %v", at, err)215+ }216+}217+218+// answer answers the first question on the board as a session would.219+func answer(t *testing.T, ann *room.Seat, ans Answer) *Ask {220+ t.Helper()221+ deadline := time.Now().Add(5 * time.Second)222+ for time.Now().Before(deadline) {223+ var q *Ask224+ ann.Do(func(*sheet.Workbook) {225+ b := BoardOf(ann)226+ if q = b.NextAsk(ann.ID(), func(int) bool { return true }); q != nil {227+ b.Answer(q, ans)228+ }229+ })230+ if q != nil {231+ return q232+ }233+ time.Sleep(5 * time.Millisecond)234+ }235+ t.Fatal("no question came")236+ return nil237+}238+239+func TestAskReturnsTheAnswer(t *testing.T) {240+ ann, a := liveRoom(t)241+ got := make(chan *sdk.ElicitResult, 1)242+ go func() {243+ res, err := a.Ask(context.Background(), &sdk.ElicitParams{Message: "Overwrite B2:B40?",244+ RequestedSchema: map[string]any{"type": "object", "properties": map[string]any{245+ "sure": map[string]any{"type": "boolean", "title": "Sure"},246+ "which": map[string]any{"type": "string", "enum": []string{"a", "b"}}}, "required": []string{"which"}}})247+ if err != nil {248+ t.Error(err)249+ }250+ got <- res251+ }()252+ q := answer(t, ann, Answer{Action: "accept", Content: map[string]any{"sure": true, "which": "b"}})253+ if q.Message != "Overwrite B2:B40?" || len(q.Fields) != 2 || q.Fields[0].Name != "which" || q.Fields[0].Kind != Choice || q.Fields[1].Kind != Boolean {254+ t.Fatalf("question %+v", q)255+ }256+ res := <-got257+ if res.Action != "accept" || res.Content["which"] != "b" {258+ t.Fatalf("result %+v", res)259+ }260+}261+262+func TestAskWithdrawnWhenTheAgentGivesUp(t *testing.T) {263+ ann, a := liveRoom(t)264+ ctx, cancel := context.WithCancel(context.Background())265+ done := make(chan error, 1)266+ go func() {267+ _, err := a.Ask(ctx, &sdk.ElicitParams{Message: "Sure?"})268+ done <- err269+ }()270+ for n := 0; n == 0; {271+ ann.Do(func(*sheet.Workbook) { n = len(BoardOf(ann).Asks()) })272+ time.Sleep(time.Millisecond)273+ }274+ cancel()275+ if err := <-done; !errors.Is(err, context.Canceled) {276+ t.Fatal(err)277+ }278+ ann.Do(func(*sheet.Workbook) {279+ if n := len(BoardOf(ann).Asks()); n != 0 {280+ t.Errorf("%d questions left", n)281+ }282+ })283+}284+285+func TestAskRefusesSchemasItCantShow(t *testing.T) {286+ _, a := liveRoom(t)287+ _, err := a.Ask(context.Background(), &sdk.ElicitParams{Message: "Where?",288+ RequestedSchema: map[string]any{"type": "object", "properties": map[string]any{"at": map[string]any{"type": "array"}}}})289+ if err == nil || !strings.Contains(err.Error(), "questions take strings") {290+ t.Fatal(err)291+ }292+}293+294+func TestJEVDirectAsksFirst(t *testing.T) {295+ ann, a := liveRoom(t)296+ ann.Do(func(*sheet.Workbook) { BoardOf(ann).Direct = true })297+ done := make(chan error, 1)298+ go func() {299+ _, err := a.Make(context.Background(), mcp.Making{Label: "ask", Fn: set("B1", `=JEV.TEST(A1, "odd?")`)})300+ done <- err301+ }()302+ q := answer(t, ann, Answer{Action: "accept", Always: true})303+ if q.Kind != Grant || q.Grant != "jev" {304+ t.Fatalf("question %+v", q)305+ }306+ if err := <-done; err != nil {307+ t.Fatal(err)308+ }309+ ann.Do(func(*sheet.Workbook) {310+ if !BoardOf(ann).Grants.JEV {311+ t.Error("always didn't grant JEV for the session")312+ }313+ })314+}315+316+func TestNotebookRunsAskAndGoToTheKeeper(t *testing.T) {317+ ann, a := liveRoom(t)318+ ann.Do(func(w *sheet.Workbook) { w.AddNotebook("Notes", w.Sheet(0)) })319+ ann.Do(func(w *sheet.Workbook) {320+ w.Lookup("Notes").SetNotebookCells("add", []notebook.Cell{{Source: "ls"}})321+ })322+ if _, err := a.RunCell(context.Background(), "Nope", 1); err == nil {323+ t.Fatal("ran a cell of no notebook")324+ }325+ done := make(chan error, 1)326+ go func() {327+ _, err := a.RunCell(context.Background(), "Notes", 1)328+ done <- err329+ }()330+ q := answer(t, ann, Answer{Action: "decline"})331+ if q.Grant != "notebooks" {332+ t.Fatalf("question %+v", q)333+ }334+ if err := <-done; err == nil {335+ t.Fatal("a declined run went ahead")336+ }337+}@@ -0,0 +1,109 @@
1+// Package cowork is live mode (docs/agents/live.md): an agent working in2+// a running 012 session, as one of the participants of the session's3+// room (package room). A session listens on a socket only its user can4+// reach (Listen); 012 mcp --attach connects to it (Attach) and carries5+// the agent's MCP messages to a server running in the session, whose6+// tools stand on the room's workbook (Agent, an mcp.Live). What the7+// agent reads is the workbook on the screen; what it changes is a8+// proposal (sheet.Propose) on the room's Board, marked on the person's9+// screen until they accept or reject it, or, when they let agents edit10+// directly, a step of the agent's own. The Board also carries the11+// agent's questions to the person (Ask) and what the person allows it.12+//13+// Everything on the Board is the room's, so it is read and changed in14+// a turn (room.Seat.Do), as the workbook is.15+package cowork16+17+import (18+ "errors"19+ "fmt"20+ "slices"21+22+ "github.com/FelineStateMachine/012/internal/sheet"23+)24+25+// ScopeKind is how much of the workbook an agent may change.26+type ScopeKind int27+28+const (29+ // Workbook lets the agent change anything.30+ Workbook ScopeKind = iota31+ // OneSheet lets it change one sheet's cells, lines and charts.32+ OneSheet33+ // OneRange lets it change the cells of one range.34+ OneRange35+ // ReadOnly lets it change nothing.36+ ReadOnly37+)38+39+// Scope is what the person invited the agent to change. The agent reads40+// the whole workbook whatever the scope, as formulas in it read cells41+// outside it; the scope bounds its changes and where it points.42+type Scope struct {43+ Kind ScopeKind44+ Sheet *sheet.Sheet // OneSheet, OneRange45+ Range sheet.Rect // OneRange46+}47+48+// String is the scope in words: "the whole workbook", "sheet Q3",49+// "Q3!A1:C9", "read only".50+func (s Scope) String() string {51+ switch s.Kind {52+ case OneSheet:53+ return "sheet " + s.Sheet.Name()54+ case OneRange:55+ return sheet.Qualified(s.Sheet.Name(), s.Range)56+ case ReadOnly:57+ return "read only"58+ }59+ return "the whole workbook"60+}61+62+// ErrReadOnly refuses any change of an agent invited read only.63+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")64+65+// Allows reports whether the agent may point at r of sheet s.66+func (s Scope) Allows(sh *sheet.Sheet, r sheet.Rect) bool {67+ switch s.Kind {68+ case OneSheet:69+ return sh == s.Sheet70+ case OneRange:71+ return sh == s.Sheet && s.Range.Contains(r.From) && s.Range.Contains(r.To)72+ }73+ return true74+}75+76+// Check refuses a proposal that changes anything outside the scope,77+// saying what.78+func (s Scope) Check(p *sheet.Proposal) error {79+ switch s.Kind {80+ case ReadOnly:81+ return ErrReadOnly82+ case Workbook:83+ return nil84+ }85+ name := s.Sheet.Name()86+ if p.Book || s.Kind == OneRange && len(p.Other) > 0 || slices.ContainsFunc(p.Sheets, func(n string) bool { return n != name }) {87+ return s.outside(p.Other[0])88+ }89+ for _, c := range p.Cells {90+ if c.Sheet != name || s.Kind == OneRange && !s.Range.Contains(c.At) {91+ return s.outside("changes " + sheet.Qualified(c.Sheet, sheet.Rect{From: c.At, To: c.At}))92+ }93+ }94+ return nil95+}96+97+func (s Scope) outside(what string) error {98+ return fmt.Errorf("the change %s, outside what the person let you change (%s): keep to it, or ask them to invite you with a wider scope", what, s)99+}100+101+// Grants are what the person lets agents do beyond changing cells, for102+// the session: run notebook cells with nu, and enter formulas that ask103+// a hosted model over the network (JEV) directly, as untrusted macros104+// ask before either. Without a grant, the agent's request asks the105+// person first.106+type Grants struct {107+ Notebooks bool108+ JEV bool109+}@@ -0,0 +1,158 @@
1+package cowork2+3+import (4+ "encoding/json"5+ "errors"6+ "fmt"7+ "io/fs"8+ "net"9+ "os"10+ "path/filepath"11+ "slices"12+ "strings"13+ "time"14+15+ "github.com/FelineStateMachine/012/internal/config"16+)17+18+// Where sessions listen: a folder of the user's own (0700), the runtime19+// folder's 012 when the system has one ($XDG_RUNTIME_DIR), else agents20+// in 012's config folder. Each listening session has a Unix socket21+// there (0600), <pid>-<n>.sock, and beside it an endpoint file,22+// <pid>-<n>.json, saying what it is, which 012 mcp --attach reads to23+// find it. Windows has Unix sockets too, and its folder is the user's.24+25+// Endpoint is what a listening session says of itself.26+type Endpoint struct {27+ PID int `json:"pid"`28+ Socket string `json:"socket"`29+ // Kind is "session" for a 012 on a terminal, "serve" for 012 serve,30+ // whose agents name the file they join.31+ Kind string `json:"kind"`32+ // Workbook is the session's workbook, as its title names it, or the33+ // folder 012 serve serves.34+ Workbook string `json:"workbook"`35+ Started time.Time `json:"started"`36+}37+38+// SocketDir is the folder sessions listen in.39+func SocketDir() (string, error) {40+ if d := os.Getenv("XDG_RUNTIME_DIR"); d != "" && filepath.IsAbs(d) {41+ return filepath.Join(d, "012"), nil42+ }43+ d, err := config.Dir()44+ if err != nil {45+ return "", err46+ }47+ return filepath.Join(d, "agents"), nil48+}49+50+// ownDir makes dir the user's own, refusing one others can reach.51+func ownDir(dir string) error {52+ if err := os.MkdirAll(dir, 0o700); err != nil {53+ return err54+ }55+ if err := os.Chmod(dir, 0o700); err != nil {56+ return err57+ }58+ return checkOwner(dir)59+}60+61+// Endpoints are the sessions listening in dir, newest first. Files62+// left by sessions that are gone are removed.63+func Endpoints(dir string) ([]Endpoint, error) {64+ entries, err := os.ReadDir(dir)65+ if errors.Is(err, fs.ErrNotExist) {66+ return nil, nil67+ }68+ if err != nil {69+ return nil, err70+ }71+ var out []Endpoint72+ for _, e := range entries {73+ if !strings.HasSuffix(e.Name(), ".json") {74+ continue75+ }76+ path := filepath.Join(dir, e.Name())77+ data, err := os.ReadFile(path)78+ if err != nil {79+ continue80+ }81+ var ep Endpoint82+ if json.Unmarshal(data, &ep) != nil || !alive(ep.Socket) {83+ os.Remove(path)84+ os.Remove(strings.TrimSuffix(path, ".json") + ".sock")85+ continue86+ }87+ out = append(out, ep)88+ }89+ slices.SortFunc(out, func(a, b Endpoint) int { return b.Started.Compare(a.Started) })90+ return out, nil91+}92+93+// alive reports whether a session answers on socket.94+func alive(socket string) bool {95+ c, err := net.DialTimeout("unix", socket, time.Second)96+ if err != nil {97+ return false98+ }99+ c.Close()100+ return true101+}102+103+// Pick is the endpoint target names among eps, and the file an agent104+// of 012 serve joins: "" when only one session listens, a workbook's105+// name, a pid, a socket's path, or with one 012 serve listening, the106+// file to join.107+func Pick(eps []Endpoint, target string) (Endpoint, string, error) {108+ if strings.HasSuffix(target, ".sock") {109+ return Endpoint{Socket: target, Kind: "session"}, "", nil110+ }111+ var match []Endpoint112+ for _, ep := range eps {113+ if target == "" || ep.Kind == "session" && (strings.EqualFold(ep.Workbook, target) || fmt.Sprint(ep.PID) == target ||114+ strings.EqualFold(ep.Workbook, strings.TrimSuffix(filepath.Base(target), filepath.Ext(target)))) {115+ match = append(match, ep)116+ }117+ }118+ if len(match) == 0 && target != "" {119+ for _, ep := range eps {120+ if ep.Kind == "serve" {121+ match = append(match, ep)122+ }123+ }124+ if len(match) == 1 {125+ return match[0], target, nil126+ }127+ }128+ switch len(match) {129+ case 1:130+ if match[0].Kind == "serve" && target == "" {131+ return Endpoint{}, "", fmt.Errorf("012 serve listens for agents on %s: name the file to join, 012 mcp --attach budget.012", match[0].Workbook)132+ }133+ return match[0], "", nil134+ case 0:135+ if target == "" {136+ return Endpoint{}, "", errors.New("no 012 session is listening for agents: start one with 012 --listen, or File > Invite an agent")137+ }138+ return Endpoint{}, "", fmt.Errorf("no 012 session listening for agents has %s open%s", target, listing(eps))139+ }140+ return Endpoint{}, "", fmt.Errorf("several 012 sessions are listening: name one, 012 mcp --attach <name>%s", listing(match))141+}142+143+// listing lists endpoints for an error.144+func listing(eps []Endpoint) string {145+ if len(eps) == 0 {146+ return ""147+ }148+ var b strings.Builder149+ b.WriteString(":")150+ for _, ep := range eps {151+ what := ep.Workbook152+ if ep.Kind == "serve" {153+ what = "012 serve of " + ep.Workbook154+ }155+ fmt.Fprintf(&b, "\n %s (pid %d)", what, ep.PID)156+ }157+ return b.String()158+}@@ -0,0 +1,274 @@
1+package cowork2+3+import (4+ "bufio"5+ "context"6+ "encoding/json"7+ "errors"8+ "fmt"9+ "io"10+ "net"11+ "os"12+ "path/filepath"13+ "strings"14+ "sync"15+ "sync/atomic"16+ "time"17+18+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"19+20+ "github.com/FelineStateMachine/012/internal/mcp"21+ "github.com/FelineStateMachine/012/internal/room"22+ "github.com/FelineStateMachine/012/internal/sheet"23+)24+25+// An attach starts with one line each way before the MCP messages: the26+// agent's hello (which room, and the MCP client's name, read from its27+// initialize request), and the session's welcome (the workbook and the28+// scope, or why not). After that the connection carries MCP's29+// newline-delimited JSON-RPC, as standard input and output would.30+31+// hello is what 012 mcp --attach sends first.32+type hello struct {33+ Version int `json:"version"`34+ Room string `json:"room,omitempty"`35+ Client string `json:"client,omitempty"`36+}37+38+// welcome is the session's answer.39+type welcome struct {40+ Error string `json:"error,omitempty"`41+ Workbook string `json:"workbook,omitempty"`42+ Scope string `json:"scope,omitempty"`43+}44+45+// Options say what a listener serves.46+type Options struct {47+ Registry *room.Registry48+ // Room is the key of the room an agent joins, given the name its49+ // attach gave ("" for none): a session's own, whatever the name, or50+ // in 012 serve the file named, which someone must have open.51+ Room func(name string) (string, error)52+ // Kind and Workbook are what the endpoint file says (Endpoint).53+ Kind, Workbook string54+ // Version is 012's, which the MCP server reports.55+ Version string56+ // Dir is the folder to listen in; "" is SocketDir.57+ Dir string58+}59+60+// Listener is a session listening for agents.61+type Listener struct {62+ opts Options63+ ln net.Listener64+ socket string65+ file string66+67+ mu sync.Mutex68+ conns map[net.Conn]bool69+ closed bool70+ wg sync.WaitGroup71+}72+73+// listeners numbers the sockets of one process.74+var listeners atomic.Int6475+76+// Listen starts listening for agents, on a socket only the user can77+// reach.78+func Listen(o Options) (*Listener, error) {79+ dir := o.Dir80+ if dir == "" {81+ var err error82+ if dir, err = SocketDir(); err != nil {83+ return nil, err84+ }85+ }86+ if err := ownDir(dir); err != nil {87+ return nil, err88+ }89+ base := filepath.Join(dir, fmt.Sprintf("%d-%d", os.Getpid(), listeners.Add(1)))90+ l := &Listener{opts: o, socket: base + ".sock", file: base + ".json", conns: map[net.Conn]bool{}}91+ os.Remove(l.socket)92+ ln, err := net.Listen("unix", l.socket)93+ if err != nil {94+ return nil, err95+ }96+ l.ln = ln97+ if err := os.Chmod(l.socket, 0o600); err != nil {98+ ln.Close()99+ return nil, err100+ }101+ data, _ := json.Marshal(Endpoint{PID: os.Getpid(), Socket: l.socket, Kind: o.Kind, Workbook: o.Workbook, Started: time.Now()})102+ if err := os.WriteFile(l.file, data, 0o600); err != nil {103+ ln.Close()104+ return nil, err105+ }106+ l.wg.Add(1)107+ go l.accept()108+ return l, nil109+}110+111+// Socket is the socket's path.112+func (l *Listener) Socket() string { return l.socket }113+114+// Close stops listening and ends every agent's connection; they leave115+// their rooms once their servers stop, which Wait waits for. Close may116+// be called in a turn: it takes no room's lock.117+func (l *Listener) Close() error {118+ l.mu.Lock()119+ if l.closed {120+ l.mu.Unlock()121+ return nil122+ }123+ l.closed = true124+ for c := range l.conns {125+ c.Close()126+ }127+ l.mu.Unlock()128+ err := l.ln.Close()129+ os.Remove(l.file)130+ os.Remove(l.socket)131+ return err132+}133+134+// Wait waits for the connections to end after Close. It takes rooms'135+// locks, so not in a turn.136+func (l *Listener) Wait() { l.wg.Wait() }137+138+func (l *Listener) accept() {139+ defer l.wg.Done()140+ for {141+ c, err := l.ln.Accept()142+ if err != nil {143+ return144+ }145+ l.mu.Lock()146+ if l.closed {147+ l.mu.Unlock()148+ c.Close()149+ return150+ }151+ l.conns[c] = true152+ l.wg.Add(1)153+ l.mu.Unlock()154+ go func() {155+ defer l.wg.Done()156+ l.serve(c)157+ l.mu.Lock()158+ delete(l.conns, c)159+ l.mu.Unlock()160+ }()161+ }162+}163+164+// serve is one agent's connection: the hello, a seat in the room, and165+// the MCP server on it until the agent goes.166+func (l *Listener) serve(c net.Conn) {167+ defer c.Close()168+ br := bufio.NewReader(c)169+ seat, err := l.greet(c, br)170+ if err != nil {171+ if !errors.Is(err, io.EOF) {172+ json.NewEncoder(c).Encode(welcome{Error: err.Error()})173+ }174+ return175+ }176+ a := NewAgent(seat.seat)177+ defer a.Leave()178+ if err := json.NewEncoder(c).Encode(welcome{Workbook: a.Name(), Scope: a.Scope()}); err != nil {179+ return180+ }181+ ctx, cancel := context.WithCancel(context.Background())182+ defer cancel()183+ srv := mcp.New(mcp.Options{Version: l.opts.Version, Live: a})184+ ss, err := srv.Connect(ctx, &sdk.IOTransport{Reader: io.NopCloser(br), Writer: c}, nil)185+ if err != nil {186+ return187+ }188+ go a.watch(ctx, seat.kick, ss)189+ ss.Wait()190+}191+192+// greeted is the seat an agent was given, and what tells it the room193+// changed.194+type greeted struct {195+ seat *room.Seat196+ kick chan struct{}197+}198+199+// greet reads the agent's hello and seats it in its room.200+func (l *Listener) greet(c net.Conn, br *bufio.Reader) (greeted, error) {201+ if err := checkPeer(c); err != nil {202+ return greeted{}, err203+ }204+ c.SetReadDeadline(time.Now().Add(10 * time.Second))205+ line, err := br.ReadBytes('\n')206+ if err != nil {207+ return greeted{}, io.EOF // a probe (Endpoints) or a client gone208+ }209+ c.SetReadDeadline(time.Time{})210+ var h hello211+ if err := json.Unmarshal(line, &h); err != nil || h.Version != 1 {212+ return greeted{}, errors.New("this isn't 012 mcp --attach of the same version: update the one the agent runs")213+ }214+ key, err := l.opts.Room(h.Room)215+ if err != nil {216+ return greeted{}, err217+ }218+ link := &agentLink{name: agentName(h.Client), kick: make(chan struct{}, 1)}219+ seat, ok := l.opts.Registry.JoinOpen(key, link)220+ if !ok {221+ return greeted{}, errors.New("nobody has that workbook open any more")222+ }223+ return greeted{seat: seat, kick: link.kick}, nil224+}225+226+// agentName is how the others see the agent: its MCP client's name.227+func agentName(client string) string {228+ client = strings.TrimSpace(client)229+ if client == "" {230+ return "agent"231+ }232+ if r := []rune(client); len(r) > 20 {233+ client = string(r[:20])234+ }235+ return client236+}237+238+// agentLink is the agent as a room's participant: telling it the room239+// changed kicks its watch.240+type agentLink struct {241+ name string242+ kick chan struct{}243+}244+245+func (p *agentLink) Name() string { return p.name }246+func (p *agentLink) Agent() {}247+248+func (p *agentLink) Notify() {249+ select {250+ case p.kick <- struct{}{}:251+ default:252+ }253+}254+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) {258+ for {259+ select {260+ case <-ctx.Done():261+ return262+ case <-kick:263+ }264+ for _, n := range a.report() {265+ ss.Log(ctx, &sdk.LoggingMessageParams{Level: "notice", Logger: "012", Data: n})266+ }267+ closed := false268+ a.seat.Do(func(*sheet.Workbook) { closed = a.seat.Closed() })269+ if closed {270+ ss.Close()271+ return272+ }273+ }274+}@@ -0,0 +1,202 @@
1+package cowork2+3+import (4+ "bufio"5+ "context"6+ "encoding/json"7+ "io"8+ "net"9+ "os"10+ "path/filepath"11+ "strings"12+ "testing"13+ "time"14+15+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"16+17+ "github.com/FelineStateMachine/012/internal/room"18+ "github.com/FelineStateMachine/012/internal/sheet"19+)20+21+// socketDir is a short folder for sockets: their paths are limited to22+// about 100 bytes.23+func socketDir(t *testing.T) string {24+ t.Helper()25+ d, err := os.MkdirTemp("", "o12")26+ if err != nil {27+ t.Fatal(err)28+ }29+ t.Cleanup(func() { os.RemoveAll(d) })30+ return filepath.Join(d, "run")31+}32+33+// listening is a session of ann's on budget.012 listening for agents.34+func listening(t *testing.T) (*room.Seat, *Listener) {35+ t.Helper()36+ g := room.NewRegistry(room.Edit)37+ ann, _ := g.Join("/home/ann/budget.012", &person{"ann"}, func() *sheet.Workbook {38+ w := sheet.New().Book()39+ w.Sheet(0).Set(sheet.Addr{}, "1")40+ w.Sheet(0).Set(sheet.Addr{Row: 1}, "=A1*2")41+ return w42+ })43+ ann.Do(func(*sheet.Workbook) { ann.SetSaved(room.Saved{Name: "budget.012"}) })44+ l, err := Listen(Options{Registry: g, Room: func(string) (string, error) { return "/home/ann/budget.012", nil },45+ Kind: "session", Workbook: "budget", Dir: socketDir(t)})46+ if err != nil {47+ t.Fatal(err)48+ }49+ t.Cleanup(func() { l.Close(); l.Wait() })50+ return ann, l51+}52+53+// attachClient connects an MCP client to the listener as 012 mcp54+// --attach does.55+func attachClient(t *testing.T, l *Listener, name string) *sdk.ClientSession {56+ t.Helper()57+ conn, err := net.Dial("unix", l.Socket())58+ if err != nil {59+ t.Fatal(err)60+ }61+ json.NewEncoder(conn).Encode(hello{Version: 1, Client: name})62+ r := bufio.NewReader(conn)63+ line, _ := r.ReadBytes('\n')64+ var w welcome65+ if err := json.Unmarshal(line, &w); err != nil || w.Error != "" || w.Workbook != "budget" || w.Scope != "the whole workbook" {66+ t.Fatalf("welcome %s: %v", line, err)67+ }68+ c := sdk.NewClient(&sdk.Implementation{Name: name, Version: "1"}, nil)69+ cs, err := c.Connect(context.Background(), &sdk.IOTransport{Reader: io.NopCloser(r), Writer: conn}, nil)70+ if err != nil {71+ t.Fatal(err)72+ }73+ t.Cleanup(func() { cs.Close() })74+ return cs75+}76+77+func callTool(t *testing.T, cs *sdk.ClientSession, name string, args any) map[string]any {78+ t.Helper()79+ res, err := cs.CallTool(context.Background(), &sdk.CallToolParams{Name: name, Arguments: args})80+ if err != nil {81+ t.Fatalf("%s: %v", name, err)82+ }83+ if res.IsError {84+ t.Fatalf("%s: %s", name, res.Content[0].(*sdk.TextContent).Text)85+ }86+ var out map[string]any87+ data, _ := json.Marshal(res.StructuredContent)88+ json.Unmarshal(data, &out)89+ return out90+}91+92+// An agent attached over the socket sits in the room, reads the93+// workbook as it is, suggests, and hears what became of it.94+func TestAttachedAgentWorksInTheRoom(t *testing.T) {95+ ann, l := listening(t)96+ info, err := os.Stat(l.Socket())97+ if err != nil || info.Mode().Perm() != 0o600 {98+ t.Fatalf("the socket's permissions: %v %v", info.Mode(), err)99+ }100+ cs := attachClient(t, l, "claude")101+ var peers []room.Peer102+ ann.Do(func(*sheet.Workbook) { peers = ann.Peers() })103+ if len(peers) != 1 || peers[0].Name != "claude" || !peers[0].Agent {104+ t.Fatalf("peers %+v", peers)105+ }106+ read := callTool(t, cs, "read_range", map[string]any{"ref": "A1:A2"})107+ if rows := read["values"].([]any); rows[1].([]any)[0] != 2.0 {108+ t.Fatalf("read %v", read)109+ }110+ ann.Do(func(w *sheet.Workbook) { w.Sheet(0).Set(sheet.Addr{}, "5") })111+ read = callTool(t, cs, "read_range", map[string]any{"ref": "A1:A2"})112+ if rows := read["values"].([]any); rows[1].([]any)[0] != 10.0 {113+ t.Fatalf("the agent doesn't see ann's edit: %v", read)114+ }115+ wrote := callTool(t, cs, "write_cells", map[string]any{"entries": []map[string]any{{"ref": "B1", "input": "=A2+1"}}, "message": "one more"})116+ if wrote["suggestion"] != 1.0 || wrote["saved"] != false {117+ t.Fatalf("write %v", wrote)118+ }119+ focus := callTool(t, cs, "focus", map[string]any{"ref": "C3"})120+ if focus["at"] != "Sheet1!C3" {121+ t.Fatalf("focus %v", focus)122+ }123+ ann.Do(func(w *sheet.Workbook) {124+ if err := BoardOf(ann).Accept(w, 1, nil); err != nil {125+ t.Error(err)126+ }127+ })128+ if v := value(ann, "Sheet1", "B1"); v.Num != 11 {129+ t.Fatalf("B1 is %v", v)130+ }131+ list := callTool(t, cs, "suggestions", nil)132+ if s := list["suggestions"].([]any)[0].(map[string]any); s["state"] != "accepted" {133+ t.Fatalf("suggestions %v", list)134+ }135+ undone := callTool(t, cs, "undo", nil)136+ if undone["undone"] != "set" {137+ t.Fatalf("undo %v", undone)138+ }139+ cs.Close()140+ deadline := time.Now().Add(5 * time.Second)141+ for len(peers) > 0 && time.Now().Before(deadline) {142+ ann.Do(func(*sheet.Workbook) { peers = ann.Peers() })143+ time.Sleep(5 * time.Millisecond)144+ }145+ if len(peers) != 0 {146+ t.Fatal("the agent didn't leave with its connection")147+ }148+}149+150+// The endpoint is listed for 012 mcp --attach, and goes with the151+// listener.152+func TestEndpointsListSessions(t *testing.T) {153+ _, l := listening(t)154+ dir := filepath.Dir(l.Socket())155+ eps, err := Endpoints(dir)156+ if err != nil || len(eps) != 1 || eps[0].Workbook != "budget" || eps[0].PID != os.Getpid() {157+ t.Fatalf("endpoints %+v, %v", eps, err)158+ }159+ if ep, _, err := Pick(eps, ""); err != nil || ep.Socket != l.Socket() {160+ t.Fatalf("picking the only one: %v", err)161+ }162+ if ep, _, err := Pick(eps, "budget.012"); err != nil || ep.Socket != l.Socket() {163+ t.Fatalf("picking by name: %v", err)164+ }165+ if _, _, err := Pick(eps, "plan"); err == nil || !strings.Contains(err.Error(), "budget (pid") {166+ t.Fatalf("picking one not there: %v", err)167+ }168+ if _, _, err := Pick(append(eps, Endpoint{Workbook: "plan", Kind: "session"}), ""); err == nil || !strings.Contains(err.Error(), "several") {169+ t.Fatalf("picking among two: %v", err)170+ }171+ if info, err := os.Stat(dir); err != nil || info.Mode().Perm() != 0o700 {172+ t.Fatalf("the folder: %v %v", info.Mode(), err)173+ }174+ l.Close()175+ if eps, _ := Endpoints(dir); len(eps) != 0 {176+ t.Fatalf("still listed: %+v", eps)177+ }178+}179+180+// A socket others can reach isn't attached to.181+func TestAttachRefusesSocketsOthersReach(t *testing.T) {182+ _, l := listening(t)183+ os.Chmod(l.Socket(), 0o666)184+ if err := checkOwner(l.Socket()); err == nil {185+ t.Fatal("a socket anyone can reach passed")186+ }187+}188+189+// A hello that isn't 012 mcp --attach's is refused.190+func TestListenerRefusesStrangers(t *testing.T) {191+ _, l := listening(t)192+ conn, err := net.Dial("unix", l.Socket())193+ if err != nil {194+ t.Fatal(err)195+ }196+ defer conn.Close()197+ conn.Write([]byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}` + "\n"))198+ line, _ := bufio.NewReader(conn).ReadBytes('\n')199+ if !strings.Contains(string(line), "012 mcp --attach") {200+ t.Fatalf("got %s", line)201+ }202+}@@ -0,0 +1,15 @@
1+//go:build darwin || freebsd2+3+package cowork4+5+import "golang.org/x/sys/unix"6+7+// peerUID is the user of the process at the other end of a Unix8+// socket, as the kernel says (LOCAL_PEERCRED).9+func peerUID(fd uintptr) (int, error) {10+ c, err := unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED)11+ if err != nil {12+ return 0, err13+ }14+ return int(c.Uid), nil15+}@@ -0,0 +1,13 @@
1+package cowork2+3+import "golang.org/x/sys/unix"4+5+// peerUID is the user of the process at the other end of a Unix6+// socket, as the kernel says (SO_PEERCRED).7+func peerUID(fd uintptr) (int, error) {8+ c, err := unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)9+ if err != nil {10+ return 0, err11+ }12+ return int(c.Uid), nil13+}@@ -0,0 +1,12 @@
1+//go:build !linux && !darwin && !freebsd2+3+package cowork4+5+import "net"6+7+// checkPeer has no kernel to ask here (Windows, the other BSDs): the8+// socket's folder, the user's own, keeps other users out.9+func checkPeer(net.Conn) error { return nil }10+11+// checkOwner leaves ownership to the folder's permissions here.12+func checkOwner(string) error { return nil }@@ -0,0 +1,56 @@
1+//go:build linux || darwin || freebsd2+3+package cowork4+5+import (6+ "errors"7+ "fmt"8+ "net"9+ "os"10+ "syscall"11+)12+13+// checkPeer refuses a connection from another user's process: the14+// socket's permissions already keep them out, and this holds even if15+// they were loosened.16+func checkPeer(c net.Conn) error {17+ uc, ok := c.(*net.UnixConn)18+ if !ok {19+ return errors.New("not a Unix socket")20+ }21+ raw, err := uc.SyscallConn()22+ if err != nil {23+ return err24+ }25+ uid, perr := 0, error(nil)26+ if err := raw.Control(func(fd uintptr) { uid, perr = peerUID(fd) }); err != nil {27+ return err28+ }29+ if perr != nil {30+ return perr31+ }32+ if uid != os.Getuid() {33+ return fmt.Errorf("refused a connection from user %d: only %d's own processes may attach", uid, os.Getuid())34+ }35+ return nil36+}37+38+// checkOwner refuses a file or folder another user owns, or that39+// others can reach.40+func checkOwner(path string) error {41+ info, err := os.Stat(path)42+ if err != nil {43+ return err44+ }45+ st, ok := info.Sys().(*syscall.Stat_t)46+ if !ok {47+ return nil48+ }49+ if int(st.Uid) != os.Getuid() {50+ return fmt.Errorf("%s belongs to user %d, not you: 012 attaches only to your own sessions", path, st.Uid)51+ }52+ if info.Mode().Perm()&0o077 != 0 {53+ return fmt.Errorf("%s can be reached by other users (%v): 012 attaches only to sockets that are yours alone", path, info.Mode().Perm())54+ }55+ return nil56+}@@ -137,6 +137,9 @@ func isWorkbook(name string) (own, readable bool) {
137137 // book is the workbook a tool's path names for the client of req, or138138 // the default one when the path is empty.139139 func (s *Server) book(ctx context.Context, ss *sdk.ServerSession, name string) (*book, error) {140+ if l := s.opts.Live; l != nil {141+ return s.liveBook(l, name)142+ }140143 roots := s.roots(ctx, ss)141144 var path string142145 switch {@@ -162,6 +165,16 @@ func (s *Server) book(ctx context.Context, ss *sdk.ServerSession, name string) (
162165 return &book{Backend: s.backend(path), path: path, name: displayName(roots, path)}, nil163166 }164167 168+// liveBook is the live workbook, which a path, when given, must name.169+func (s *Server) liveBook(l Live, name string) (*book, error) {170+ n := l.Name()171+ base := strings.TrimSuffix(filepath.Base(filepath.FromSlash(name)), filepath.Ext(name))172+ if name != "" && name != n && base != n {173+ return nil, fmt.Errorf("this server is attached to %s, open on a person's screen, and works on it alone: leave path out", n)174+ }175+ return &book{Backend: l, path: n, name: n}, nil176+}177+165178 // backend is the one backend of the file at path, so calls on the same166179 // file take turns.167180 func (s *Server) backend(path string) Backend {@@ -0,0 +1,147 @@
1+package mcp2+3+import (4+ "context"5+6+ sdk "github.com/modelcontextprotocol/go-sdk/mcp"7+8+ "github.com/FelineStateMachine/012/internal/diff"9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// Live mode (docs/agents/live.md): the server runs inside a 012 session13+// with its workbook on someone's screen, and the agent is one of the14+// participants of the session's room. Every tool works on that one15+// workbook, whose Backend is a Live: reads see it as it is on the16+// screen, and a change is the agent's, a suggestion the person accepts17+// or rejects unless they let agents edit directly. Live mode adds the18+// tools only a live workbook has: focus, undo, ask and suggestions.19+20+// Live is a workbook open in a session, as the agent attached to it21+// works on it.22+type Live interface {23+ Backend24+ // Make makes a change as the agent's, or suggests it.25+ Make(ctx context.Context, m Making) (Made, error)26+ // Focus moves the agent's pointer to ref, for the person to see27+ // where it's looking, and says where that is.28+ Focus(ctx context.Context, ref string) (string, error)29+ // Undo takes back the agent's latest step, saying what it was.30+ Undo(ctx context.Context) (string, error)31+ // Ask asks the person a question, as an elicitation request asks a32+ // host's user, and returns their answer.33+ Ask(ctx context.Context, p *sdk.ElicitParams) (*sdk.ElicitResult, error)34+ // Suggestions are the agent's suggestions and what became of them.35+ Suggestions(ctx context.Context) ([]Suggestion, error)36+ // RunCell has the person's session run a notebook cell, once they37+ // allow it, and says what happens next.38+ RunCell(ctx context.Context, notebook string, cell int) (string, error)39+ // Scope is what the person let the agent change, in words.40+ Scope() string41+}42+43+// Making is a change a tool asks for: its label, the agent's message44+// shown with it, whether only to see it, and the change itself.45+type Making struct {46+ Label, Message string47+ DryRun bool48+ Fn func(*sheet.Workbook) error49+}50+51+// Made is what came of a change: what it changes, whether it was made52+// on the workbook or waits as a suggestion, and news of the agent's53+// earlier suggestions.54+type Made struct {55+ Changes []diff.Change56+ Applied bool57+ Suggestion int58+ Notices []string59+}60+61+// Suggestion is one of the agent's suggestions as the suggestions tool62+// lists it.63+type Suggestion struct {64+ ID int `json:"id"`65+ Label string `json:"label"`66+ Message string `json:"message,omitempty"`67+ State string `json:"state" jsonschema:"pending, accepted, accepted in part or rejected"`68+ Cells int `json:"cells" jsonschema:"the cells it sets"`69+ Accepted int `json:"accepted" jsonschema:"the cells the person accepted"`70+ Rejected int `json:"rejected" jsonschema:"the cells the person rejected"`71+}72+73+const liveInstructions = `This server is attached to a workbook open on a person's screen in 012 (live mode): every tool works on that one workbook, so leave path out. Reads see it as it is now, the person's edits included.74+Your changes are suggestions unless the person lets agents edit directly: the cells you'd change are marked on their screen, and they accept or reject them, whole or cell by cell. A write returns the suggestion's number; the suggestions tool says what became of each, and news of them also comes back with your next write. Pass message with a write to tell the person why.75+Call focus to move your pointer where you're working, so the person sees where you're looking. undo takes back your own latest change, never the person's. ask asks the person a question and waits for the answer, as elicitation does: use it before anything they might not expect.76+You may change only what the person let you (the scope); a change outside it is refused with the reason.`77+78+type focusIn struct {79+ Ref string `json:"ref" jsonschema:"the cell or range to point at: B7, Q3!A1:C9, a named range or a table"`80+}81+82+type focusOut struct {83+ At string `json:"at" jsonschema:"where the pointer is, with its sheet"`84+}85+86+type undoOut struct {87+ Undone string `json:"undone" jsonschema:"what the step undone did"`88+}89+90+type askIn struct {91+ Message string `json:"message" jsonschema:"the question, shown to the person"`92+ RequestedSchema map[string]any `json:"requestedSchema,omitempty" jsonschema:"what to ask for, as an elicitation request's: an object schema of top-level string, number, integer and boolean properties, strings with enum for a choice; none for a yes or no question"`93+}94+95+type askOut struct {96+ Action string `json:"action" jsonschema:"accept, decline or cancel, as an elicitation's answer"`97+ Content map[string]any `json:"content,omitempty" jsonschema:"with accept, the answers by property"`98+}99+100+type suggestionsOut struct {101+ Scope string `json:"scope" jsonschema:"what the person let you change"`102+ Suggestions []Suggestion `json:"suggestions"`103+}104+105+type liveRunOut struct {106+ Status string `json:"status"`107+}108+109+// addLiveTools adds the tools of a live workbook.110+func (s *Server) addLiveTools(l Live) {111+ tool(s, &sdk.Tool{Name: "focus", Annotations: &sdk.ToolAnnotations{IdempotentHint: true, OpenWorldHint: ptr(false)},112+ Description: "Move your pointer to a cell or range, where the person sees it with your name, to show where you're working."},113+ func(ctx context.Context, _ *sdk.CallToolRequest, in focusIn) (*sdk.CallToolResult, focusOut, error) {114+ at, err := l.Focus(ctx, in.Ref)115+ return nil, focusOut{At: at}, err116+ })117+ tool(s, &sdk.Tool{Name: "undo", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(true), OpenWorldHint: ptr(false)},118+ Description: "Take back your own latest change that was made (accepted, or made directly), as Edit > Undo does for the person: never theirs."},119+ func(ctx context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, undoOut, error) {120+ label, err := l.Undo(ctx)121+ return nil, undoOut{Undone: label}, err122+ })123+ tool(s, &sdk.Tool{Name: "ask", Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: ptr(false)},124+ Description: "Ask the person a question on their screen and wait for the answer: yes or no, a choice, or values, as an elicitation request asks. Use it before a change they might not expect (\"Overwrite B2:B40?\")."},125+ func(ctx context.Context, _ *sdk.CallToolRequest, in askIn) (*sdk.CallToolResult, askOut, error) {126+ res, err := l.Ask(ctx, &sdk.ElicitParams{Message: in.Message, RequestedSchema: in.RequestedSchema})127+ if err != nil {128+ return nil, askOut{}, err129+ }130+ return nil, askOut{Action: res.Action, Content: res.Content}, nil131+ })132+ tool(s, &sdk.Tool{Name: "suggestions", Annotations: readOnly,133+ Description: "Your suggestions and what became of each: pending, accepted, accepted in part or rejected, with how many cells; and what the person let you change."},134+ func(ctx context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, suggestionsOut, error) {135+ list, err := l.Suggestions(ctx)136+ if list == nil {137+ list = []Suggestion{}138+ }139+ return nil, suggestionsOut{Scope: l.Scope(), Suggestions: list}, err140+ })141+ tool(s, &sdk.Tool{Name: "run_notebook_cell", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(false), OpenWorldHint: ptr(true)},142+ Description: "Have the person's session run a notebook tab's code cell with nushell, once they allow it: its output arrives in the workbook as when they run it, to read with describe or read_range."},143+ func(ctx context.Context, _ *sdk.CallToolRequest, in runCellIn) (*sdk.CallToolResult, liveRunOut, error) {144+ status, err := l.RunCell(ctx, in.Notebook, in.Cell)145+ return nil, liveRunOut{Status: status}, err146+ })147+}@@ -113,7 +113,7 @@ func describeSchema() *jsonschema.Schema {
113113 }114114 115115 func (s *Server) describe(ctx context.Context, req *sdk.CallToolRequest, in describeIn) (*sdk.CallToolResult, describeOut, error) {116- if in.Path == "" && s.opts.Default == "" {116+ if in.Path == "" && s.opts.Default == "" && s.opts.Live == nil {117117 roots := s.roots(ctx, req.Session)118118 files, more := s.listFiles(roots)119119 return nil, describeOut{Roots: rootDirs(roots), Workbooks: files, More: more}, nil@@ -57,7 +57,11 @@ 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- return &sdk.ServerOptions{Instructions: instructions, RootsListChangedHandler: s.forgetRoots,60+ text := instructions61+ if s.opts.Live != nil {62+ text = liveInstructions63+ }64+ return &sdk.ServerOptions{Instructions: text, RootsListChangedHandler: s.forgetRoots,6165 Capabilities: &sdk.ServerCapabilities{Extensions: map[string]any{uiExtension: map[string]any{}}}}6266 }6367 @@ -47,6 +47,10 @@ type Options struct {
4747 // JEV functions); Save writes a changed one, nil being File.Save.4848 Prepare func(context.Context, *headless.File) error4949 Save func(*headless.File) error50+ // Live, when set, is the one workbook of a server attached to a51+ // session (live mode, live.go): every tool works on it, and the52+ // live tools are added. Roots, Default and Notebooks go unused.53+ Live Live5054 }5155 5256 // Server is the MCP server on the workbooks open to it.@@ -74,10 +78,13 @@ func New(o Options) *Server {
7478 if !o.ReadOnly {7579 s.addWriteTools()7680 }81+ if o.Live != nil {82+ s.addLiveTools(o.Live)83+ }7784 s.addResources()7885 s.addPrompts()7986 s.addViews()80- if o.Default != "" {87+ if o.Default != "" || o.Live != nil {8188 if b, err := s.book(context.Background(), nil, ""); err == nil {8289 s.used(context.Background(), b)8390 }@@ -115,7 +122,7 @@ func tool[In, Out any](s *Server, t *sdk.Tool, h func(context.Context, *sdk.Call
115122 if err != nil {116123 panic(err)117124 }118- if _, ok := schema.Properties["path"]; ok && s.opts.Default == "" && t.Name != "describe" && !slices.Contains(schema.Required, "path") {125+ if _, ok := schema.Properties["path"]; ok && s.opts.Default == "" && s.opts.Live == nil && t.Name != "describe" && !slices.Contains(schema.Required, "path") {119126 schema.Required = append(schema.Required, "path")120127 }121128 t.InputSchema = schema@@ -13,20 +13,28 @@ import (
1313 // Change, so one undo step and one save, and returns what changed as1414 // 012 diff lists it; dry_run returns it without keeping it.1515 16+// whyIn is a change's message, in live mode.17+type whyIn struct {18+ Message string `json:"message,omitempty" jsonschema:"live mode: why, shown to the person with your suggestion"`19+}20+1621 type writeIn struct {1722 bookIn23+ whyIn1824 Entries []headless.Entry `json:"entries" jsonschema:"the cells to set, in order: each a ref (one cell) and its input, typed as a person types it in en-US form; an empty input clears the cell"`1925 DryRun bool `json:"dry_run,omitempty" jsonschema:"check the entries and return the change without making it"`2026 }2127 2228 type opsIn struct {2329 bookIn30+ whyIn2431 Operations []headless.Operation `json:"operations" jsonschema:"the operations, made in order as one change"`2532 DryRun bool `json:"dry_run,omitempty" jsonschema:"return the change without making it"`2633 }2734 2835 type sortIn struct {2936 bookIn37+ whyIn3038 Ref string `json:"ref" jsonschema:"the range to sort"`3139 Keys []headless.SortKey `json:"keys" jsonschema:"the columns to sort by, first first"`3240 Header bool `json:"header,omitempty" jsonschema:"the first row is a header and stays in place"`@@ -35,6 +43,7 @@ type sortIn struct {
3543 3644 type filterIn struct {3745 bookIn46+ whyIn3847 Ref string `json:"ref" jsonschema:"the range to filter, its first row the header"`3948 Columns []headless.FilterColumn `json:"columns,omitempty" jsonschema:"each filtered column's criteria"`4049 Remove bool `json:"remove,omitempty" jsonschema:"take the sheet's filter off instead"`@@ -43,12 +52,14 @@ type filterIn struct {
4352 4453 type chartIn struct {4554 bookIn55+ whyIn4656 headless.ChartSpec4757 DryRun bool `json:"dry_run,omitempty"`4858 }4959 5060 type pivotIn struct {5161 bookIn62+ whyIn5263 headless.PivotSpec5364 DryRun bool `json:"dry_run,omitempty"`5465 }@@ -65,6 +76,10 @@ type Changed struct {
6576 Saved bool `json:"saved"`6677 Changes []headless.Change `json:"changes"`6778 Warnings []string `json:"warnings"`79+ // Suggestion and Notices are live mode's: the change waiting for80+ // the person, and what became of earlier ones.81+ Suggestion int `json:"suggestion,omitempty" jsonschema:"live mode: the suggestion's number, waiting for the person to accept or reject it"`82+ Notices []string `json:"notices,omitempty" jsonschema:"live mode: what became of your earlier suggestions"`6883 }6984 7085 type chartOut struct {@@ -85,7 +100,7 @@ func (s *Server) addWriteTools() {
85100 Description: "Type entries into cells as one change, as 012 set does: each checked as typing it is (a formula must parse, validation rules, protected ranges), stopping at the first a cell can't take."},86101 func(ctx context.Context, req *sdk.CallToolRequest, in writeIn) (*sdk.CallToolResult, Changed, error) {87102 var warnings []string88- return s.change(ctx, req, in.Path, "set", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {103+ return s.change(ctx, req, in.Path, "set", in.Message, in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {89104 warnings, err = headless.Set(w, in.Entries, set)90105 return err91106 })@@ -94,19 +109,19 @@ func (s *Server) addWriteTools() {
94109 Description: "Make several operations as one change: set, clear, insert_rows, delete_rows, insert_columns, delete_columns, add_sheet, rename_sheet, delete_sheet, define_name, sort."},95110 func(ctx context.Context, req *sdk.CallToolRequest, in opsIn) (*sdk.CallToolResult, Changed, error) {96111 var warnings []string97- return s.change(ctx, req, in.Path, "apply operations", in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {112+ return s.change(ctx, req, in.Path, "apply operations", in.Message, in.DryRun, &warnings, func(w *sheet.Workbook) (err error) {98113 warnings, err = headless.Apply(w, in.Operations, set)99114 return err100115 })101116 })102117 tool(s, &sdk.Tool{Name: "sort", Annotations: writes, Description: "Sort a range's rows by columns, as Data > Sort range does."},103118 func(ctx context.Context, req *sdk.CallToolRequest, in sortIn) (*sdk.CallToolResult, Changed, error) {104- return s.change(ctx, req, in.Path, "sort", in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Sort(w, in.Ref, in.Keys, in.Header) })119+ return s.change(ctx, req, in.Path, "sort", in.Message, in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Sort(w, in.Ref, in.Keys, in.Header) })105120 })106121 tool(s, &sdk.Tool{Name: "filter", Annotations: writes,107122 Description: "Put a filter on a range, hiding the rows its columns' conditions or values leave out, as Data > Create a filter does; or remove the sheet's filter."},108123 func(ctx context.Context, req *sdk.CallToolRequest, in filterIn) (*sdk.CallToolResult, Changed, error) {109- return s.change(ctx, req, in.Path, "filter", in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Filter(w, in.Ref, in.Columns, in.Remove) })124+ return s.change(ctx, req, in.Path, "filter", in.Message, in.DryRun, nil, func(w *sheet.Workbook) error { return headless.Filter(w, in.Ref, in.Columns, in.Remove) })110125 })111126 tool(s, &sdk.Tool{Name: "create_chart", Annotations: writes, Meta: viewMeta("Adding the chart", "Added the chart"),112127 Description: "Add a chart of a range, as Insert > Chart does, with the header row, labels and title guessed unless given."},@@ -115,13 +130,16 @@ func (s *Server) addWriteTools() {
115130 Description: "Add a pivot table on a new sheet, summarizing a range whose first row names its columns."},116131 func(ctx context.Context, req *sdk.CallToolRequest, in pivotIn) (*sdk.CallToolResult, pivotOut, error) {117132 var out pivotOut118- _, changed, err := s.change(ctx, req, in.Path, "create pivot table", in.DryRun, nil, func(w *sheet.Workbook) (err error) {133+ _, changed, err := s.change(ctx, req, in.Path, "create pivot table", in.Message, in.DryRun, nil, func(w *sheet.Workbook) (err error) {119134 out.Sheet, err = headless.AddPivot(w, in.PivotSpec)120135 return err121136 })122137 out.Changed = changed123138 return nil, out, err124139 })140+ if s.opts.Live != nil {141+ return // the live tools run notebook cells; there are no files to create142+ }125143 s.addCreateTool()126144 if s.opts.Notebooks != nil {127145 tool(s, &sdk.Tool{Name: "run_notebook_cell", Annotations: &sdk.ToolAnnotations{DestructiveHint: ptr(false), OpenWorldHint: ptr(true)},@@ -137,7 +155,7 @@ func (s *Server) createChart(ctx context.Context, req *sdk.CallToolRequest, in c
137155 if err != nil {138156 return nil, out, err139157 }140- _, changed, err := s.change(ctx, req, in.Path, "insert chart", in.DryRun, nil, func(w *sheet.Workbook) (err error) {158+ _, changed, err := s.change(ctx, req, in.Path, "insert chart", in.Message, in.DryRun, nil, func(w *sheet.Workbook) (err error) {141159 if out.Chart, err = headless.AddChart(w, in.ChartSpec); err != nil {142160 return err143161 }@@ -157,7 +175,7 @@ func (s *Server) runCell(ctx context.Context, req *sdk.CallToolRequest, in runCe
157175 if err != nil {158176 return nil, out, err159177 }160- _, _, err = s.change(ctx, req, in.Path, "run notebook cell", false, nil, func(w *sheet.Workbook) error {178+ _, _, err = s.change(ctx, req, in.Path, "run notebook cell", "", false, nil, func(w *sheet.Workbook) error {161179 if s.opts.MayRun != nil {162180 if err := s.opts.MayRun(b.path, w); err != nil {163181 return err@@ -172,16 +190,23 @@ func (s *Server) runCell(ctx context.Context, req *sdk.CallToolRequest, in runCe
172190 173191 // change makes fn a change of the workbook path names and says what it174192 // changed; warnings, when set, are fn's.175-func (s *Server) change(ctx context.Context, req *sdk.CallToolRequest, path, label string, dryRun bool, warnings *[]string, fn func(*sheet.Workbook) error) (*sdk.CallToolResult, Changed, error) {193+func (s *Server) change(ctx context.Context, req *sdk.CallToolRequest, path, label, message string, dryRun bool, warnings *[]string, fn func(*sheet.Workbook) error) (*sdk.CallToolResult, Changed, error) {176194 b, err := s.open(ctx, req, path)177195 if err != nil {178196 return nil, Changed{}, err179197 }180- changes, err := b.Change(ctx, label, dryRun, fn)198+ var made Made199+ if l, ok := b.Backend.(Live); ok {200+ made, err = l.Make(ctx, Making{Label: label, Message: message, DryRun: dryRun, Fn: fn})201+ } else {202+ made.Changes, err = b.Change(ctx, label, dryRun, fn)203+ made.Applied = !dryRun204+ }181205 if err != nil {182206 return nil, Changed{}, err183207 }184- out := Changed{Saved: !dryRun && len(changes) > 0, Changes: headless.Changes(changes), Warnings: []string{}}208+ out := Changed{Saved: made.Applied && len(made.Changes) > 0, Changes: headless.Changes(made.Changes), Warnings: []string{},209+ Suggestion: made.Suggestion, Notices: made.Notices}185210 if warnings != nil && *warnings != nil {186211 out.Warnings = *warnings187212 }@@ -44,10 +44,18 @@ func (g *Registry) Rooms() int {
4444 // reports whether it opened the room. A room's history is shared: undo4545 // takes back each participant's own steps.4646 func (g *Registry) Join(key string, p Participant, book func() *sheet.Workbook) (*Seat, bool) {47+ return g.join(key, p, book)48+}49+50+// join is Join; with book nil, it seats p only in a room that's open.51+func (g *Registry) join(key string, p Participant, book func() *sheet.Workbook) (*Seat, bool) {4752 g.mu.Lock()4853 defer g.mu.Unlock()4954 r := g.rooms[key]5055 created := false56+ if r == nil && book == nil {57+ return nil, false58+ }5159 if r == nil {5260 w := book()5361 w.ShareHistory()@@ -60,6 +68,9 @@ func (g *Registry) Join(key string, p Participant, book func() *sheet.Workbook)
6068 defer r.mu.Unlock()6169 r.nextID++6270 s := &Seat{room: r, id: r.nextID, name: p.Name(), color: r.freeColor(), p: p, joined: time.Now()}71+ if _, ok := p.(Agent); ok {72+ s.agent = true73+ }6374 if len(r.seats) == 0 {6475 r.writer = s.id6576 }@@ -68,6 +79,13 @@ func (g *Registry) Join(key string, p Participant, book func() *sheet.Workbook)
6879 return s, created6980 }7081 82+// 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.84+func (g *Registry) JoinOpen(key string, p Participant) (*Seat, bool) {85+ s, _ := g.join(key, p, nil)86+ return s, s != nil87+}88+7189 // freeColor is the first color no one in the room has.7290 func (r *Room) freeColor() int {7391 used := map[int]bool{}@@ -92,14 +110,14 @@ func (g *Registry) alias(r *Room, key string) {
92110 }93111 }94112 95-// close forgets the room once its last seat has left, reporting whether113+// close forgets the room once its last person has left, reporting whether96114 // it did: someone may have joined since.97115 func (g *Registry) close(r *Room) bool {98116 g.mu.Lock()99117 defer g.mu.Unlock()100118 r.mu.Lock()101119 defer r.mu.Unlock()102- if len(r.seats) > 0 || r.closed {120+ if r.keeper() != nil || r.closed {103121 return false104122 }105123 r.closed = true@@ -7,9 +7,10 @@
77 // its undo steps), each attributed to its author, as a stream of88 // operations (sheet.Op) the room hands every participant. A participant99 // is anything with a name that can be told the room changed10-// (Participant): a served session's model. The interface stays that11-// small so an MCP client attached to the server can take part the same12-// way (ROADMAP.md, Agents). See docs/contributing/architecture.md.10+// (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). See13+// docs/contributing/architecture.md.1314 package room1415 1516 import (@@ -33,6 +34,14 @@ type Participant interface {
3334 Notify()3435 }3536 37+// Agent is a participant that is an agent (live mode, an MCP client38+// attached): the others see it marked as one, and it keeps nothing the39+// room runs.40+type Agent interface {41+ Participant42+ Agent()43+}44+3645 // Mode is who may change a room's workbook.3746 type Mode int3847 @@ -103,6 +112,7 @@ type Seat struct {
103112 presence Presence104113 moved bool // presence changed this turn105114 left bool115+ agent bool106116 }107117 108118 // Peer is what a participant sees of another: who, in what color,@@ -114,6 +124,7 @@ type Peer struct {
114124 Presence Presence115125 Writing bool116126 Joined time.Time127+ Agent bool117128 }118129 119130 // Mark is an operation's place in the workbook: who changed which@@ -243,7 +254,28 @@ func (s *Seat) Peers() []Peer {
243254 }244255 245256 func (s *Seat) peer() Peer {246- return Peer{ID: s.id, Name: s.name, Color: s.color, Presence: s.presence, Writing: s.Writing(), Joined: s.joined}257+ return Peer{ID: s.id, Name: s.name, Color: s.color, Presence: s.presence, Writing: s.Writing(), Joined: s.joined, Agent: s.agent}258+}259+260+// Agent reports whether the participant is an agent.261+func (s *Seat) Agent() bool { return s.agent }262+263+// Closed reports whether the room closed: its last person left. Call264+// it in a turn.265+func (s *Seat) Closed() bool { return s.room.closed }266+267+// Touch tells the others, once the turn ends, that something they268+// share changed without the workbook changing: a value of the room's269+// (Value), such as an agent's suggestions. Call it in a turn.270+func (s *Seat) Touch() { s.moved = true }271+272+// Peer is the participant with id, and whether they're here. Call it in273+// a turn.274+func (s *Seat) Peer(id int) (Peer, bool) {275+ if o := s.room.seat(id); o != nil {276+ return o.peer(), true277+ }278+ return Peer{}, false247279 }248280 249281 // Others is how many others are in the room. Call it in a turn.@@ -319,9 +351,20 @@ func (s *Seat) Value(key string, make func() any) any {
319351 }320352 321353 // Keeper reports whether the participant keeps what the room runs on322-// its own (linked files followed, notebook cells running): the one who323-// has been there longest. Call it in a turn.324-func (s *Seat) Keeper() bool { return len(s.room.seats) > 0 && s.room.seats[0] == s }354+// its own (linked files followed, notebook cells running): the person355+// who has been there longest, as agents run nothing of the room's. Call356+// it in a turn.357+func (s *Seat) Keeper() bool { return s.room.keeper() == s }358+359+// keeper is the person there longest, nil when only agents are.360+func (r *Room) keeper() *Seat {361+ for _, s := range r.seats {362+ if !s.agent {363+ return s364+ }365+ }366+ return nil367+}325368 326369 // Post leaves msg for the keeper, whoever it is when it takes it: what327370 // the room runs reports back this way rather than to the participant@@ -331,11 +374,12 @@ func (s *Seat) Post(msg any) {
331374 r := s.room332375 r.mu.Lock()333376 defer r.mu.Unlock()334- if r.closed || len(r.seats) == 0 {377+ k := r.keeper()378+ if r.closed || k == nil {335379 return336380 }337381 r.inbox = append(r.inbox, msg)338- r.seats[0].p.Notify()382+ k.p.Notify()339383 }340384 341385 // Take is what was posted for the keeper, if this is the keeper. Call@@ -349,8 +393,10 @@ func (s *Seat) Take() []any {
349393 return in350394 }351395 352-// Leave gives up the seat, reporting whether it was the last: the room353-// closes then, and its workbook is the participant's alone.396+// Leave gives up the seat, reporting whether it was the last person397+// there: the room closes then, and its workbook is the participant's398+// alone. Agents still there are told, and find the room closed399+// (Closed).354400 func (s *Seat) Leave() (last bool) {355401 r := s.room356402 r.mu.Lock()@@ -361,13 +407,11 @@ func (s *Seat) Leave() (last bool) {
361407 s.left = true362408 i := slices.Index(r.seats, s)363409 r.seats = slices.Delete(r.seats, i, i+1)364- empty := len(r.seats) == 0365- if !empty {366- if r.mode == View && r.writer == s.id {367- r.writer = r.seats[0].id368- }369- r.notify(nil)410+ empty := r.keeper() == nil411+ if !empty && r.mode == View && r.writer == s.id {412+ r.writer = r.keeper().id370413 }414+ r.notify(nil)371415 r.mu.Unlock()372416 if empty {373417 return r.reg.close(r)@@ -167,3 +167,53 @@ func TestRoomTurnsAtOnce(t *testing.T) {
167167 }168168 })169169 }170+171+// bot is an agent participant.172+type bot struct{ who }173+174+func (*bot) Agent() {}175+176+// An agent is marked as one to the others, keeps nothing the room runs177+// even when it was there first, and a touch tells the others.178+func TestRoomAgents(t *testing.T) {179+ g := NewRegistry(Edit)180+ claude := &bot{who{name: "claude"}}181+ c, _ := g.Join("k", claude, newBook)182+ ann := &who{name: "ann"}183+ a, _ := g.Join("k", ann, nil)184+ a.Do(func(*sheet.Workbook) {185+ peers := a.Peers()186+ if len(peers) != 1 || !peers[0].Agent || a.Agent() || !c.Agent() {187+ t.Errorf("peers %+v", peers)188+ }189+ if !a.Keeper() || c.Keeper() {190+ t.Error("the agent keeps the room")191+ }192+ if p, ok := a.Peer(c.ID()); !ok || p.Name != "claude" {193+ t.Errorf("peer %+v", p)194+ }195+ })196+ c.Post("ran")197+ var got []any198+ a.Do(func(*sheet.Workbook) { got = a.Take() })199+ if len(got) != 1 {200+ t.Errorf("ann took %v", got)201+ }202+ before := ann.kicks.Load()203+ c.Do(func(*sheet.Workbook) { c.Touch() })204+ if ann.kicks.Load() == before {205+ t.Error("a touch didn't tell ann")206+ }207+ // The last person out closes the room, agents or not.208+ if !a.Leave() || g.Has("k") {209+ t.Error("ann, the last person, didn't close the room")210+ }211+ c.Do(func(*sheet.Workbook) {212+ if !c.Closed() {213+ t.Error("the agent's room isn't closed")214+ }215+ })216+ if _, ok := g.JoinOpen("k", &bot{who{name: "late"}}); ok {217+ t.Error("an agent joined a closed room")218+ }219+}