tinyrelaycommit

Commit 7d475627c988

browse files at this commit | history

1diff --git a/cmd/tiny/agent.go b/cmd/tiny/agent.go
2new file mode 100644
3index 0000000..53c0022
4--- /dev/null
5+++ b/cmd/tiny/agent.go
6@@ -0,0 +1,68 @@
7+package main
8+
9+import (
10+ "context"
11+ "errors"
12+ "flag"
13+ "fmt"
14+ "io"
15+ "os"
16+ "path/filepath"
17+ "regexp"
18+ "strings"
19+ "time"
20+
21+ "github.com/FelineStateMachine/tinyrelay/internal/agentrunner"
22+)
23+
24+func agent(ctx context.Context, args []string, out io.Writer) error {
25+ fs := flag.NewFlagSet("tiny agent", flag.ContinueOnError)
26+ fs.SetOutput(out)
27+ relay := fs.String("relay", "", "Nostr relay WebSocket URL")
28+ command := fs.String("command", "", "agent executable")
29+ protocol := fs.String("protocol", "acp", "agent protocol: acp or command (text stdin/stdout)")
30+ keyEnv := fs.String("key-env", "TINY_AGENT_KEY", "environment variable containing the agent hex key or nsec")
31+ state := fs.String("state", "", "queue journal path (required)")
32+ cwd := fs.String("cwd", ".", "agent working directory")
33+ timeout := fs.Duration("timeout", 30*time.Minute, "maximum task and approval wait")
34+ var rooms, commandArgs []string
35+ fs.Func("room", "authorized room id (repeatable)", func(value string) error {
36+ if !regexp.MustCompile(`^[a-z0-9_-]{1,64}$`).MatchString(value) {
37+ return errors.New("invalid room id")
38+ }
39+ rooms = append(rooms, value)
40+ return nil
41+ })
42+ fs.Func("arg", "argument passed directly to the agent (repeatable)", func(value string) error { commandArgs = append(commandArgs, value); return nil })
43+ if err := fs.Parse(args); err != nil {
44+ return err
45+ }
46+ if *relay == "" || *command == "" || *state == "" || len(rooms) == 0 {
47+ return errors.New("agent requires --relay, --room, --command and --state")
48+ }
49+ if fs.NArg() != 0 || (*protocol != "acp" && *protocol != "command") || *timeout <= 0 {
50+ return errors.New("invalid agent options")
51+ }
52+ secret, err := secretKey(os.Getenv(*keyEnv))
53+ if err != nil {
54+ return fmt.Errorf("agent key: %w", err)
55+ }
56+ directory, err := filepath.Abs(*cwd)
57+ if err != nil {
58+ return err
59+ }
60+ info, err := os.Stat(directory)
61+ if err != nil || !info.IsDir() {
62+ return errors.New("agent working directory does not exist")
63+ }
64+ // The subprocess does not need the relay signing key. It receives messages
65+ // through stdio and returns requests and output to this signing boundary.
66+ environment := []string{}
67+ for _, entry := range os.Environ() {
68+ if !strings.HasPrefix(entry, *keyEnv+"=") {
69+ environment = append(environment, entry)
70+ }
71+ }
72+ log := func(err error) { fmt.Fprintln(out, err) }
73+ return agentrunner.Serve(ctx, agentrunner.ServiceOptions{RelayURL: *relay, Secret: secret, Rooms: rooms, StatePath: *state, Timeout: *timeout, Log: log, Process: agentrunner.ProcessOptions{Command: *command, Args: commandArgs, Env: environment, CWD: directory, Protocol: *protocol}})
74+}
75diff --git a/cmd/tiny/main.go b/cmd/tiny/main.go
76index 1098a29..0aa9945 100644
77--- a/cmd/tiny/main.go
78+++ b/cmd/tiny/main.go
79@@ -37,7 +37,7 @@ func main() {
80 // to out. Returning errors to main keeps process exit at the command boundary.
81 func run(ctx context.Context, args []string, out io.Writer) error {
82 if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
83- _, err := fmt.Fprintln(out, "tiny serve [--data-dir PATH] [--listen :7447]\ntiny tenant create --name NAME --owner PUBKEY [--template default]\ntiny tenant list|enable|disable|host [options]\ntiny git-token --repo URL [--key-env TINY_AGENT_KEY] [--format header|value|git]\ntiny templates\ntiny version")
84+ _, err := fmt.Fprintln(out, "tiny serve [--data-dir PATH] [--listen :7447]\ntiny agent --relay URL --room ROOM --command PROGRAM [--protocol acp|command]\ntiny tenant create --name NAME --owner PUBKEY [--template default]\ntiny tenant list|enable|disable|host [options]\ntiny git-token --repo URL [--key-env TINY_AGENT_KEY] [--format header|value|git]\ntiny templates\ntiny version")
85 return err
86 }
87 switch args[0] {
88@@ -48,6 +48,8 @@ func run(ctx context.Context, args []string, out io.Writer) error {
89 return json.NewEncoder(out).Encode(templates.Names())
90 case "serve":
91 return serve(ctx, args[1:], out)
92+ case "agent":
93+ return agent(ctx, args[1:], out)
94 case "tenant":
95 return tenantCommand(ctx, args[1:], out)
96 case "git-token":
97diff --git a/docs/agents.md b/docs/agents.md
98index 58914db..0cc08e2 100644
99--- a/docs/agents.md
100+++ b/docs/agents.md
101@@ -67,6 +67,12 @@ A `maintain` grant makes the agent a maintainer of that repository, the same as
102
103 Rejected events return a `restricted: agent grant ...` reason to the client. The relay never edits or stamps an agent's events; it keeps them as signed or refuses them.
104
105+### Requesting additional access
106+
107+An active agent can ask its grant operator for additional access with the `request_grant` MCP tool. The agent supplies a reason of 500 characters or fewer and additive changes to kinds, rooms, repositories, sites, wiki, jobs or rate. The relay addresses the request to the operator on the current grant and publishes it as a kind 1111 NIP-22 request. It never changes the grant automatically.
108+
109+The request appears in Notifications and Approvals with the current grant and the proposed replacement side by side. The operator reviews and signs the new kind 30392 grant. The relay checks that the request still names the current grant before applying it, so an older request cannot overwrite a newer decision. Agents cannot approve their own requests.
110+
111 The rest of the relay's policy still applies. An agent cannot publish a kind the relay blocks, and a banned key stays banned whether or not it holds a grant.
112
113 ## Pause and revoke
114@@ -211,6 +217,33 @@ Over MCP, `request_job` builds a request, `job_feedback` and `job_result` build
115
116 A result, or feedback that reports `error` or `payment-required`, wakes the requester's devices in the mentions category with the body `job <kind> <status>`, where the kind is the request's.
117
118+## Agent chat features
119+
120+Agents can participate in room conversations using the same Nostr events as people. A kind 9 message is the interoperable reply form: include an `e` tag whose fourth value is `root`, and add `p` tags for people who should be notified. Kind 12 remains available for clients that use NIP-29's thread reply kind. The web UI groups replies under their root and preserves nested parent relationships. A grant must include the room and the kinds the agent will publish, such as `9`, `12`, `20001` and `20002`.
121+
122+An agent may publish ephemeral kind 20001 presence and kind 20002 typing events when its grant includes those kinds. These events are delivered live to authorized room members and are never written to history. They are useful for showing that an agent is working, but they are not a job record or a durable status. See Buzz's [Nostr event conventions](https://github.com/block/buzz/blob/main/NOSTR.md) for a compatible client reference.
123+
124+NIP-90 job requests, feedback and results appear as compact task cards above the room conversation. Approval requests published with the normal approval tags also appear there, and use the same signed answers and access rules as **Approvals**. A task card can show current progress, a result or an error without changing the underlying event protocol.
125+
126+The relay follows standard NIP-90 events for long tasks. Buzz's experimental 43001 to 43006 job kinds are not required for room chat or task cards; clients can continue to use their own support for those kinds when they need it.
127+
128+## Run a local agent
129+
130+The optional `tiny agent` command connects one agent key to a relay and runs an agent process for mentions in authorized rooms. It keeps a small on-disk queue journal, processes one batch per room at a time, and accepts either ACP version 1 or a simple text process. Pending work and work interrupted by a runner restart are returned to the queue; a task that completed before the journal was saved is deduplicated by event id.
131+
132+```sh
133+tiny agent \
134+ --relay wss://relay.example/ \
135+ --room general \
136+ --command /usr/local/bin/my-agent \
137+ --state /var/lib/tiny-agent/queue.json \
138+ --protocol acp
139+```
140+
141+Set `TINY_AGENT_KEY` to the agent's hex secret or `nsec`, or choose another variable with `--key-env`. Repeat `--room` for each authorized room, `--arg` for an argument passed to the process, and use `--cwd` to set its working directory. The runner removes the selected key variable from the child environment; the child still runs with the machine permissions of its account. The default task and approval timeout is 30 minutes; change it with `--timeout`.
142+
143+ACP agents use the [Agent Client Protocol](https://agentclientprotocol.com/) version 1 over standard input and output. `--protocol command` sends the task prompt as text on standard input and reads the response as text from standard output. The runner does not start automatically when the relay daemon starts. Start it as a separate supervised process and grant its key access to the rooms, kind `9` and kind `20001`, plus `jobs: both`. The runner listens for kind 7 approval answers; it publishes approval requests as kind 9 messages rather than publishing kind 7 answers itself. Use `--room` once for each of one to 32 authorized rooms. The default pending queue is 32 mentions per room. Address the agent with `/cancel` or `/stop` to cancel your queued or active work in that room.
144+
145 ## Static sites
146
147 An agent can publish a static site and upload the files behind it, with the owner deciding how long that work lives and whether the files must be encrypted. The site is a [NIP-5A](https://github.com/nostr-protocol/nips/blob/master/5A.md) manifest under the agent's own key: kind 15128 for the key's site, whose label is the agent's `npub`, or kind 35128 for a named site, whose label is the key in base36 followed by the name. The files are blobs the agent uploads to the relay's file store.
148diff --git a/docs/fixi.md b/docs/fixi.md
149index 5296908..bf81172 100644
150--- a/docs/fixi.md
151+++ b/docs/fixi.md
152@@ -10,7 +10,7 @@ b70458c212d0409f8a6319c286351865d92587292ad4f9bc0dfd1c46ffcad855
153
154 `/fixi.js` is served locally with no CDN dependency. The page loads it before the existing signer bundle. The Sync page uses Fixi's `fx-action` request for the job status stream, which renders server-escaped table HTML; `/connect.json` remains a JSON API for the configured connections. Mutation forms remain handled by the existing signer bridge because every request body must be serialized once, hashed exactly, signed as NIP-98, and submitted once. Fixi is not allowed to issue those signed mutations or retry them.
155
156-The signer bridge handles NIP-07, NIP-46, Nostr Connect, Amber links, session login and management actions. Encrypted uploads, NIP-34 publishing and NIP-17 file messages sign through the same bridge and never retry a signed request on their own. Fixi updates page links and GET forms within the current tenant without a full reload. Filters, repository refs, browser history and page titles remain available. Downloads, external links and modified clicks retain normal browser behavior. A failed request displays an error in the footer and leaves the current page usable. Navigation and content also work without JavaScript, including on phones.
157+The signer bridge handles NIP-07, NIP-46, Nostr Connect, Amber links, session login and management actions. Encrypted uploads, NIP-34 publishing and NIP-17 file messages sign through the same bridge and never retry a signed request on their own. Fixi updates page links and GET forms within the current tenant without a full reload. Chat activity uses a bounded Fixi refresh and Paxi morph so task cards keep their open state while active inputs and signer actions remain protected. Filters, repository refs, browser history and page titles remain available. Downloads, external links and modified clicks retain normal browser behavior. A failed request displays an error in the footer and leaves the current page usable. Navigation and content also work without JavaScript, including on phones.
158
159 Signed-in browsers read the connection list and the query bridge with their session, so opening a page never asks the signer; signatures are requested only when publishing or decrypting. Signed actions require JavaScript and submit once while their controls are disabled. Connect previews update in place. Job status uses an authenticated stream that sends updates when the results change.
160
161diff --git a/docs/mcp.md b/docs/mcp.md
162index be8ca6c..9399333 100644
163--- a/docs/mcp.md
164+++ b/docs/mcp.md
165@@ -116,6 +116,7 @@ Write tools, which publish through the same path as `POST /events`:
166 | `publish_site` | Publish a static site manifest, kind 15128 for the key's own site or kind 35128 for a named site, from `paths` given as `[path, sha256]` pairs and an optional `expiration`. |
167 | `create_room` | Create a room with a kind 9007 event carrying its id, name, description and visibility. |
168 | `request_decision` | Ask a person to approve, decide or answer with a kind 9 room message or a kind 1111 comment carrying a `request` tag. |
169+| `request_grant` | Ask the current grant operator for additional agent permissions with a short reason and additive changes. Returns a kind 1111 NIP-22 request for the agent to sign; the operator must review and publish the replacement kind 30392 grant. |
170 | `request_job` | Ask for a long task with a NIP-90 job request of kind 5000 to 5127 or 5129 to 5999 carrying its inputs, output type, parameters, bid and relays. |
171 | `job_feedback` | Report progress on a long task with a kind 7000 event naming the request, the requester and a status. |
172 | `job_result` | Deliver a long task's output with an event of the request kind plus 1000, naming the request and the requester. |
173@@ -134,6 +135,10 @@ An agent whose grant holds `propose` on a repository publishes issues, pull requ
174
175 The person answers with a kind 7 reaction to the published event from the asked key: `+` approves, `-` declines and any other content is their reply. Read the room or thread with `read_room` or `read_thread`, or query kind 7 events with `#e` set to the event id, to collect the answer. An `expiration` tag tells clients when the request lapses; the relay does not answer on the person's behalf.
176
177+### Requesting grant access
178+
179+`request_grant` is for an agent that needs a permission missing from its active grant. Pass `reason` (up to 500 characters) and `changes`, whose fields may include `kinds`, `rooms`, `repos`, `sites`, `wiki`, `jobs` and `rate`. Kinds and rooms are additive; repository and site entries replace entries with the same identity. The tool derives the current grant and operator, so it cannot target another agent or operator. It returns an unsigned kind 1111 event carrying NIP-22 references to the current kind 30392 grant and a `grant` JSON tag containing the proposed changes. Sign and submit that event through the same tool. Approval is a separately signed kind 30392 replacement from the operator and is shown in Approvals.
180+
181 ### Static sites
182
183 `publish_site` builds a [NIP-5A](https://github.com/nostr-protocol/nips/blob/master/5A.md) manifest. Upload each file to the blob store first, then pass `paths`, one `[path, sha256]` pair per file such as `["/index.html", "<sha256>"]`, and optionally `label` and `expiration`. Without a label the manifest is kind 15128, the key's own site; a named site label under the key gives a kind 35128 event with its `d` tag. Each pair becomes a `path` tag, and the template is checked with the same rules as the signed event: absolute paths with a file extension, no duplicates and a 64-character hex hash. An agent needs a `sites` grant that covers the label; when the grant sets a ttl the manifest must carry an `expiration` within it. See [Static sites](agents.md#static-sites).
184diff --git a/docs/rooms.md b/docs/rooms.md
185index 1eaf24d..19416ef 100644
186--- a/docs/rooms.md
187+++ b/docs/rooms.md
188@@ -65,10 +65,14 @@ One-to-one messages require a connected signer with NIP-44 support. The message
189
190 Open a room to read its messages, oldest first. Each message shows the author's name, their room role and the time; messages from agents carry an agent marker. Message text renders the common Markdown subset that people and agents type: paragraphs with line breaks, bold and italic, inline code and fenced code, lists, headings and links, and bare `https://` or `nostr:` references become links. Nothing else in a message is treated as markup. Links open in place and `nostr:` links resolve through the relay. A thread shows how many replies it has and opens on its own page, where replies read in order. Reactions appear under the message they answer, and an edited message shows its newest text with an edited marker. Choose **load earlier** for older messages.
191
192+For broad client compatibility, replies can use a kind 9 message with NIP-10 `e` tags: the root reference has marker `root` in the fourth tag value, and a nested reply can also identify its immediate parent. Kind 12 is accepted for clients that use the NIP-29 thread reply convention. Both forms are grouped into the same thread by the web UI.
193+
194 Choose **Attach files**, paste files or drop them into the compose bar. A message can carry up to eight files, each from 1 byte through 32 MiB, with or without text. Files upload when you send. If sending fails, retrying reuses completed uploads. Relay quotas and agent grants also apply.
195
196 Messages with NIP-92 `imeta` attachment tags and matching content references display images, video and audio inline, with download links for every file. Inline media also appears in threads, live messages and pages viewed without JavaScript. An edit replaces the message's attachment metadata along with its text.
197
198+Presence and typing are optional. A client publishes kind 20001 with an `h` tag and a short status such as `online`, `away` or `offline`; kind 20002 reports that typing started or stopped. Both are temporary live events and are not stored in room history. The web UI shares them only after you enable **Share presence and typing** on your Account page. This preference is saved for your account on the relay and applies across your browsers. Open Account by selecting your name in the footer; your relay lists and profile link are there too. Clients should treat them as hints that expire rather than as durable member state.
199+
200 Room uploads follow the room's current access rule. A members-only room's files require room access even when someone knows the URL. Files may be shared with several rooms; access to any of those rooms permits reading. Deleting a room ends access through that room, including if someone later reuses its ID. Room attachments use access controls and are not end-to-end encrypted.
201
202 Agents can call MCP `upload_attachment`, pass the returned descriptor to a room write tool and sign the resulting event to display generated media. `read_attachment` lets authorized agents retrieve incoming files. See [MCP room attachments](mcp.md#room-attachments).
203diff --git a/internal/agentrunner/acp_test.go b/internal/agentrunner/acp_test.go
204new file mode 100644
205index 0000000..0e49643
206--- /dev/null
207+++ b/internal/agentrunner/acp_test.go
208@@ -0,0 +1,89 @@
209+package agentrunner
210+
211+import (
212+ "bufio"
213+ "context"
214+ "encoding/json"
215+ "errors"
216+ "os"
217+ "path/filepath"
218+ "testing"
219+ "time"
220+)
221+
222+func TestACPHandshakeAndStream(t *testing.T) {
223+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
224+ defer cancel()
225+ result, err := RunProcess(ctx, ProcessOptions{Command: os.Args[0], Args: []string{"-test.run=TestACPHelper"}, Env: append(os.Environ(), "TINY_ACP_HELPER=1"), CWD: t.TempDir(), Protocol: "acp"}, "hello")
226+ if err != nil || result != "hello from agent" {
227+ t.Fatalf("result=%q err=%v", result, err)
228+ }
229+}
230+
231+func TestACPCancellationNotifiesAgentBeforeExit(t *testing.T) {
232+ marker := filepath.Join(t.TempDir(), "cancelled")
233+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
234+ defer cancel()
235+ _, err := RunProcess(ctx, ProcessOptions{Command: os.Args[0], Args: []string{"-test.run=TestACPHelper"}, Env: append(os.Environ(), "TINY_ACP_HELPER=1", "TINY_ACP_CANCEL_HELPER=1", "TINY_ACP_CANCEL_MARKER="+marker), CWD: t.TempDir(), Protocol: "acp"}, "hello")
236+ if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
237+ t.Fatalf("err=%v", err)
238+ }
239+ if _, err := os.Stat(marker); err != nil {
240+ t.Fatalf("agent did not receive cancellation: %v", err)
241+ }
242+}
243+
244+func TestCommandCleansUpDescendantProcess(t *testing.T) {
245+ marker := filepath.Join(t.TempDir(), "child-survived")
246+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
247+ defer cancel()
248+ _, err := RunProcess(ctx, ProcessOptions{Command: "sh", Args: []string{"-c", "(sleep 0.5; touch \"$1\") & wait", "descendant", marker}, CWD: t.TempDir(), Protocol: "command"}, "hello")
249+ if err == nil {
250+ t.Fatal("expected cancellation")
251+ }
252+ time.Sleep(200 * time.Millisecond)
253+ if _, err := os.Stat(marker); err == nil {
254+ t.Fatal("descendant survived")
255+ }
256+}
257+func TestACPHelper(t *testing.T) {
258+ if os.Getenv("TINY_ACP_HELPER") != "1" && os.Getenv("TINY_ACP_CANCEL_HELPER") != "1" {
259+ return
260+ }
261+ scan := bufio.NewScanner(os.Stdin)
262+ encoder := json.NewEncoder(os.Stdout)
263+ for scan.Scan() {
264+ var message map[string]any
265+ if json.Unmarshal(scan.Bytes(), &message) != nil {
266+ os.Exit(2)
267+ }
268+ params, _ := message["params"].(map[string]any)
269+ switch message["method"] {
270+ case "initialize":
271+ if params["protocolVersion"] != float64(1) {
272+ os.Exit(3)
273+ }
274+ encoder.Encode(map[string]any{"jsonrpc": "2.0", "id": message["id"], "result": map[string]any{"protocolVersion": 1}})
275+ case "session/new":
276+ if params["cwd"] == nil || params["mcpServers"] == nil {
277+ os.Exit(4)
278+ }
279+ encoder.Encode(map[string]any{"jsonrpc": "2.0", "id": message["id"], "result": map[string]any{"sessionId": "test-session"}})
280+ case "session/prompt":
281+ if params["sessionId"] != "test-session" || params["prompt"] == nil {
282+ os.Exit(5)
283+ }
284+ if os.Getenv("TINY_ACP_CANCEL_HELPER") == "1" {
285+ continue
286+ }
287+ encoder.Encode(map[string]any{"jsonrpc": "2.0", "method": "session/update", "params": map[string]any{"sessionId": "test-session", "update": map[string]any{"sessionUpdate": "agent_message_chunk", "content": map[string]any{"type": "text", "text": "hello from agent"}}}})
288+ encoder.Encode(map[string]any{"jsonrpc": "2.0", "id": message["id"], "result": map[string]any{"stopReason": "end_turn"}})
289+ case "session/cancel":
290+ if marker := os.Getenv("TINY_ACP_CANCEL_MARKER"); marker != "" {
291+ _ = os.WriteFile(marker, []byte("cancelled"), 0600)
292+ }
293+ os.Exit(0)
294+ }
295+ }
296+ os.Exit(0)
297+}
298diff --git a/internal/agentrunner/process.go b/internal/agentrunner/process.go
299new file mode 100644
300index 0000000..51ea2ca
301--- /dev/null
302+++ b/internal/agentrunner/process.go
303@@ -0,0 +1,325 @@
304+package agentrunner
305+
306+import (
307+ "bufio"
308+ "bytes"
309+ "context"
310+ "encoding/json"
311+ "errors"
312+ "fmt"
313+ "io"
314+ "os/exec"
315+ "strings"
316+ "sync"
317+ "time"
318+)
319+
320+type PermissionOption struct {
321+ ID string `json:"optionId"`
322+ Name string `json:"name"`
323+ Kind string `json:"kind"`
324+}
325+type Permission struct {
326+ Title string
327+ Details string
328+ Options []PermissionOption
329+}
330+type ProcessOptions struct {
331+ Command string
332+ Args []string
333+ Env []string
334+ CWD string
335+ Protocol string
336+ Progress func(string)
337+ Permission func(context.Context, Permission) (string, error)
338+}
339+type rpcFrame struct {
340+ ID json.RawMessage `json:"id,omitempty"`
341+ Method string `json:"method,omitempty"`
342+ Params json.RawMessage `json:"params,omitempty"`
343+ Result json.RawMessage `json:"result,omitempty"`
344+ Error *rpcError `json:"error,omitempty"`
345+}
346+type rpcError struct {
347+ Code int `json:"code"`
348+ Message string `json:"message"`
349+}
350+type rpcProcess struct {
351+ input io.WriteCloser
352+ frames chan rpcFrame
353+ finished chan struct{}
354+ readErr error
355+ writeMu sync.Mutex
356+ cancelOnce sync.Once
357+ sessionMu sync.RWMutex
358+ opts ProcessOptions
359+ session string
360+ text strings.Builder
361+}
362+
363+func RunProcess(ctx context.Context, opts ProcessOptions, prompt string) (string, error) {
364+ if opts.Command == "" {
365+ return "", errors.New("agent command is required")
366+ }
367+ if opts.Protocol == "command" {
368+ return runCommand(ctx, opts, prompt)
369+ }
370+ if opts.Protocol != "acp" {
371+ return "", errors.New("agent protocol must be acp or command")
372+ }
373+ command := exec.CommandContext(ctx, opts.Command, opts.Args...)
374+ configureProcessGroup(command)
375+ var process *rpcProcess
376+ command.Cancel = func() error {
377+ if process != nil && process.hasSession() {
378+ process.cancelSession()
379+ }
380+ terminateProcess(command)
381+ return nil
382+ }
383+ command.Dir = opts.CWD
384+ command.Env = opts.Env
385+ command.WaitDelay = 2 * time.Second
386+ input, err := command.StdinPipe()
387+ if err != nil {
388+ return "", err
389+ }
390+ output, err := command.StdoutPipe()
391+ if err != nil {
392+ return "", err
393+ }
394+ // Agent stderr belongs to the local runner, never to a room transcript.
395+ command.Stderr = io.Discard
396+ p := &rpcProcess{input: input, frames: make(chan rpcFrame, 1), finished: make(chan struct{}), opts: opts}
397+ process = p
398+ if err := command.Start(); err != nil {
399+ return "", fmt.Errorf("start agent: %w", err)
400+ }
401+ readCtx, stopRead := context.WithCancel(ctx)
402+ go p.read(readCtx, output)
403+ defer func() { stopRead(); input.Close(); terminateProcess(command); command.Wait(); <-p.finished }()
404+ init, err := p.call(ctx, 1, "initialize", map[string]any{"protocolVersion": 1, "clientCapabilities": map[string]any{}, "clientInfo": map[string]string{"name": "tiny", "version": "1"}})
405+ if err != nil {
406+ return "", err
407+ }
408+ var initialized struct {
409+ Version int `json:"protocolVersion"`
410+ }
411+ if json.Unmarshal(init, &initialized) != nil || initialized.Version != 1 {
412+ return "", errors.New("agent does not support ACP version 1")
413+ }
414+ raw, err := p.call(ctx, 2, "session/new", map[string]any{"cwd": opts.CWD, "mcpServers": []any{}})
415+ if err != nil {
416+ return "", err
417+ }
418+ var session struct {
419+ ID string `json:"sessionId"`
420+ }
421+ if json.Unmarshal(raw, &session) != nil || session.ID == "" {
422+ return "", errors.New("agent returned no session id")
423+ }
424+ p.setSession(session.ID)
425+ raw, err = p.call(ctx, 3, "session/prompt", map[string]any{"sessionId": session.ID, "prompt": []any{map[string]string{"type": "text", "text": prompt}}})
426+ if err != nil {
427+ if ctx.Err() != nil {
428+ p.cancelSession()
429+ }
430+ return "", err
431+ }
432+ var done struct {
433+ Reason string `json:"stopReason"`
434+ }
435+ if json.Unmarshal(raw, &done) != nil {
436+ return "", errors.New("invalid ACP prompt result")
437+ }
438+ if done.Reason == "cancelled" {
439+ return "", context.Canceled
440+ }
441+ return strings.TrimSpace(p.text.String()), nil
442+}
443+func (p *rpcProcess) read(ctx context.Context, out io.Reader) {
444+ defer close(p.finished)
445+ defer close(p.frames)
446+ scanner := bufio.NewScanner(out)
447+ scanner.Buffer(make([]byte, 4096), 1<<20)
448+ for scanner.Scan() {
449+ var frame rpcFrame
450+ if err := json.Unmarshal(scanner.Bytes(), &frame); err != nil {
451+ p.readErr = fmt.Errorf("invalid ACP response: %w", err)
452+ return
453+ }
454+ select {
455+ case p.frames <- frame:
456+ case <-ctx.Done():
457+ return
458+ }
459+ }
460+ p.readErr = scanner.Err()
461+}
462+func (p *rpcProcess) write(value any) error {
463+ p.writeMu.Lock()
464+ defer p.writeMu.Unlock()
465+ return json.NewEncoder(p.input).Encode(value)
466+}
467+func (p *rpcProcess) call(ctx context.Context, id int, method string, params any) (json.RawMessage, error) {
468+ if err := p.write(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}); err != nil {
469+ return nil, err
470+ }
471+ for {
472+ select {
473+ case <-ctx.Done():
474+ if method == "session/prompt" && p.session != "" {
475+ p.cancelSession()
476+ }
477+ return nil, ctx.Err()
478+ case frame, ok := <-p.frames:
479+ if !ok {
480+ if p.readErr != nil {
481+ return nil, p.readErr
482+ }
483+ return nil, errors.New("agent process exited before replying")
484+ }
485+ if frame.Method != "" {
486+ if err := p.receive(ctx, frame); err != nil {
487+ return nil, err
488+ }
489+ continue
490+ }
491+ if string(frame.ID) != fmt.Sprint(id) {
492+ continue
493+ }
494+ if frame.Error != nil {
495+ return nil, fmt.Errorf("ACP %s: %s", method, frame.Error.Message)
496+ }
497+ return frame.Result, nil
498+ }
499+ }
500+}
501+
502+func (p *rpcProcess) cancelSession() {
503+ p.cancelOnce.Do(func() {
504+ session := p.getSession()
505+ if session == "" {
506+ return
507+ }
508+ done := make(chan struct{})
509+ go func() {
510+ defer close(done)
511+ _ = p.write(map[string]any{"jsonrpc": "2.0", "method": "session/cancel", "params": map[string]string{"sessionId": session}})
512+ }()
513+ timer := time.NewTimer(100 * time.Millisecond)
514+ defer timer.Stop()
515+ select {
516+ case <-done:
517+ <-timer.C
518+ case <-timer.C:
519+ // Closing the pipe releases a blocked write before we join its goroutine.
520+ _ = p.input.Close()
521+ <-done
522+ }
523+ })
524+}
525+
526+func (p *rpcProcess) setSession(session string) {
527+ p.sessionMu.Lock()
528+ p.session = session
529+ p.sessionMu.Unlock()
530+}
531+func (p *rpcProcess) getSession() string {
532+ p.sessionMu.RLock()
533+ defer p.sessionMu.RUnlock()
534+ return p.session
535+}
536+func (p *rpcProcess) hasSession() bool { return p.getSession() != "" }
537+func (p *rpcProcess) receive(ctx context.Context, frame rpcFrame) error {
538+ var params struct {
539+ Session string `json:"sessionId"`
540+ Update struct {
541+ Type string `json:"sessionUpdate"`
542+ Title string `json:"title"`
543+ Status string `json:"status"`
544+ Content struct {
545+ Type string `json:"type"`
546+ Text string `json:"text"`
547+ } `json:"content"`
548+ } `json:"update"`
549+ Tool struct {
550+ Title string `json:"title"`
551+ Input json.RawMessage `json:"rawInput"`
552+ } `json:"toolCall"`
553+ Options []PermissionOption `json:"options"`
554+ }
555+ if json.Unmarshal(frame.Params, &params) != nil {
556+ return errors.New("invalid ACP parameters")
557+ }
558+ if params.Session != "" && params.Session != p.session {
559+ return errors.New("ACP update belongs to another session")
560+ }
561+ if frame.Method == "session/update" {
562+ if params.Update.Type == "agent_message_chunk" && params.Update.Content.Type == "text" {
563+ if p.text.Len()+len(params.Update.Content.Text) > 64000 {
564+ return errors.New("agent reply exceeds 64 KiB")
565+ }
566+ p.text.WriteString(params.Update.Content.Text)
567+ }
568+ if (params.Update.Type == "tool_call" || params.Update.Type == "tool_call_update") && p.opts.Progress != nil {
569+ p.opts.Progress(strings.TrimSpace(params.Update.Title + " " + params.Update.Status))
570+ }
571+ return nil
572+ }
573+ if len(frame.ID) == 0 {
574+ return nil
575+ }
576+ if frame.Method != "session/request_permission" {
577+ return p.write(map[string]any{"jsonrpc": "2.0", "id": frame.ID, "error": rpcError{Code: -32601, Message: "Client capability unavailable"}})
578+ }
579+ outcome := map[string]string{"outcome": "cancelled"}
580+ if p.opts.Permission != nil {
581+ if len(params.Tool.Input) > 16000 {
582+ return errors.New("permission request is too large to review")
583+ }
584+ choice, err := p.opts.Permission(ctx, Permission{Title: params.Tool.Title, Details: string(params.Tool.Input), Options: params.Options})
585+ if err != nil {
586+ return err
587+ }
588+ for _, option := range params.Options {
589+ if option.ID == choice {
590+ outcome = map[string]string{"outcome": "selected", "optionId": choice}
591+ break
592+ }
593+ }
594+ }
595+ return p.write(map[string]any{"jsonrpc": "2.0", "id": frame.ID, "result": map[string]any{"outcome": outcome}})
596+}
597+
598+type boundedOutput struct {
599+ bytes.Buffer
600+ limit int
601+}
602+
603+func (b *boundedOutput) Write(p []byte) (int, error) {
604+ if b.Len()+len(p) > b.limit {
605+ return 0, errors.New("agent output exceeds 64 KiB")
606+ }
607+ return b.Buffer.Write(p)
608+}
609+func runCommand(ctx context.Context, opts ProcessOptions, prompt string) (string, error) {
610+ command := exec.CommandContext(ctx, opts.Command, opts.Args...)
611+ configureProcessGroup(command)
612+ command.Cancel = func() error {
613+ terminateProcess(command)
614+ return nil
615+ }
616+ command.Dir = opts.CWD
617+ command.Env = opts.Env
618+ command.WaitDelay = 2 * time.Second
619+ command.Stdin = strings.NewReader(prompt)
620+ output := &boundedOutput{limit: 64000}
621+ command.Stdout = output
622+ command.Stderr = io.Discard
623+ defer terminateProcess(command)
624+ if err := command.Run(); err != nil {
625+ return "", fmt.Errorf("agent command failed: %w", err)
626+ }
627+ return strings.TrimSpace(output.String()), nil
628+}
629diff --git a/internal/agentrunner/process_unix.go b/internal/agentrunner/process_unix.go
630new file mode 100644
631index 0000000..868d48c
632--- /dev/null
633+++ b/internal/agentrunner/process_unix.go
634@@ -0,0 +1,21 @@
635+//go:build !windows
636+
637+package agentrunner
638+
639+import (
640+ "os/exec"
641+ "syscall"
642+)
643+
644+func configureProcessGroup(command *exec.Cmd) {
645+ command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
646+}
647+
648+func terminateProcess(command *exec.Cmd) {
649+ if command.Process == nil {
650+ return
651+ }
652+ _ = syscall.Kill(-command.Process.Pid, syscall.SIGTERM)
653+ _ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL)
654+ _ = command.Process.Kill()
655+}
656diff --git a/internal/agentrunner/process_windows.go b/internal/agentrunner/process_windows.go
657new file mode 100644
658index 0000000..ed5ec53
659--- /dev/null
660+++ b/internal/agentrunner/process_windows.go
661@@ -0,0 +1,13 @@
662+//go:build windows
663+
664+package agentrunner
665+
666+import "os/exec"
667+
668+func configureProcessGroup(command *exec.Cmd) {}
669+
670+func terminateProcess(command *exec.Cmd) {
671+ if command.Process != nil {
672+ _ = command.Process.Kill()
673+ }
674+}
675diff --git a/internal/agentrunner/relay.go b/internal/agentrunner/relay.go
676new file mode 100644
677index 0000000..c36d7c1
678--- /dev/null
679+++ b/internal/agentrunner/relay.go
680@@ -0,0 +1,226 @@
681+package agentrunner
682+
683+import (
684+ "context"
685+ "encoding/json"
686+ "errors"
687+ "fmt"
688+ "net/url"
689+ "slices"
690+ "strings"
691+ "sync"
692+ "time"
693+
694+ "github.com/FelineStateMachine/tinyrelay/internal/event"
695+ "github.com/coder/websocket"
696+)
697+
698+type RelayClient struct {
699+ URL, Secret, PubKey string
700+ Rooms []string
701+ OnEvent func(event.Event)
702+ OnReady func()
703+ OnError func(error)
704+ mu sync.Mutex
705+ conn *websocket.Conn
706+ connection context.Context
707+ pending map[string]chan error
708+}
709+
710+func (c *RelayClient) Run(ctx context.Context) error {
711+ parsed, err := url.Parse(c.URL)
712+ if err != nil || parsed.Host == "" || (parsed.Scheme != "ws" && parsed.Scheme != "wss") || parsed.User != nil || parsed.Fragment != "" {
713+ return errors.New("agent relay must be a ws or wss URL")
714+ }
715+ c.PubKey, err = event.PublicKey(c.Secret)
716+ if err != nil {
717+ return err
718+ }
719+ delay := time.Second
720+ for ctx.Err() == nil {
721+ err = c.connect(ctx)
722+ if ctx.Err() != nil {
723+ break
724+ }
725+ if c.OnError != nil {
726+ c.OnError(err)
727+ }
728+ timer := time.NewTimer(delay)
729+ select {
730+ case <-ctx.Done():
731+ timer.Stop()
732+ return ctx.Err()
733+ case <-timer.C:
734+ }
735+ delay = min(delay*2, 30*time.Second)
736+ }
737+ return ctx.Err()
738+}
739+func (c *RelayClient) connect(ctx context.Context) error {
740+ conn, _, err := websocket.Dial(ctx, c.URL, nil)
741+ if err != nil {
742+ return err
743+ }
744+ conn.SetReadLimit(1 << 20)
745+ scope, cancel := context.WithCancel(ctx)
746+ defer cancel()
747+ defer conn.CloseNow()
748+ c.mu.Lock()
749+ c.conn = conn
750+ c.connection = scope
751+ c.pending = map[string]chan error{}
752+ c.mu.Unlock()
753+ defer func() {
754+ c.mu.Lock()
755+ if c.conn == conn {
756+ c.conn = nil
757+ }
758+ c.mu.Unlock()
759+ }()
760+ if err := c.subscribe(scope, conn); err != nil {
761+ return err
762+ }
763+ authID := ""
764+ for {
765+ _, raw, err := conn.Read(scope)
766+ if err != nil {
767+ return err
768+ }
769+ var frame []json.RawMessage
770+ if json.Unmarshal(raw, &frame) != nil || len(frame) < 2 {
771+ continue
772+ }
773+ var kind string
774+ json.Unmarshal(frame[0], &kind)
775+ switch kind {
776+ case "AUTH":
777+ var challenge string
778+ if json.Unmarshal(frame[1], &challenge) != nil {
779+ continue
780+ }
781+ auth := event.Event{Kind: 22242, CreatedAt: time.Now().Unix(), Tags: [][]string{{"relay", c.URL}, {"challenge", challenge}}}
782+ if err := event.Sign(&auth, c.Secret); err != nil {
783+ return err
784+ }
785+ authID = auth.ID
786+ if err := writeRelay(scope, conn, []any{"AUTH", auth}); err != nil {
787+ return err
788+ }
789+ case "OK":
790+ if len(frame) < 3 {
791+ continue
792+ }
793+ var id string
794+ var ok bool
795+ var reason string
796+ json.Unmarshal(frame[1], &id)
797+ json.Unmarshal(frame[2], &ok)
798+ if len(frame) > 3 {
799+ json.Unmarshal(frame[3], &reason)
800+ }
801+ if id == authID {
802+ if !ok {
803+ return fmt.Errorf("agent authentication refused: %s", reason)
804+ }
805+ if err := c.subscribe(scope, conn); err != nil {
806+ return err
807+ }
808+ if c.OnReady != nil {
809+ c.OnReady()
810+ }
811+ continue
812+ }
813+ var result error
814+ if !ok && !strings.HasPrefix(reason, "duplicate:") {
815+ result = fmt.Errorf("relay refused event: %s", reason)
816+ }
817+ c.mu.Lock()
818+ wait := c.pending[id]
819+ c.mu.Unlock()
820+ if wait != nil {
821+ select {
822+ case wait <- result:
823+ default:
824+ }
825+ }
826+ case "EOSE":
827+ if c.OnReady != nil {
828+ c.OnReady()
829+ }
830+ case "EVENT":
831+ if len(frame) != 3 {
832+ continue
833+ }
834+ e, err := event.Parse(frame[2])
835+ if err != nil {
836+ continue
837+ }
838+ if c.OnEvent != nil {
839+ c.OnEvent(e)
840+ }
841+ case "CLOSED":
842+ if len(frame) > 2 {
843+ var reason string
844+ json.Unmarshal(frame[2], &reason)
845+ if !strings.HasPrefix(reason, "auth-required:") {
846+ return fmt.Errorf("agent subscription closed: %s", reason)
847+ }
848+ }
849+ }
850+ }
851+}
852+func (c *RelayClient) subscribe(ctx context.Context, conn *websocket.Conn) error {
853+ since := time.Now().Add(-5 * time.Minute).Unix()
854+ return writeRelay(ctx, conn, []any{"REQ", "tiny-agent", map[string]any{"kinds": []int{9, 11, 12}, "#h": c.Rooms, "#p": []string{c.PubKey}, "since": since}, map[string]any{"kinds": []int{7, 1111}, "#p": []string{c.PubKey}, "since": since}})
855+}
856+func writeRelay(ctx context.Context, conn *websocket.Conn, value any) error {
857+ raw, err := json.Marshal(value)
858+ if err != nil {
859+ return err
860+ }
861+ return conn.Write(ctx, websocket.MessageText, raw)
862+}
863+func (c *RelayClient) Publish(ctx context.Context, e event.Event) (event.Event, error) {
864+ if e.CreatedAt == 0 {
865+ e.CreatedAt = time.Now().Unix()
866+ }
867+ if err := event.Sign(&e, c.Secret); err != nil {
868+ return e, err
869+ }
870+ c.mu.Lock()
871+ conn, scope := c.conn, c.connection
872+ if conn == nil {
873+ c.mu.Unlock()
874+ return e, errors.New("agent relay is disconnected")
875+ }
876+ wait := make(chan error, 1)
877+ c.pending[e.ID] = wait
878+ c.mu.Unlock()
879+ defer func() { c.mu.Lock(); delete(c.pending, e.ID); c.mu.Unlock() }()
880+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
881+ defer cancel()
882+ if err := writeRelay(ctx, conn, []any{"EVENT", e}); err != nil {
883+ return e, err
884+ }
885+ select {
886+ case err := <-wait:
887+ return e, err
888+ case <-ctx.Done():
889+ return e, ctx.Err()
890+ case <-scope.Done():
891+ return e, errors.New("agent relay disconnected before acknowledgement")
892+ }
893+}
894+func (c *RelayClient) Mention(e event.Event) (Mention, bool) {
895+ if e.PubKey == c.PubKey || !slices.Contains([]int{9, 11, 12}, e.Kind) || !slices.Contains(event.TagValues(e, "p"), c.PubKey) || !slices.Contains(c.Rooms, event.Tag(e, "h")) {
896+ return Mention{}, false
897+ }
898+ if e.CreatedAt > time.Now().Add(time.Minute).Unix() || e.CreatedAt < time.Now().Add(-24*time.Hour).Unix() || len(e.Content) > 64000 {
899+ return Mention{}, false
900+ }
901+ root := event.RoomReplyRoot(e)
902+ if root == "" {
903+ root = e.ID
904+ }
905+ return Mention{Room: event.Tag(e, "h"), EventID: e.ID, Author: e.PubKey, Prompt: e.Content, Root: root, Kind: e.Kind, CreatedAt: e.CreatedAt}, true
906+}
907diff --git a/internal/agentrunner/runner.go b/internal/agentrunner/runner.go
908new file mode 100644
909index 0000000..f413b44
910--- /dev/null
911+++ b/internal/agentrunner/runner.go
912@@ -0,0 +1,375 @@
913+// Package agentrunner runs an optional agent process outside the relay daemon.
914+package agentrunner
915+
916+import (
917+ "context"
918+ "encoding/json"
919+ "errors"
920+ "fmt"
921+ "os"
922+ "path/filepath"
923+ "sync"
924+ "time"
925+)
926+
927+var (
928+ ErrDuplicate = errors.New("agent runner: duplicate event")
929+ ErrUnauthorizedRoom = errors.New("agent runner: unauthorized room")
930+ ErrQueueFull = errors.New("agent runner: room queue full")
931+ ErrStopped = errors.New("agent runner: stopped")
932+)
933+
934+type Mention struct {
935+ Room string `json:"room"`
936+ EventID string `json:"event_id"`
937+ Author string `json:"author"`
938+ Prompt string `json:"prompt"`
939+ Root string `json:"root"`
940+ Kind int `json:"kind"`
941+ CreatedAt int64 `json:"created_at"`
942+}
943+type Handler func(context.Context, []Mention) error
944+type Options struct {
945+ AllowedRooms []string
946+ QueueLimit int
947+ StatePath string
948+ Handle Handler
949+ OnError func(error)
950+}
951+type roomQueue struct {
952+ pending, active []Mention
953+ cancel context.CancelFunc
954+ wake chan struct{}
955+}
956+type journal struct {
957+ Pending map[string][]Mention `json:"pending"`
958+ Seen []string `json:"seen"`
959+}
960+type Runner struct {
961+ mu sync.Mutex
962+ rooms map[string]*roomQueue
963+ seen map[string]bool
964+ order []string
965+ opts Options
966+ ctx context.Context
967+ cancel context.CancelFunc
968+ wg sync.WaitGroup
969+ stopped bool
970+}
971+
972+func New(opts Options) (*Runner, error) {
973+ if opts.Handle == nil || len(opts.AllowedRooms) == 0 || len(opts.AllowedRooms) > 32 {
974+ return nil, errors.New("agent runner: a handler and 1 to 32 rooms are required")
975+ }
976+ if opts.QueueLimit <= 0 {
977+ opts.QueueLimit = 32
978+ }
979+ if opts.QueueLimit > 256 {
980+ return nil, errors.New("agent runner: queue limit cannot exceed 256")
981+ }
982+ r := &Runner{opts: opts, rooms: map[string]*roomQueue{}, seen: map[string]bool{}}
983+ for _, room := range opts.AllowedRooms {
984+ if room == "" {
985+ return nil, ErrUnauthorizedRoom
986+ }
987+ r.rooms[room] = &roomQueue{wake: make(chan struct{}, 1)}
988+ }
989+ if err := r.restore(); err != nil {
990+ return nil, err
991+ }
992+ return r, nil
993+}
994+func (r *Runner) Start(ctx context.Context) {
995+ r.mu.Lock()
996+ defer r.mu.Unlock()
997+ if r.ctx != nil || r.stopped {
998+ return
999+ }
1000+ r.ctx, r.cancel = context.WithCancel(ctx)
1001+ for room, q := range r.rooms {
1002+ r.wg.Add(1)
1003+ go r.work(room, q)
1004+ }
1005+}
1006+func (r *Runner) Enqueue(m Mention) error {
1007+ if m.EventID == "" || m.Author == "" || len(m.Prompt) > 64*1024 {
1008+ return errors.New("agent runner: invalid mention")
1009+ }
1010+ r.mu.Lock()
1011+ defer r.mu.Unlock()
1012+ if r.stopped {
1013+ return ErrStopped
1014+ }
1015+ q, ok := r.rooms[m.Room]
1016+ if !ok {
1017+ return ErrUnauthorizedRoom
1018+ }
1019+ if r.seen[m.EventID] {
1020+ return ErrDuplicate
1021+ }
1022+ if len(q.pending) >= r.opts.QueueLimit {
1023+ return ErrQueueFull
1024+ }
1025+ q.pending = append(q.pending, m)
1026+ r.seen[m.EventID] = true
1027+ r.order = append(r.order, m.EventID)
1028+ if err := r.save(); err != nil {
1029+ q.pending = q.pending[:len(q.pending)-1]
1030+ delete(r.seen, m.EventID)
1031+ r.order = r.order[:len(r.order)-1]
1032+ return err
1033+ }
1034+ select {
1035+ case q.wake <- struct{}{}:
1036+ default:
1037+ }
1038+ return nil
1039+}
1040+func (r *Runner) CancelFrom(room, author string) bool {
1041+ r.mu.Lock()
1042+ defer r.mu.Unlock()
1043+ q := r.rooms[room]
1044+ if q == nil {
1045+ return false
1046+ }
1047+ allowed := false
1048+ for _, m := range append(append([]Mention{}, q.active...), q.pending...) {
1049+ if author == "" || m.Author == author {
1050+ allowed = true
1051+ break
1052+ }
1053+ }
1054+ if !allowed {
1055+ return false
1056+ }
1057+ filtered := q.pending[:0]
1058+ for _, m := range q.pending {
1059+ if author == "" || m.Author == author {
1060+ continue
1061+ }
1062+ filtered = append(filtered, m)
1063+ }
1064+ q.pending = filtered
1065+ if q.cancel != nil && anyAuthor(q.active, author) {
1066+ q.cancel()
1067+ }
1068+ if err := r.save(); err != nil {
1069+ r.report(err)
1070+ }
1071+ return true
1072+}
1073+func (r *Runner) Cancel(room string) error { r.CancelFrom(room, ""); return nil }
1074+func (r *Runner) Stop() {
1075+ r.mu.Lock()
1076+ r.stopped = true
1077+ if r.cancel != nil {
1078+ r.cancel()
1079+ }
1080+ r.mu.Unlock()
1081+ r.wg.Wait()
1082+}
1083+func (r *Runner) work(room string, q *roomQueue) {
1084+ defer r.wg.Done()
1085+ for {
1086+ select {
1087+ case <-r.ctx.Done():
1088+ return
1089+ default:
1090+ }
1091+ r.mu.Lock()
1092+ if len(q.pending) == 0 {
1093+ r.mu.Unlock()
1094+ select {
1095+ case <-r.ctx.Done():
1096+ return
1097+ case <-q.wake:
1098+ }
1099+ continue
1100+ }
1101+ batch := takeBatch(q.pending)
1102+ q.pending = q.pending[len(batch):]
1103+ q.active = batch
1104+ ctx, cancel := context.WithCancel(r.ctx)
1105+ q.cancel = cancel
1106+ if err := r.save(); err != nil {
1107+ r.report(err)
1108+ cancel()
1109+ q.pending = append(batch, q.pending...)
1110+ q.active = nil
1111+ q.cancel = nil
1112+ r.mu.Unlock()
1113+ return
1114+ }
1115+ r.mu.Unlock()
1116+ err := r.opts.Handle(ctx, batch)
1117+ cancel()
1118+ r.mu.Lock()
1119+ if r.ctx.Err() != nil {
1120+ q.pending = append(batch, q.pending...)
1121+ }
1122+ q.active = nil
1123+ q.cancel = nil
1124+ if saveErr := r.save(); saveErr != nil {
1125+ r.report(saveErr)
1126+ }
1127+ r.mu.Unlock()
1128+ if err != nil && !errors.Is(err, context.Canceled) {
1129+ r.report(fmt.Errorf("agent room %s: %w", room, err))
1130+ }
1131+ }
1132+}
1133+
1134+func takeBatch(pending []Mention) []Mention {
1135+ if len(pending) == 0 {
1136+ return nil
1137+ }
1138+ root := pending[0].Root
1139+ n := 1
1140+ for n < len(pending) && pending[n].Root == root {
1141+ n++
1142+ }
1143+ return append([]Mention(nil), pending[:n]...)
1144+}
1145+
1146+func anyAuthor(events []Mention, author string) bool {
1147+ if author == "" {
1148+ return len(events) > 0
1149+ }
1150+ for _, event := range events {
1151+ if event.Author == author {
1152+ return true
1153+ }
1154+ }
1155+ return false
1156+}
1157+func (r *Runner) report(err error) {
1158+ if r.opts.OnError != nil {
1159+ r.opts.OnError(err)
1160+ }
1161+}
1162+func (r *Runner) restore() error {
1163+ if r.opts.StatePath == "" {
1164+ return nil
1165+ }
1166+ raw, err := os.ReadFile(r.opts.StatePath)
1167+ if errors.Is(err, os.ErrNotExist) {
1168+ return nil
1169+ }
1170+ if err != nil {
1171+ return fmt.Errorf("read agent state: %w", err)
1172+ }
1173+ if len(raw) > 32<<20 {
1174+ return errors.New("agent state exceeds 32 MiB")
1175+ }
1176+ var state journal
1177+ if err := json.Unmarshal(raw, &state); err != nil {
1178+ return fmt.Errorf("decode agent state: %w", err)
1179+ }
1180+ for _, id := range state.Seen {
1181+ if !r.seen[id] {
1182+ r.seen[id] = true
1183+ r.order = append(r.order, id)
1184+ }
1185+ }
1186+ for room, queue := range state.Pending {
1187+ q := r.rooms[room]
1188+ if q == nil {
1189+ return errors.New("agent state contains an unconfigured room")
1190+ }
1191+ if len(queue) > r.opts.QueueLimit*2 {
1192+ return ErrQueueFull
1193+ }
1194+ q.pending = queue
1195+ }
1196+ return nil
1197+}
1198+func (r *Runner) save() error {
1199+ if r.opts.StatePath == "" {
1200+ return nil
1201+ }
1202+ // Keep a bounded replay history while retaining every queued event.
1203+ active := map[string]bool{}
1204+ pending := map[string][]Mention{}
1205+ for room, q := range r.rooms {
1206+ pending[room] = append(append([]Mention{}, q.active...), q.pending...)
1207+ for _, m := range pending[room] {
1208+ active[m.EventID] = true
1209+ }
1210+ }
1211+ if len(r.order) > 10000 {
1212+ keep := make(map[string]bool, len(active)+10000)
1213+ for id := range active {
1214+ keep[id] = true
1215+ }
1216+ remaining := 10000 - len(active)
1217+ if remaining < 0 {
1218+ remaining = 0
1219+ }
1220+ for i := len(r.order) - 1; i >= 0 && remaining > 0; i-- {
1221+ if !keep[r.order[i]] {
1222+ keep[r.order[i]] = true
1223+ remaining--
1224+ }
1225+ }
1226+ kept := make([]string, 0, len(keep))
1227+ for _, id := range r.order {
1228+ if keep[id] {
1229+ kept = append(kept, id)
1230+ }
1231+ }
1232+ for id := range r.seen {
1233+ if !keep[id] {
1234+ delete(r.seen, id)
1235+ }
1236+ }
1237+ r.order = kept
1238+ }
1239+ raw, err := json.Marshal(journal{Pending: pending, Seen: r.order})
1240+ if err != nil {
1241+ return err
1242+ }
1243+ dir := filepath.Dir(r.opts.StatePath)
1244+ if err := os.MkdirAll(dir, 0700); err != nil {
1245+ return err
1246+ }
1247+ file, err := os.CreateTemp(dir, ".tiny-agent-*")
1248+ if err != nil {
1249+ return err
1250+ }
1251+ name := file.Name()
1252+ defer os.Remove(name)
1253+ if _, err = file.Write(raw); err != nil {
1254+ file.Close()
1255+ return err
1256+ }
1257+ if err = file.Sync(); err != nil {
1258+ file.Close()
1259+ return err
1260+ }
1261+ if err = file.Close(); err != nil {
1262+ return err
1263+ }
1264+ return os.Rename(name, r.opts.StatePath)
1265+}
1266+
1267+// Drain waits until queued work has finished, or the caller cancels.
1268+func (r *Runner) Drain(ctx context.Context) error {
1269+ ticker := time.NewTicker(10 * time.Millisecond)
1270+ defer ticker.Stop()
1271+ for {
1272+ r.mu.Lock()
1273+ empty := true
1274+ for _, q := range r.rooms {
1275+ empty = empty && len(q.pending) == 0 && len(q.active) == 0
1276+ }
1277+ r.mu.Unlock()
1278+ if empty {
1279+ return nil
1280+ }
1281+ select {
1282+ case <-ctx.Done():
1283+ return ctx.Err()
1284+ case <-ticker.C:
1285+ }
1286+ }
1287+}
1288diff --git a/internal/agentrunner/runner_test.go b/internal/agentrunner/runner_test.go
1289new file mode 100644
1290index 0000000..a0187c1
1291--- /dev/null
1292+++ b/internal/agentrunner/runner_test.go
1293@@ -0,0 +1,196 @@
1294+package agentrunner
1295+
1296+import (
1297+ "context"
1298+ "errors"
1299+ "path/filepath"
1300+ "sync"
1301+ "testing"
1302+ "time"
1303+)
1304+
1305+func TestRunnerSerializesBatchesAndOtherRoomsProgress(t *testing.T) {
1306+ start := make(chan string, 3)
1307+ release := make(chan struct{})
1308+ var mu sync.Mutex
1309+ var batches [][]Mention
1310+ r, err := New(Options{AllowedRooms: []string{"one", "two"}, Handle: func(ctx context.Context, b []Mention) error {
1311+ mu.Lock()
1312+ batches = append(batches, b)
1313+ mu.Unlock()
1314+ start <- b[0].EventID
1315+ if b[0].EventID == "a" {
1316+ select {
1317+ case <-ctx.Done():
1318+ return ctx.Err()
1319+ case <-release:
1320+ }
1321+ }
1322+ return nil
1323+ }})
1324+ if err != nil {
1325+ t.Fatal(err)
1326+ }
1327+ r.Start(context.Background())
1328+ defer r.Stop()
1329+ add := func(id, room string) {
1330+ t.Helper()
1331+ if err := r.Enqueue(Mention{EventID: id, Author: "alice", Room: room}); err != nil {
1332+ t.Fatal(err)
1333+ }
1334+ }
1335+ add("a", "one")
1336+ <-start
1337+ add("b", "one")
1338+ add("c", "one")
1339+ add("d", "two")
1340+ select {
1341+ case id := <-start:
1342+ if id != "d" {
1343+ t.Fatal(id)
1344+ }
1345+ case <-time.After(time.Second):
1346+ t.Fatal("second room blocked")
1347+ }
1348+ if err := r.Enqueue(Mention{EventID: "b", Author: "alice", Room: "one"}); !errors.Is(err, ErrDuplicate) {
1349+ t.Fatal(err)
1350+ }
1351+ close(release)
1352+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
1353+ defer cancel()
1354+ if err := r.Drain(ctx); err != nil {
1355+ t.Fatal(err)
1356+ }
1357+ mu.Lock()
1358+ defer mu.Unlock()
1359+ if len(batches) != 3 || len(batches[2]) != 2 {
1360+ t.Fatalf("batches=%v", batches)
1361+ }
1362+}
1363+func TestRunnerStopsActiveTaskAndRestoresJournal(t *testing.T) {
1364+ path := filepath.Join(t.TempDir(), "queue.json")
1365+ started := make(chan struct{})
1366+ r, err := New(Options{AllowedRooms: []string{"one"}, StatePath: path, Handle: func(ctx context.Context, b []Mention) error { close(started); <-ctx.Done(); return ctx.Err() }})
1367+ if err != nil {
1368+ t.Fatal(err)
1369+ }
1370+ r.Start(context.Background())
1371+ r.Enqueue(Mention{Room: "one", Author: "alice", EventID: "a"})
1372+ <-started
1373+ if r.CancelFrom("one", "other") {
1374+ t.Fatal("another author canceled work")
1375+ }
1376+ done := make(chan struct{})
1377+ go func() { r.Stop(); close(done) }()
1378+ select {
1379+ case <-done:
1380+ case <-time.After(time.Second):
1381+ t.Fatal("stop hung")
1382+ }
1383+ if err := r.Enqueue(Mention{Room: "one", Author: "alice", EventID: "b"}); !errors.Is(err, ErrStopped) {
1384+ t.Fatal(err)
1385+ }
1386+ got := make(chan string, 1)
1387+ restored, err := New(Options{AllowedRooms: []string{"one"}, StatePath: path, Handle: func(ctx context.Context, b []Mention) error { got <- b[0].EventID; return nil }})
1388+ if err != nil {
1389+ t.Fatal(err)
1390+ }
1391+ restored.Start(context.Background())
1392+ defer restored.Stop()
1393+ select {
1394+ case id := <-got:
1395+ if id != "a" {
1396+ t.Fatal(id)
1397+ }
1398+ case <-time.After(time.Second):
1399+ t.Fatal("unfinished work not recovered")
1400+ }
1401+}
1402+func TestRunnerRejectsUnconfiguredRoomsAndRecoversAfterFailure(t *testing.T) {
1403+ got := make(chan string, 2)
1404+ r, err := New(Options{AllowedRooms: []string{"one"}, QueueLimit: 1, Handle: func(ctx context.Context, b []Mention) error {
1405+ got <- b[0].EventID
1406+ return errors.New("process crashed")
1407+ }})
1408+ if err != nil {
1409+ t.Fatal(err)
1410+ }
1411+ if err := r.Enqueue(Mention{Room: "two", Author: "a", EventID: "a"}); !errors.Is(err, ErrUnauthorizedRoom) {
1412+ t.Fatal(err)
1413+ }
1414+ r.Enqueue(Mention{Room: "one", Author: "a", EventID: "a"})
1415+ if err := r.Enqueue(Mention{Room: "one", Author: "a", EventID: "b"}); !errors.Is(err, ErrQueueFull) {
1416+ t.Fatal(err)
1417+ }
1418+ r.Start(context.Background())
1419+ defer r.Stop()
1420+ <-got
1421+ r.Enqueue(Mention{Room: "one", Author: "a", EventID: "b"})
1422+ select {
1423+ case <-got:
1424+ case <-time.After(time.Second):
1425+ t.Fatal("queue died after process crash")
1426+ }
1427+}
1428+
1429+func TestRunnerDoesNotMixThreadBatches(t *testing.T) {
1430+ got := make(chan []Mention, 2)
1431+ r, err := New(Options{AllowedRooms: []string{"one"}, Handle: func(ctx context.Context, batch []Mention) error {
1432+ got <- batch
1433+ return nil
1434+ }})
1435+ if err != nil {
1436+ t.Fatal(err)
1437+ }
1438+ r.Start(context.Background())
1439+ defer r.Stop()
1440+ for _, m := range []Mention{{Room: "one", Author: "a", EventID: "a", Root: "root-a"}, {Room: "one", Author: "b", EventID: "b", Root: "root-a"}, {Room: "one", Author: "c", EventID: "c", Root: "root-b"}} {
1441+ if err := r.Enqueue(m); err != nil {
1442+ t.Fatal(err)
1443+ }
1444+ }
1445+ first := <-got
1446+ second := <-got
1447+ if len(first) != 2 || first[0].Root != "root-a" || len(second) != 1 || second[0].Root != "root-b" {
1448+ t.Fatalf("batches=%v,%v", first, second)
1449+ }
1450+}
1451+
1452+func TestRunnerCancelFromKeepsOtherAuthorsQueued(t *testing.T) {
1453+ started := make(chan struct{})
1454+ release := make(chan struct{})
1455+ var startOnce sync.Once
1456+ r, err := New(Options{AllowedRooms: []string{"one"}, Handle: func(ctx context.Context, batch []Mention) error {
1457+ startOnce.Do(func() { close(started) })
1458+ select {
1459+ case <-release:
1460+ return nil
1461+ case <-ctx.Done():
1462+ return ctx.Err()
1463+ }
1464+ }})
1465+ if err != nil {
1466+ t.Fatal(err)
1467+ }
1468+ r.Start(context.Background())
1469+ defer r.Stop()
1470+ if err := r.Enqueue(Mention{Room: "one", Author: "alice", EventID: "a", Root: "root"}); err != nil {
1471+ t.Fatal(err)
1472+ }
1473+ <-started
1474+ if err := r.Enqueue(Mention{Room: "one", Author: "bob", EventID: "b", Root: "root-b"}); err != nil {
1475+ t.Fatal(err)
1476+ }
1477+ if !r.CancelFrom("one", "alice") {
1478+ t.Fatal("alice cancellation was not accepted")
1479+ }
1480+ close(release)
1481+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
1482+ defer cancel()
1483+ if err := r.Drain(ctx); err != nil {
1484+ t.Fatal(err)
1485+ }
1486+ if err := r.Enqueue(Mention{Room: "one", Author: "bob", EventID: "b"}); !errors.Is(err, ErrDuplicate) {
1487+ t.Fatalf("bob event should remain seen: %v", err)
1488+ }
1489+}
1490diff --git a/internal/agentrunner/service.go b/internal/agentrunner/service.go
1491new file mode 100644
1492index 0000000..5252fa0
1493--- /dev/null
1494+++ b/internal/agentrunner/service.go
1495@@ -0,0 +1,234 @@
1496+package agentrunner
1497+
1498+import (
1499+ "context"
1500+ "encoding/json"
1501+ "errors"
1502+ "fmt"
1503+ "slices"
1504+ "strconv"
1505+ "strings"
1506+ "sync"
1507+ "time"
1508+
1509+ "github.com/FelineStateMachine/tinyrelay/internal/event"
1510+ "github.com/google/uuid"
1511+)
1512+
1513+type ServiceOptions struct {
1514+ RelayURL, Secret, StatePath string
1515+ Rooms []string
1516+ Process ProcessOptions
1517+ Timeout time.Duration
1518+ Log func(error)
1519+}
1520+type service struct {
1521+ options ServiceOptions
1522+ relay RelayClient
1523+ queue *Runner
1524+ ctx context.Context
1525+ mu sync.Mutex
1526+ decisions map[string]chan event.Event
1527+}
1528+
1529+func Serve(ctx context.Context, opts ServiceOptions) error {
1530+ if opts.Timeout <= 0 {
1531+ opts.Timeout = 30 * time.Minute
1532+ }
1533+ pubkey, err := event.PublicKey(opts.Secret)
1534+ if err != nil {
1535+ return err
1536+ }
1537+ s := &service{options: opts, ctx: ctx, decisions: map[string]chan event.Event{}, relay: RelayClient{URL: opts.RelayURL, Secret: opts.Secret, PubKey: pubkey, Rooms: opts.Rooms, OnError: opts.Log}}
1538+ queue, err := New(Options{AllowedRooms: opts.Rooms, StatePath: opts.StatePath, Handle: s.handle, OnError: opts.Log})
1539+ if err != nil {
1540+ return err
1541+ }
1542+ s.queue = queue
1543+ s.relay.OnEvent = s.receive
1544+ // Start queues only after a connection is established, so journal recovery
1545+ // can publish status before it invokes the agent process.
1546+ s.relay.OnReady = func() { queue.Start(ctx) }
1547+ defer queue.Stop()
1548+ err = s.relay.Run(ctx)
1549+ if errors.Is(err, context.Canceled) {
1550+ return nil
1551+ }
1552+ return err
1553+}
1554+func (s *service) receive(e event.Event) {
1555+ s.mu.Lock()
1556+ for _, id := range event.TagValues(e, "e") {
1557+ if wait := s.decisions[id]; wait != nil {
1558+ select {
1559+ case wait <- e:
1560+ default:
1561+ }
1562+ }
1563+ }
1564+ s.mu.Unlock()
1565+ m, ok := s.relay.Mention(e)
1566+ if !ok {
1567+ return
1568+ }
1569+ if isCancelCommand(m.Prompt) {
1570+ s.queue.CancelFrom(m.Room, m.Author)
1571+ return
1572+ }
1573+ if err := s.queue.Enqueue(m); err != nil && !errors.Is(err, ErrDuplicate) && s.options.Log != nil {
1574+ s.options.Log(err)
1575+ }
1576+}
1577+func (s *service) handle(parent context.Context, batch []Mention) error {
1578+ ctx, cancel := context.WithTimeout(parent, s.options.Timeout)
1579+ defer cancel()
1580+ first := batch[0]
1581+ tags := [][]string{{"h", first.Room}, {"e", first.Root}, {"p", s.relay.PubKey}, {"output", "text/plain"}, {"subject", shortText(first.Prompt, 100)}, {"expiration", strconv.FormatInt(time.Now().Add(s.options.Timeout).Unix(), 10)}}
1582+ var prompt strings.Builder
1583+ prompt.WriteString("These are messages addressed to you in a Nostr chat. Reply to the users' request. Room: " + first.Room + "\n\n")
1584+ for _, m := range batch {
1585+ tags = append(tags, []string{"i", m.EventID, "event"})
1586+ fmt.Fprintf(&prompt, "Author: %s\nMessage: %s\n%s\n\n", m.Author, m.EventID, m.Prompt)
1587+ }
1588+ request, err := s.relay.Publish(ctx, event.Event{Kind: 5000, CreatedAt: first.CreatedAt, Tags: tags, Content: shortText(first.Prompt, 1000)})
1589+ if err != nil {
1590+ return err
1591+ }
1592+ feedback := func(status, info string) error {
1593+ _, err := s.relay.Publish(ctx, event.Event{Kind: 7000, Tags: [][]string{{"h", first.Room}, {"e", request.ID}, {"p", request.PubKey}, {"status", status, shortText(info, 500)}}, Content: shortText(info, 4000)})
1594+ return err
1595+ }
1596+ if err := feedback("processing", "Working on your request"); err != nil {
1597+ return err
1598+ }
1599+ process := s.options.Process
1600+ var last time.Time
1601+ process.Progress = func(info string) {
1602+ if time.Since(last) < 2*time.Second {
1603+ return
1604+ }
1605+ last = time.Now()
1606+ if err := feedback("processing", info); err != nil {
1607+ cancel()
1608+ }
1609+ }
1610+ process.Permission = func(ctx context.Context, p Permission) (string, error) { return s.permission(ctx, batch, p) }
1611+ heartbeatCtx, stopHeartbeat := context.WithCancel(ctx)
1612+ heartbeatDone := make(chan struct{})
1613+ go func() { defer close(heartbeatDone); s.heartbeat(heartbeatCtx, first.Room) }()
1614+ output, runErr := RunProcess(ctx, process, prompt.String())
1615+ stopHeartbeat()
1616+ <-heartbeatDone
1617+ finishCtx, finish := context.WithTimeout(s.ctx, 10*time.Second)
1618+ defer finish()
1619+ finalStatus := "idle"
1620+ if runErr != nil {
1621+ finalStatus = "offline"
1622+ _ = s.publishPresence(finishCtx, first.Room, finalStatus)
1623+ message := "Agent stopped: " + runErr.Error()
1624+ if errors.Is(runErr, context.Canceled) {
1625+ message = "Canceled"
1626+ }
1627+ _, publishErr := s.relay.Publish(finishCtx, event.Event{Kind: 7000, Tags: [][]string{{"h", first.Room}, {"e", request.ID}, {"p", request.PubKey}, {"status", "error"}}, Content: shortText(message, 1000)})
1628+ if publishErr != nil {
1629+ return errors.Join(runErr, publishErr)
1630+ }
1631+ return runErr
1632+ }
1633+ _ = s.publishPresence(finishCtx, first.Room, finalStatus)
1634+ if output == "" {
1635+ output = "Task completed."
1636+ }
1637+ raw, _ := json.Marshal(request)
1638+ _, err = s.relay.Publish(ctx, event.Event{Kind: 6000, Tags: [][]string{{"h", first.Room}, {"e", request.ID}, {"p", request.PubKey}, {"request", string(raw)}}, Content: output})
1639+ if err != nil {
1640+ return err
1641+ }
1642+ replyTags := [][]string{{"h", first.Room}, {"e", first.Root, "", "root"}}
1643+ for _, m := range batch {
1644+ if !slices.ContainsFunc(replyTags, func(tag []string) bool { return len(tag) > 1 && tag[0] == "p" && tag[1] == m.Author }) {
1645+ replyTags = append(replyTags, []string{"p", m.Author})
1646+ }
1647+ }
1648+ _, err = s.relay.Publish(ctx, event.Event{Kind: 9, Tags: replyTags, Content: output})
1649+ return err
1650+}
1651+
1652+func isCancelCommand(prompt string) bool {
1653+ fields := strings.Fields(prompt)
1654+ return len(fields) > 0 && (fields[0] == "/cancel" || fields[0] == "/stop")
1655+}
1656+
1657+func (s *service) publishPresence(ctx context.Context, room, status string) error {
1658+ _, err := s.relay.Publish(ctx, event.Event{Kind: 20001, Tags: [][]string{{"h", room}}, Content: status})
1659+ return err
1660+}
1661+func (s *service) heartbeat(ctx context.Context, room string) {
1662+ timer := time.NewTicker(30 * time.Second)
1663+ defer timer.Stop()
1664+ for {
1665+ _, err := s.relay.Publish(ctx, event.Event{Kind: 20001, Tags: [][]string{{"h", room}}, Content: "working"})
1666+ if err != nil {
1667+ return
1668+ }
1669+ select {
1670+ case <-ctx.Done():
1671+ return
1672+ case <-timer.C:
1673+ }
1674+ }
1675+}
1676+func (s *service) permission(ctx context.Context, batch []Mention, p Permission) (string, error) {
1677+ allow, deny := "", ""
1678+ for _, option := range p.Options {
1679+ if option.Kind == "allow_once" {
1680+ allow = option.ID
1681+ }
1682+ if option.Kind == "reject_once" {
1683+ deny = option.ID
1684+ }
1685+ }
1686+ if allow == "" {
1687+ return deny, nil
1688+ }
1689+ first := batch[0]
1690+ expires := time.Now().Add(10 * time.Minute).Unix()
1691+ if deadline, ok := ctx.Deadline(); ok && deadline.Unix() < expires {
1692+ expires = deadline.Unix()
1693+ }
1694+ request := event.Event{Kind: 9, CreatedAt: time.Now().Unix(), Tags: [][]string{{"h", first.Room}, {"e", first.Root, "", "root"}, {"request", "approve"}, {"d", uuid.NewString()}, {"expiration", strconv.FormatInt(expires, 10)}, {"subject", shortText(p.Title, 150)}, {"p", first.Author}}, Content: "Allow this tool action once?\n\n" + shortText(p.Title, 4000) + "\n\n" + p.Details}
1695+ if err := event.Sign(&request, s.options.Secret); err != nil {
1696+ return "", err
1697+ }
1698+ wait := make(chan event.Event, 1)
1699+ s.mu.Lock()
1700+ s.decisions[request.ID] = wait
1701+ s.mu.Unlock()
1702+ defer func() { s.mu.Lock(); delete(s.decisions, request.ID); s.mu.Unlock() }()
1703+ if _, err := s.relay.Publish(ctx, request); err != nil {
1704+ return "", err
1705+ }
1706+ for {
1707+ select {
1708+ case <-ctx.Done():
1709+ return "", ctx.Err()
1710+ case answer := <-wait:
1711+ if answer.PubKey != first.Author || answer.Kind != 7 {
1712+ continue
1713+ }
1714+ switch strings.TrimSpace(answer.Content) {
1715+ case "+", "":
1716+ return allow, nil
1717+ case "-":
1718+ return deny, nil
1719+ }
1720+ }
1721+ }
1722+}
1723+func shortText(value string, size int) string {
1724+ runes := []rune(value)
1725+ if len(runes) > size {
1726+ return string(runes[:size]) + "…"
1727+ }
1728+ return value
1729+}
1730diff --git a/internal/community/agent_grant_requests.go b/internal/community/agent_grant_requests.go
1731new file mode 100644
1732index 0000000..8139e84
1733--- /dev/null
1734+++ b/internal/community/agent_grant_requests.go
1735@@ -0,0 +1,350 @@
1736+package community
1737+
1738+// Agent grant requests are deliberately ordinary NIP-22 comments. The
1739+// request describes a narrow change to the current grant; the operator's
1740+// signed 30392 replacement remains the authority.
1741+
1742+import (
1743+ "context"
1744+ "database/sql"
1745+ "encoding/json"
1746+ "errors"
1747+ "fmt"
1748+ "io"
1749+ "reflect"
1750+ "slices"
1751+ "strings"
1752+ "unicode/utf8"
1753+
1754+ "github.com/FelineStateMachine/tinyrelay/internal/event"
1755+)
1756+
1757+const agentGrantReasonMax = 500
1758+
1759+type grantRequestReader interface {
1760+ QueryRowContext(context.Context, string, ...any) *sql.Row
1761+}
1762+
1763+func grantRequestOpen(ctx context.Context, db grantRequestReader, request event.Event, now int64) error {
1764+ if expires := event.Expiration(request); expires != 0 && expires <= now {
1765+ return errors.New("conflict: grant request has expired")
1766+ }
1767+ var denied bool
1768+ err := db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM events AS reaction
1769+ WHERE kind=7 AND pubkey=? AND trim(json_extract(raw,'$.content'))='-' AND EXISTS(
1770+ SELECT 1 FROM tags WHERE event_id=reaction.id AND name='e' AND value=?))`, event.Tag(request, "p"), request.ID).Scan(&denied)
1771+ if err != nil {
1772+ return err
1773+ }
1774+ if denied {
1775+ return errors.New("conflict: grant request was denied")
1776+ }
1777+ return nil
1778+}
1779+
1780+type AgentGrantChanges struct {
1781+ Kinds []int `json:"kinds,omitempty"`
1782+ Rooms []string `json:"rooms,omitempty"`
1783+ Repos []AgentRepo `json:"repos,omitempty"`
1784+ Sites []AgentSite `json:"sites,omitempty"`
1785+ Wiki *string `json:"wiki,omitempty"`
1786+ Jobs *string `json:"jobs,omitempty"`
1787+ Rate *int `json:"rate,omitempty"`
1788+}
1789+
1790+type AgentGrantRequest struct {
1791+ Base string `json:"base"`
1792+ Changes AgentGrantChanges `json:"changes"`
1793+}
1794+
1795+type AgentGrantReview struct {
1796+ RequestID string `json:"request_id"`
1797+ Base string `json:"base"`
1798+ Agent string `json:"agent"`
1799+ Operator string `json:"operator"`
1800+ Before AgentGrant `json:"before"`
1801+ After AgentGrant `json:"after"`
1802+ Unsigned event.Event `json:"unsigned"`
1803+}
1804+
1805+func IsAgentGrantRequest(e event.Event) bool {
1806+ return e.Kind == 1111 && event.Tag(e, "request") == "grant"
1807+}
1808+
1809+func decodeGrantRequest(e event.Event) (AgentGrantRequest, error) {
1810+ if !IsAgentGrantRequest(e) {
1811+ return AgentGrantRequest{}, errors.New("invalid: not an agent grant request")
1812+ }
1813+ if strings.TrimSpace(e.Content) == "" || len([]rune(e.Content)) > agentGrantReasonMax {
1814+ return AgentGrantRequest{}, fmt.Errorf("invalid: grant request rationale must be 1 to %d characters", agentGrantReasonMax)
1815+ }
1816+ if !utf8.ValidString(e.Content) {
1817+ return AgentGrantRequest{}, errors.New("invalid: grant request rationale is not valid UTF-8")
1818+ }
1819+ var req AgentGrantRequest
1820+ dec := json.NewDecoder(strings.NewReader(event.Tag(e, "grant")))
1821+ dec.DisallowUnknownFields()
1822+ if err := dec.Decode(&req); err != nil || req.Base == "" {
1823+ return AgentGrantRequest{}, errors.New("invalid: grant request needs a base and changes")
1824+ }
1825+ var extra any
1826+ if err := dec.Decode(&extra); err != io.EOF {
1827+ return AgentGrantRequest{}, errors.New("invalid: grant request JSON has trailing data")
1828+ }
1829+ allowed := map[string]bool{"request": true, "grant": true, "E": true, "K": true, "P": true, "e": true, "k": true, "p": true, "subject": true, "expiration": true}
1830+ seen := map[string]bool{}
1831+ for _, tag := range e.Tags {
1832+ if len(tag) == 0 || !allowed[tag[0]] {
1833+ return AgentGrantRequest{}, errors.New("invalid: grant request contains an unsupported tag")
1834+ }
1835+ if seen[tag[0]] {
1836+ return AgentGrantRequest{}, errors.New("invalid: grant request contains duplicate tags")
1837+ }
1838+ seen[tag[0]] = true
1839+ }
1840+ if req.Changes.Kinds == nil && req.Changes.Rooms == nil && req.Changes.Repos == nil && req.Changes.Sites == nil && req.Changes.Wiki == nil && req.Changes.Jobs == nil && req.Changes.Rate == nil {
1841+ return AgentGrantRequest{}, errors.New("invalid: grant request has no changes")
1842+ }
1843+ if err := validateGrantChanges(req.Changes); err != nil {
1844+ return AgentGrantRequest{}, err
1845+ }
1846+ return req, nil
1847+}
1848+
1849+func validateGrantChanges(changes AgentGrantChanges) error {
1850+ for _, room := range changes.Rooms {
1851+ if !ValidRoomID(room) {
1852+ return errors.New("invalid: grant request room")
1853+ }
1854+ }
1855+ repos, sites := map[string]bool{}, map[string]bool{}
1856+ for _, repo := range changes.Repos {
1857+ key := repo.Owner + ":" + repo.Identifier
1858+ if repos[key] {
1859+ return errors.New("invalid: repeated repository in grant request")
1860+ }
1861+ repos[key] = true
1862+ }
1863+ for _, site := range changes.Sites {
1864+ if sites[site.Label] || site.TTLDays < 0 {
1865+ return errors.New("invalid: duplicate site or negative TTL in grant request")
1866+ }
1867+ sites[site.Label] = true
1868+ }
1869+ return nil
1870+}
1871+
1872+func (s *Service) ReviewAgentGrantRequest(ctx context.Context, e event.Event, now int64) (AgentGrantReview, error) {
1873+ req, err := decodeGrantRequest(e)
1874+ if err != nil {
1875+ return AgentGrantReview{}, err
1876+ }
1877+ if err := grantRequestOpen(ctx, s.store.DB(), e, now); err != nil {
1878+ return AgentGrantReview{}, err
1879+ }
1880+ operator := event.Tag(e, "p")
1881+ if req.Base != event.Tag(e, "E") || req.Base != event.Tag(e, "e") {
1882+ return AgentGrantReview{}, errors.New("invalid: grant request base must match its NIP-22 references")
1883+ }
1884+ if len(event.TagValues(e, "p")) != 1 || operator == "" {
1885+ return AgentGrantReview{}, errors.New("invalid: grant request needs exactly one operator")
1886+ }
1887+ if len(event.Tag(e, "E")) != 64 || event.Tag(e, "e") != event.Tag(e, "E") || event.Tag(e, "K") != "30392" || event.Tag(e, "k") != "30392" || event.Tag(e, "P") != operator || event.Tag(e, "p") != operator {
1888+ return AgentGrantReview{}, errors.New("invalid: grant request needs matching NIP-22 grant references")
1889+ }
1890+ var raw string
1891+ var currentID string
1892+ var paused int
1893+ var revokedAt int64
1894+ if err := s.store.DB().QueryRowContext(ctx, `SELECT event_id,paused,revoked_at FROM agent_grants WHERE agent=?`, e.PubKey).Scan(&currentID, &paused, &revokedAt); err != nil || currentID != req.Base {
1895+ return AgentGrantReview{}, errors.New("conflict: grant request is no longer for the current grant")
1896+ }
1897+ if err := s.store.DB().QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND kind=?`, req.Base, event.KIND_AGENT_GRANT).Scan(&raw); err != nil {
1898+ if err := s.store.DB().QueryRowContext(ctx, `SELECT raw FROM agent_grant_revisions WHERE event_id=?`, req.Base).Scan(&raw); err != nil {
1899+ return AgentGrantReview{}, errors.New("invalid: current agent grant not found")
1900+ }
1901+ }
1902+ var beforeEvent event.Event
1903+ if beforeEvent, err = event.Parse([]byte(raw)); err != nil {
1904+ return AgentGrantReview{}, err
1905+ }
1906+ review, err := s.reviewAgainstGrant(e, req, beforeEvent, now)
1907+ if err != nil {
1908+ return AgentGrantReview{}, err
1909+ }
1910+ review.Before.Paused = paused != 0
1911+ review.Before.RevokedAt = revokedAt
1912+ if !review.Before.Active(now) {
1913+ return AgentGrantReview{}, errors.New("restricted: grant request is not for the agent's active current grant")
1914+ }
1915+ return review, nil
1916+}
1917+
1918+func (s *Service) reviewAgainstGrant(e event.Event, req AgentGrantRequest, beforeEvent event.Event, now int64) (AgentGrantReview, error) {
1919+ operator := event.Tag(e, "p")
1920+ before, err := ParseAgentGrant(beforeEvent, now)
1921+ if err != nil {
1922+ return AgentGrantReview{}, err
1923+ }
1924+ if before.Agent != e.PubKey || before.Owner != operator || !before.Active(now) {
1925+ return AgentGrantReview{}, errors.New("restricted: grant request is not for the agent's active current grant")
1926+ }
1927+ afterEvent := beforeEvent
1928+ afterEvent.Tags = grantRequestTags(beforeEvent.Tags, req.Changes)
1929+ afterEvent.Tags = append(afterEvent.Tags, []string{"grant-request", e.ID}, []string{"grant-base", req.Base}, []string{"e", e.ID, "", "grant-request"})
1930+ afterEvent.ID, afterEvent.Sig, afterEvent.CreatedAt = "", "", now
1931+ after, err := ParseAgentGrant(afterEvent, now)
1932+ if err != nil {
1933+ return AgentGrantReview{}, err
1934+ }
1935+ if reflect.DeepEqual(after.Scope, before.Scope) {
1936+ return AgentGrantReview{}, errors.New("invalid: grant request changes nothing")
1937+ }
1938+ return AgentGrantReview{RequestID: e.ID, Base: req.Base, Agent: before.Agent, Operator: operator, Before: before, After: after, Unsigned: afterEvent}, nil
1939+}
1940+
1941+func (s *Service) ValidateAgentGrantReplacement(ctx context.Context, e event.Event, now int64) error {
1942+ return s.validateGrantReplacement(ctx, s.store.DB(), e, now)
1943+}
1944+
1945+func (s *Service) validateGrantReplacement(ctx context.Context, tx grantRequestReader, e event.Event, now int64) error {
1946+ requestID := event.Tag(e, "grant-request")
1947+ baseID := event.Tag(e, "grant-base")
1948+ if requestID == "" || baseID == "" || !slices.Contains(event.TagValues(e, "e"), requestID) {
1949+ return errors.New("invalid: grant replacement needs grant request provenance")
1950+ }
1951+ var raw string
1952+ if err := tx.QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND kind=1111`, requestID).Scan(&raw); err != nil {
1953+ return errors.New("invalid: grant request is not stored")
1954+ }
1955+ request, err := event.Parse([]byte(raw))
1956+ if err != nil {
1957+ return err
1958+ }
1959+ if err := grantRequestOpen(ctx, tx, request, now); err != nil {
1960+ return err
1961+ }
1962+ var grantRaw string
1963+ if err := tx.QueryRowContext(ctx, `SELECT raw FROM events WHERE id=? AND kind=?`, baseID, event.KIND_AGENT_GRANT).Scan(&grantRaw); err != nil {
1964+ if err := tx.QueryRowContext(ctx, `SELECT raw FROM agent_grant_revisions WHERE event_id=?`, baseID).Scan(&grantRaw); err != nil {
1965+ return errors.New("invalid: current agent grant not found")
1966+ }
1967+ }
1968+ grantEvent, err := event.Parse([]byte(grantRaw))
1969+ if err != nil {
1970+ return err
1971+ }
1972+ req, err := decodeGrantRequest(request)
1973+ if err != nil {
1974+ return err
1975+ }
1976+ review, err := s.reviewAgainstGrant(request, req, grantEvent, now)
1977+ if err != nil || review.Base != baseID || review.Operator != e.PubKey || review.Agent != event.Tag(e, "d") {
1978+ return errors.New("restricted: grant replacement does not match the reviewed request")
1979+ }
1980+ var paused int
1981+ var revokedAt int64
1982+ if err := tx.QueryRowContext(ctx, `SELECT paused,revoked_at FROM agent_grants WHERE agent=?`, review.Agent).Scan(&paused, &revokedAt); err != nil {
1983+ return errors.New("conflict: agent grant is no longer current")
1984+ }
1985+ if paused != 0 || revokedAt != 0 || review.After.ExpiresAt <= now {
1986+ return errors.New("restricted: grant request is no longer active")
1987+ }
1988+ got, err := ParseAgentGrant(e, now)
1989+ if err != nil {
1990+ return err
1991+ }
1992+ if !reflect.DeepEqual(got.Scope, review.After.Scope) || got.ExpiresAt != review.After.ExpiresAt || got.Agent != review.After.Agent || got.Owner != review.After.Owner || got.Name != review.After.Name || e.Content != grantEvent.Content {
1993+ return errors.New("invalid: grant replacement scope differs from the reviewed request")
1994+ }
1995+ if !reflect.DeepEqual(e.Tags, review.Unsigned.Tags) {
1996+ return errors.New("invalid: grant replacement tags differ from the reviewed request")
1997+ }
1998+ var current string
1999+ if err := tx.QueryRowContext(ctx, `SELECT event_id FROM agent_grants WHERE agent=?`, got.Agent).Scan(&current); err != nil || current != baseID {
2000+ return errors.New("conflict: agent grant changed since the request")
2001+ }
2002+ return nil
2003+}
2004+
2005+func grantRequestTags(tags [][]string, changes AgentGrantChanges) [][]string {
2006+ replaceRepo := map[string]bool{}
2007+ for _, repo := range changes.Repos {
2008+ replaceRepo[repo.Owner+":"+repo.Identifier] = true
2009+ }
2010+ replaceSite := map[string]bool{}
2011+ for _, site := range changes.Sites {
2012+ replaceSite[site.Label] = true
2013+ }
2014+ copyTags := make([][]string, 0, len(tags)+len(changes.Kinds)+len(changes.Rooms))
2015+ seenKinds := map[string]bool{}
2016+ seenRooms := map[string]bool{}
2017+ remove := map[string]bool{}
2018+ if changes.Wiki != nil {
2019+ remove["wiki"] = true
2020+ }
2021+ if changes.Jobs != nil {
2022+ remove["jobs"] = true
2023+ }
2024+ if changes.Rate != nil {
2025+ remove["rate"] = true
2026+ }
2027+ for _, tag := range tags {
2028+ skip := false
2029+ if len(tag) > 1 && tag[0] == "repo" {
2030+ parts := strings.SplitN(tag[1], ":", 3)
2031+ skip = len(parts) == 3 && replaceRepo[parts[0]+":"+parts[1]]
2032+ }
2033+ if len(tag) > 1 && tag[0] == "sites" {
2034+ skip = replaceSite[tag[1]]
2035+ }
2036+ if len(tag) > 0 && !remove[tag[0]] && !skip && tag[0] != "grant-request" && tag[0] != "grant-base" && !(tag[0] == "e" && len(tag) > 3 && tag[3] == "grant-request") {
2037+ copyTags = append(copyTags, append([]string(nil), tag...))
2038+ if len(tag) > 1 && tag[0] == "k" {
2039+ seenKinds[tag[1]] = true
2040+ }
2041+ if len(tag) > 1 && tag[0] == "room" {
2042+ seenRooms[tag[1]] = true
2043+ }
2044+ }
2045+ }
2046+ // Changes are patches: retain the old entries and add or replace only the
2047+ // requested keys. Reconstructing these fields also gives deterministic
2048+ // output when a request repeats an existing value.
2049+ for _, k := range changes.Kinds {
2050+ value := fmt.Sprint(k)
2051+ if !seenKinds[value] {
2052+ copyTags = append(copyTags, []string{"k", value})
2053+ seenKinds[value] = true
2054+ }
2055+ }
2056+ for _, room := range changes.Rooms {
2057+ if !seenRooms[room] {
2058+ copyTags = append(copyTags, []string{"room", room})
2059+ seenRooms[room] = true
2060+ }
2061+ }
2062+ for _, repo := range changes.Repos {
2063+ copyTags = append(copyTags, []string{"repo", repo.Owner + ":" + repo.Identifier + ":" + repo.Level})
2064+ }
2065+ for _, site := range changes.Sites {
2066+ tag := []string{"sites", site.Label}
2067+ if site.TTLDays > 0 {
2068+ tag = append(tag, fmt.Sprintf("ttl=%d", site.TTLDays))
2069+ }
2070+ if site.Encrypted {
2071+ tag = append(tag, "encrypted")
2072+ }
2073+ copyTags = append(copyTags, tag)
2074+ }
2075+ if changes.Wiki != nil {
2076+ copyTags = append(copyTags, []string{"wiki", *changes.Wiki})
2077+ }
2078+ if changes.Jobs != nil {
2079+ copyTags = append(copyTags, []string{"jobs", *changes.Jobs})
2080+ }
2081+ if changes.Rate != nil {
2082+ copyTags = append(copyTags, []string{"rate", fmt.Sprint(*changes.Rate)})
2083+ }
2084+ return copyTags
2085+}
2086diff --git a/internal/community/agent_grant_requests_test.go b/internal/community/agent_grant_requests_test.go
2087new file mode 100644
2088index 0000000..211c05a
2089--- /dev/null
2090+++ b/internal/community/agent_grant_requests_test.go
2091@@ -0,0 +1,34 @@
2092+package community
2093+
2094+import (
2095+ "reflect"
2096+ "testing"
2097+)
2098+
2099+func TestGrantRequestTagsPatchOnlyRequestedEntries(t *testing.T) {
2100+ old := [][]string{
2101+ {"d", "agent"}, {"p", "agent"}, {"expiration", "200"},
2102+ {"k", "1"}, {"k", "9"}, {"room", "alpha"}, {"room", "beta"},
2103+ {"repo", "owner:one:read"}, {"repo", "owner:two:maintain"},
2104+ {"grant-request", "old"}, {"grant-base", "old"}, {"e", "old", "", "grant-request"},
2105+ }
2106+ want := [][]string{
2107+ {"d", "agent"}, {"p", "agent"}, {"expiration", "200"},
2108+ {"k", "1"}, {"k", "9"}, {"room", "alpha"}, {"room", "beta"},
2109+ {"repo", "owner:one:read"}, {"repo", "owner:two:maintain"},
2110+ {"k", "11"},
2111+ }
2112+ got := grantRequestTags(old, AgentGrantChanges{Kinds: []int{11}, Rooms: []string{"alpha"}})
2113+ if !reflect.DeepEqual(got, want) {
2114+ t.Fatalf("patch tags = %#v, want %#v", got, want)
2115+ }
2116+}
2117+
2118+func TestGrantRequestPatchKeepsOtherRepositoriesAndSites(t *testing.T) {
2119+ old := [][]string{{"repo", "owner:one:read"}, {"repo", "owner:two:maintain"}, {"sites", "one", "encrypted"}, {"sites", "two", "ttl=7"}}
2120+ changed := grantRequestTags(old, AgentGrantChanges{Repos: []AgentRepo{{Owner: "owner", Identifier: "one", Level: "maintain"}}, Sites: []AgentSite{{Label: "one", TTLDays: 3}}})
2121+ want := [][]string{{"repo", "owner:two:maintain"}, {"sites", "two", "ttl=7"}, {"repo", "owner:one:maintain"}, {"sites", "one", "ttl=3"}}
2122+ if !reflect.DeepEqual(changed, want) {
2123+ t.Fatalf("patch changed unrelated entries: %v", changed)
2124+ }
2125+}
2126diff --git a/internal/community/agents.go b/internal/community/agents.go
2127index 24173e8..7a9983f 100644
2128--- a/internal/community/agents.go
2129+++ b/internal/community/agents.go
2130@@ -506,6 +506,11 @@ func (s *Service) AgentGrant(ctx context.Context, pubkey string) (AgentGrant, bo
2131 func (s *Service) ApplyAgentEventTx(ctx context.Context, tx *sql.Tx, e event.Event, now int64) error {
2132 switch e.Kind {
2133 case event.KIND_AGENT_GRANT:
2134+ if event.Tag(e, "grant-request") != "" || event.Tag(e, "grant-base") != "" {
2135+ if err := s.validateGrantReplacement(ctx, tx, e, now); err != nil {
2136+ return err
2137+ }
2138+ }
2139 grant, err := ParseAgentGrant(e, now)
2140 if err != nil {
2141 return err
2142diff --git a/internal/daemon/approvals.go b/internal/daemon/approvals.go
2143index 7ac0d3b..97c7457 100644
2144--- a/internal/daemon/approvals.go
2145+++ b/internal/daemon/approvals.go
2146@@ -13,6 +13,7 @@ import (
2147 "strings"
2148 "time"
2149
2150+ "github.com/FelineStateMachine/tinyrelay/internal/community"
2151 "github.com/FelineStateMachine/tinyrelay/internal/event"
2152 "github.com/FelineStateMachine/tinyrelay/internal/storage"
2153 )
2154@@ -26,8 +27,8 @@ const (
2155 )
2156
2157 // approvalTypes is the bounded vocabulary of the request tag.
2158-var approvalTypes = []string{"approve", "decide", "question"}
2159-var approvalKinds = []int{kindComment, kindChatMessage, kindThreadRoot}
2160+var approvalTypes = []string{"approve", "decide", "question", "grant"}
2161+var approvalKinds = []int{kindComment, kindChatMessage, kindThreadRoot, event.KIND_THREAD_REPLY}
2162
2163 const (
2164 approvalWikiProposal = "wiki-proposal"
2165@@ -36,20 +37,22 @@ const (
2166
2167 // approvalItem is one request as the asked person sees it.
2168 type approvalItem struct {
2169- ID string `json:"id"`
2170- Kind int `json:"kind"`
2171- Type string `json:"type"`
2172- Asker string `json:"asker"`
2173- Asked []string `json:"asked"`
2174- Subject string `json:"subject,omitempty"`
2175- Content string `json:"content"`
2176- CreatedAt int64 `json:"created_at"`
2177- Expires int64 `json:"expires,omitempty"`
2178- State string `json:"state"`
2179- Room string `json:"room,omitempty"`
2180- About *approvalAbout `json:"about,omitempty"`
2181- Answer *approvalAnswer `json:"answer,omitempty"`
2182- Event event.Event `json:"event"`
2183+ ID string `json:"id"`
2184+ Kind int `json:"kind"`
2185+ Type string `json:"type"`
2186+ Asker string `json:"asker"`
2187+ Asked []string `json:"asked"`
2188+ Subject string `json:"subject,omitempty"`
2189+ Content string `json:"content"`
2190+ CreatedAt int64 `json:"created_at"`
2191+ Expires int64 `json:"expires,omitempty"`
2192+ State string `json:"state"`
2193+ Room string `json:"room,omitempty"`
2194+ About *approvalAbout `json:"about,omitempty"`
2195+ Answer *approvalAnswer `json:"answer,omitempty"`
2196+ Event event.Event `json:"event"`
2197+ Grant *community.AgentGrantReview `json:"grant,omitempty"`
2198+ GrantError string `json:"grant_error,omitempty"`
2199 }
2200
2201 // approvalAbout is what the request refers to: a repository coordinate, an
2202@@ -82,6 +85,9 @@ func approvalRequest(e event.Event) (string, bool) {
2203 if !containsString(approvalTypes, kind) || len(approvalAsked(e)) == 0 {
2204 return "", false
2205 }
2206+ if kind == "grant" && !community.IsAgentGrantRequest(e) {
2207+ return "", false
2208+ }
2209 return kind, true
2210 }
2211
2212@@ -166,6 +172,11 @@ func (t *Tenant) approvalAnswers(ctx context.Context, items []approvalItem, now
2213 if err != nil {
2214 return nil, err
2215 }
2216+ grants, err := t.grantApprovalHistory(ctx, items)
2217+ if err != nil {
2218+ return nil, err
2219+ }
2220+ rows.Events = append(rows.Events, grants...)
2221 sortEventsNewestFirst(rows.Events)
2222 for _, row := range rows.Events {
2223 if _, isRequest := approvalRequest(row); isRequest {
2224@@ -183,6 +194,10 @@ func (t *Tenant) approvalAnswers(ctx context.Context, items []approvalItem, now
2225 // approvalSettle fills the answer and state of one request from its answers,
2226 // which arrive newest first.
2227 func approvalSettle(item *approvalItem, rows []event.Event, now int64) {
2228+ if item.Type == "grant" {
2229+ settleGrantApproval(item, rows, now)
2230+ return
2231+ }
2232 var reply *event.Event
2233 for i := range rows {
2234 row := rows[i]
2235@@ -192,7 +207,7 @@ func approvalSettle(item *approvalItem, rows []event.Event, now int64) {
2236 item.State = "answered"
2237 return
2238 }
2239- } else if reply == nil {
2240+ } else if row.Kind == kindComment && reply == nil {
2241 reply = &row
2242 }
2243 }
2244@@ -230,6 +245,7 @@ func (t *Tenant) approvalsFor(ctx context.Context, pubkey string, before *storag
2245 }
2246 for i := range items {
2247 approvalSettle(&items[i], answers[items[i].ID], now)
2248+ t.reviewGrantApproval(ctx, &items[i], now)
2249 }
2250 wikiItems, wikiMore, err := t.wikiApprovalItems(ctx, pubkey, before, limit)
2251 if err != nil {
2252@@ -288,6 +304,10 @@ func (t *Tenant) approvalNotices(e event.Event, kind string) []pushNotice {
2253 if kind != "question" {
2254 actions = []pushAction{{Action: "approve", Title: "Approve"}, {Action: "deny", Title: "Deny"}, {Action: "reply", Title: "Reply"}}
2255 }
2256+ if kind == "grant" {
2257+ body = shortKey(e.PubKey) + " requests an access change: " + excerpt(e.Content)
2258+ actions = []pushAction{{Action: "review", Title: "Review access"}}
2259+ }
2260 var notices []pushNotice
2261 for _, recipient := range approvalAsked(e) {
2262 notices = append(notices, pushNotice{recipient: recipient, category: pushApprovals, body: body, url: base + "/approvals?id=" + e.ID, actions: actions})
2263diff --git a/internal/daemon/approvals_browse.go b/internal/daemon/approvals_browse.go
2264index 1bf34b3..281a746 100644
2265--- a/internal/daemon/approvals_browse.go
2266+++ b/internal/daemon/approvals_browse.go
2267@@ -179,6 +179,7 @@ func (t *Tenant) browseApproval(ctx context.Context, actor, id string) (any, err
2268 return nil, err
2269 }
2270 approvalSettle(&item, answers[item.ID], now)
2271+ t.reviewGrantApproval(ctx, &item, now)
2272 history := answers[item.ID]
2273 if history == nil {
2274 history = []event.Event{}
2275diff --git a/internal/daemon/chat_activity.go b/internal/daemon/chat_activity.go
2276new file mode 100644
2277index 0000000..037210b
2278--- /dev/null
2279+++ b/internal/daemon/chat_activity.go
2280@@ -0,0 +1,123 @@
2281+package daemon
2282+
2283+import (
2284+ "context"
2285+ "encoding/json"
2286+ "errors"
2287+ "time"
2288+
2289+ "github.com/FelineStateMachine/tinyrelay/internal/event"
2290+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
2291+)
2292+
2293+func (t *Tenant) browseChatActivity(ctx context.Context, actor string, params []json.RawMessage) (any, error) {
2294+ var q struct {
2295+ Room string `json:"room"`
2296+ Root string `json:"root"`
2297+ }
2298+ if len(params) != 1 || json.Unmarshal(params[0], &q) != nil || q.Room == "" {
2299+ return nil, errors.New("invalid: chat activity room")
2300+ }
2301+ if _, _, err := t.roomFor(ctx, actor, q.Room); err != nil {
2302+ return nil, err
2303+ }
2304+ if q.Root != "" {
2305+ root, err := t.roomThreadRoot(ctx, actor, q.Room, q.Root)
2306+ if err != nil {
2307+ return nil, err
2308+ }
2309+ q.Root = root.ID
2310+ }
2311+ rows := []event.Event{}
2312+ for _, filter := range []event.Filter{
2313+ {Kinds: approvalKinds, Tags: map[string][]string{"h": {q.Room}}},
2314+ {Kinds: jobRequestKinds, Tags: map[string][]string{"h": {q.Room}}},
2315+ } {
2316+ page, err := t.store.Query(ctx, filter, storage.QueryOptions{Access: storage.Access{PubKeys: []string{actor}}, Limit: 1000})
2317+ if err != nil {
2318+ return nil, err
2319+ }
2320+ rows = append(rows, page.Events...)
2321+ }
2322+ session := browseSession(t, actor)
2323+ jobs, approvals := []jobItem{}, []approvalItem{}
2324+ now := time.Now().Unix()
2325+ roots := map[string]string{}
2326+ for _, row := range rows {
2327+ _, isApproval := approvalRequest(row)
2328+ if !event.IsJobRequest(row.Kind) && !isApproval {
2329+ continue
2330+ }
2331+ if !t.gate.CanSee(ctx, row, session, nil) {
2332+ continue
2333+ }
2334+ if q.Root != "" && !t.activityInThread(ctx, actor, q.Root, row, roots) {
2335+ continue
2336+ }
2337+ if event.IsJobRequest(row.Kind) && t.Policy().Features.Jobs && (event.Expiration(row) == 0 || event.Expiration(row) > now) {
2338+ jobs = append(jobs, jobItemFrom(row))
2339+ } else if kind, ok := approvalRequest(row); ok && (row.PubKey == actor || containsString(approvalAsked(row), actor)) {
2340+ approvals = append(approvals, approvalItemFrom(row, kind, t.publicURL))
2341+ }
2342+ }
2343+ // Keep the inline panel bounded even when a busy room has accumulated a
2344+ // large amount of historical task and approval traffic. The store returns
2345+ // each filter newest first, so retaining the first 50 preserves the useful
2346+ // recent context while the dedicated views remain available for history.
2347+ if len(jobs) > 50 {
2348+ jobs = jobs[:50]
2349+ }
2350+ if len(approvals) > 50 {
2351+ approvals = approvals[:50]
2352+ }
2353+ answers, err := t.jobAnswers(ctx, jobs, session, now)
2354+ if err != nil {
2355+ return nil, err
2356+ }
2357+ for i := range jobs {
2358+ jobSettle(&jobs[i], answers[jobs[i].ID])
2359+ }
2360+ decisions, err := t.approvalAnswers(ctx, approvals, now)
2361+ if err != nil {
2362+ return nil, err
2363+ }
2364+ for i := range approvals {
2365+ approvalSettle(&approvals[i], decisions[approvals[i].ID], now)
2366+ }
2367+ return map[string]any{"jobs": jobs, "approvals": approvals}, nil
2368+}
2369+
2370+func (b backend) ReadChatActivity(ctx context.Context, actor, room, root string) (any, error) {
2371+ raw, err := json.Marshal(map[string]string{"room": room, "root": root})
2372+ if err != nil {
2373+ return nil, err
2374+ }
2375+ return b.tenant.Execute(ctx, actor, "browsechatactivity", []json.RawMessage{raw})
2376+}
2377+
2378+// Resolve parent-only references once per parent while keeping unrelated tasks
2379+// out of the thread. roomThreadRoot also enforces room and reader access.
2380+func (t *Tenant) activityInThread(ctx context.Context, actor, root string, row event.Event, roots map[string]string) bool {
2381+ if row.ID == root {
2382+ return true
2383+ }
2384+ reference := event.RoomReplyRoot(row)
2385+ if reference == "" && event.IsJobRequest(row.Kind) {
2386+ reference = event.Tag(row, "e")
2387+ }
2388+ if reference == root {
2389+ return true
2390+ }
2391+ if reference == "" {
2392+ return false
2393+ }
2394+ canonical, known := roots[reference]
2395+ if !known {
2396+ parent, err := t.roomThreadRoot(ctx, actor, event.Tag(row, "h"), reference)
2397+ if err == nil {
2398+ canonical = parent.ID
2399+ }
2400+ roots[reference] = canonical
2401+ }
2402+ return canonical == root
2403+}
2404diff --git a/internal/daemon/chat_activity_test.go b/internal/daemon/chat_activity_test.go
2405new file mode 100644
2406index 0000000..caf0620
2407--- /dev/null
2408+++ b/internal/daemon/chat_activity_test.go
2409@@ -0,0 +1,88 @@
2410+package daemon
2411+
2412+import (
2413+ "encoding/json"
2414+ "github.com/FelineStateMachine/tinyrelay/internal/event"
2415+ "testing"
2416+)
2417+
2418+func TestChatActivitySharesApprovalStateAndScopesRooms(t *testing.T) {
2419+ h := newRoomHarness(t)
2420+ request := h.must("alice", 5000, [][]string{{"h", "main"}, {"p", h.keys["bob"]}, {"subject", "Build preview"}}, "Build preview")
2421+ h.must("bob", 7000, [][]string{{"h", "main"}, {"e", request.ID}, {"p", h.keys["alice"]}, {"status", "processing", "Running checks"}}, "2 of 3 checks complete")
2422+ decision := h.must("alice", 9, [][]string{{"h", "main"}, {"request", "approve"}, {"p", h.keys["bob"]}, {"subject", "Publish preview"}}, "Publish it?")
2423+ read := func(who, room string) map[string]any {
2424+ t.Helper()
2425+ raw, err := h.tenant.Execute(h.ctx, h.keys[who], "browsechatactivity", []json.RawMessage{rawJSON(map[string]string{"room": room})})
2426+ if err != nil {
2427+ t.Fatal(err)
2428+ }
2429+ return raw.(map[string]any)
2430+ }
2431+ data := read("bob", "main")
2432+ jobs := data["jobs"].([]jobItem)
2433+ if len(jobs) != 1 || jobs[0].Status != "processing" {
2434+ t.Fatalf("jobs=%v", jobs)
2435+ }
2436+ approvals := data["approvals"].([]approvalItem)
2437+ if len(approvals) != 1 || approvals[0].State != "open" {
2438+ t.Fatalf("approvals=%v", approvals)
2439+ }
2440+ h.must("bob", 7, [][]string{{"h", "main"}, {"e", decision.ID}, {"p", h.keys["alice"]}}, "+")
2441+ data = read("bob", "main")
2442+ if data["approvals"].([]approvalItem)[0].State != "answered" {
2443+ t.Fatal("inline approval not settled")
2444+ }
2445+ detail, err := h.tenant.Execute(h.ctx, h.keys["bob"], "browseapproval", []json.RawMessage{rawJSON(map[string]string{"id": decision.ID})})
2446+ if err != nil {
2447+ t.Fatal(err)
2448+ }
2449+ if detail.(map[string]any)["item"].(approvalItem).State != "answered" {
2450+ t.Fatal("Approvals disagrees")
2451+ }
2452+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private-chat"}, {"name", "Private"}, {"visibility", "members"}}, "")
2453+ private := h.must("alice", 5000, [][]string{{"h", "private-chat"}, {"p", h.keys["alice"]}}, "Private work")
2454+ if _, err := h.tenant.Execute(h.ctx, h.keys["bob"], "browsechatactivity", []json.RawMessage{rawJSON(map[string]string{"room": "private-chat"})}); err == nil {
2455+ t.Fatal("outsider read private activity")
2456+ }
2457+ for _, job := range read("bob", "main")["jobs"].([]jobItem) {
2458+ if job.ID == private.ID {
2459+ t.Fatal("private job leaked")
2460+ }
2461+ }
2462+ if _, err := h.publish("alice", 7000, [][]string{{"e", private.ID}, {"p", h.keys["alice"]}, {"status", "processing"}}, "private progress"); err == nil {
2463+ t.Fatal("unscoped progress accepted for a private room task")
2464+ }
2465+}
2466+func TestChatPresenceIsEphemeralAndRoomAuthorized(t *testing.T) {
2467+ h := newRoomHarness(t)
2468+ h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private-chat"}, {"visibility", "members"}}, "")
2469+ for _, kind := range []int{20001, 20002} {
2470+ e := h.must("alice", kind, [][]string{{"h", "private-chat"}}, "typing")
2471+ if rows := h.query("alice", event.Filter{IDs: []string{e.ID}}); len(rows) != 0 {
2472+ t.Fatal("presence persisted")
2473+ }
2474+ if _, err := h.publish("bob", kind, [][]string{{"h", "private-chat"}}, "typing"); err == nil {
2475+ t.Fatal("outsider presence accepted")
2476+ }
2477+ }
2478+}
2479+
2480+func TestChatActivityIncludesParentOnlyRequestsInCanonicalThread(t *testing.T) {
2481+ h := newRoomHarness(t)
2482+ root := h.must("alice", 9, [][]string{{"h", "main"}}, "Root")
2483+ parent := h.must("bob", 9, [][]string{{"h", "main"}, {"e", root.ID, "", "reply"}}, "Parent")
2484+ approval := h.must("alice", 9, [][]string{{"h", "main"}, {"e", parent.ID, "", "reply"}, {"request", "approve"}, {"p", h.keys["bob"]}}, "Approve the nested action?")
2485+ job := h.must("alice", 5000, [][]string{{"h", "main"}, {"e", parent.ID}, {"p", h.keys["bob"]}}, "Nested job")
2486+ other := h.must("alice", 9, [][]string{{"h", "main"}}, "Another root")
2487+ h.must("alice", 9, [][]string{{"h", "main"}, {"e", other.ID, "", "root"}, {"request", "approve"}, {"p", h.keys["bob"]}}, "Unrelated action")
2488+ raw, err := h.tenant.Execute(h.ctx, h.keys["bob"], "browsechatactivity", []json.RawMessage{rawJSON(map[string]string{"room": "main", "root": root.ID})})
2489+ if err != nil {
2490+ t.Fatal(err)
2491+ }
2492+ data := raw.(map[string]any)
2493+ approvals, jobs := data["approvals"].([]approvalItem), data["jobs"].([]jobItem)
2494+ if len(approvals) != 1 || approvals[0].ID != approval.ID || len(jobs) != 1 || jobs[0].ID != job.ID {
2495+ t.Fatalf("activity omitted nested request or included another thread: %v", data)
2496+ }
2497+}
2498diff --git a/internal/daemon/chat_preferences.go b/internal/daemon/chat_preferences.go
2499new file mode 100644
2500index 0000000..cdf9eea
2501--- /dev/null
2502+++ b/internal/daemon/chat_preferences.go
2503@@ -0,0 +1,74 @@
2504+package daemon
2505+
2506+import (
2507+ "context"
2508+ "database/sql"
2509+ "encoding/json"
2510+ "errors"
2511+ "io"
2512+ "net/http"
2513+ "strings"
2514+)
2515+
2516+const chatSharePresenceSetting = "account.chat.share_presence."
2517+
2518+type chatPreferences struct {
2519+ SharePresence bool `json:"share_presence"`
2520+}
2521+
2522+func chatPreferenceKey(actor string) string {
2523+ return chatSharePresenceSetting + actor
2524+}
2525+
2526+func (t *Tenant) chatPreferencesHTTP(w http.ResponseWriter, r *http.Request) {
2527+ w.Header().Set("Cache-Control", "private, no-store")
2528+ r.Body = http.MaxBytesReader(w, r.Body, 4096)
2529+ actor, err := t.resolveUIActor(r)
2530+ if err != nil || actor == "" {
2531+ http.Error(w, "authentication required", http.StatusUnauthorized)
2532+ return
2533+ }
2534+ key := chatPreferenceKey(actor)
2535+ if r.Method == http.MethodGet || r.Method == http.MethodHead {
2536+ preferences := chatPreferences{}
2537+ if err := t.store.GetSetting(r.Context(), key, &preferences); err != nil && !errors.Is(err, sql.ErrNoRows) {
2538+ http.Error(w, "could not read chat preferences", http.StatusInternalServerError)
2539+ return
2540+ }
2541+ writeJSON(w, http.StatusOK, preferences)
2542+ return
2543+ }
2544+ if r.Method != http.MethodPost {
2545+ w.Header().Set("Allow", "GET, HEAD, POST")
2546+ w.WriteHeader(http.StatusMethodNotAllowed)
2547+ return
2548+ }
2549+ var preferences chatPreferences
2550+ decoder := json.NewDecoder(r.Body)
2551+ decoder.DisallowUnknownFields()
2552+ if err := decoder.Decode(&preferences); err != nil {
2553+ http.Error(w, "invalid chat preferences", http.StatusBadRequest)
2554+ return
2555+ }
2556+ var extra any
2557+ if err := decoder.Decode(&extra); err != io.EOF {
2558+ http.Error(w, "invalid chat preferences", http.StatusBadRequest)
2559+ return
2560+ }
2561+ if err := t.store.PutSetting(r.Context(), key, preferences); err != nil {
2562+ http.Error(w, "could not save chat preferences", http.StatusInternalServerError)
2563+ return
2564+ }
2565+ writeJSON(w, http.StatusOK, preferences)
2566+}
2567+
2568+func (t *Tenant) chatSharePresence(ctx context.Context, actor string) bool {
2569+ if strings.TrimSpace(actor) == "" {
2570+ return false
2571+ }
2572+ var preferences chatPreferences
2573+ if err := t.store.GetSetting(ctx, chatPreferenceKey(actor), &preferences); err != nil {
2574+ return false
2575+ }
2576+ return preferences.SharePresence
2577+}
2578diff --git a/internal/daemon/chat_preferences_test.go b/internal/daemon/chat_preferences_test.go
2579new file mode 100644
2580index 0000000..d3d4a3a
2581--- /dev/null
2582+++ b/internal/daemon/chat_preferences_test.go
2583@@ -0,0 +1,59 @@
2584+package daemon
2585+
2586+import (
2587+ "context"
2588+ "io"
2589+ "net/http"
2590+ "net/http/httptest"
2591+ "strings"
2592+ "testing"
2593+)
2594+
2595+func TestChatPreferencesWritesBindAccountAndSignedBody(t *testing.T) {
2596+ app, tenant := testTenant(t)
2597+ owner := tenant.Policy().Owner
2598+ if err := tenant.store.PutSetting(context.Background(), chatPreferenceKey(owner), chatPreferences{SharePresence: true}); err != nil {
2599+ t.Fatal(err)
2600+ }
2601+ other := strings.Repeat("0", 63) + "2"
2602+ read := httptest.NewRequest("GET", "http://relay.test/chat/preferences?actor="+owner, nil)
2603+ signRequestWithSecret(t, read, "", other)
2604+ result := httptest.NewRecorder()
2605+ app.ServeHTTP(result, read)
2606+ if result.Code != 200 || result.Body.String() != `{"share_presence":false}` {
2607+ t.Fatalf("cross-account read: %d %s", result.Code, result.Body.String())
2608+ }
2609+ for _, test := range []struct {
2610+ name, body string
2611+ signed bool
2612+ code int
2613+ }{
2614+ {"unsigned", `{"share_presence":false}`, false, 401},
2615+ {"actor injection", `{"actor":"` + owner + `","share_presence":false}`, true, 400},
2616+ {"trailing object", `{"share_presence":false}{}`, true, 400},
2617+ {"invalid type", `{"share_presence":"true"}`, true, 400},
2618+ } {
2619+ t.Run(test.name, func(t *testing.T) {
2620+ req := httptest.NewRequest("POST", "http://relay.test/chat/preferences", strings.NewReader(test.body))
2621+ if test.signed {
2622+ signRequestWithSecret(t, req, test.body, other)
2623+ }
2624+ res := httptest.NewRecorder()
2625+ app.ServeHTTP(res, req)
2626+ if res.Code != test.code {
2627+ t.Fatalf("status %d: %s", res.Code, res.Body.String())
2628+ }
2629+ })
2630+ }
2631+ altered := httptest.NewRequest("POST", "http://relay.test/chat/preferences", strings.NewReader(`{"share_presence":true}`))
2632+ signRequest(t, altered, `{"share_presence":true}`)
2633+ altered.Body = io.NopCloser(strings.NewReader(`{"share_presence":false}`))
2634+ rejected := httptest.NewRecorder()
2635+ app.ServeHTTP(rejected, altered)
2636+ if rejected.Code != http.StatusUnauthorized {
2637+ t.Fatalf("altered signed body: %d", rejected.Code)
2638+ }
2639+ if !tenant.chatSharePresence(context.Background(), owner) {
2640+ t.Fatal("another account or invalid signature changed owner preference")
2641+ }
2642+}
2643diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go
2644index d9fc298..aa7186a 100644
2645--- a/internal/daemon/client_browse.go
2646+++ b/internal/daemon/client_browse.go
2647@@ -34,7 +34,7 @@ type clientBrowseRequest struct {
2648 }
2649
2650 func clientBrowseMethod(method string) bool {
2651- return wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || socialBrowseMethod(method) || directMessagesBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent", "browseprofile"}, method)
2652+ return method == "browsechatactivity" || wikiBrowseMethod(method) || approvalBrowseMethod(method) || jobBrowseMethod(method) || socialBrowseMethod(method) || directMessagesBrowseMethod(method) || containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull", "browseagent", "browseprofile"}, method)
2653 }
2654
2655 func (t *Tenant) browseRead(ctx context.Context, actor string) error {
2656@@ -58,6 +58,9 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params
2657 if err := t.browseRead(ctx, actor); err != nil {
2658 return nil, err
2659 }
2660+ if method == "browsechatactivity" {
2661+ return t.browseChatActivity(ctx, actor, params)
2662+ }
2663 if wikiBrowseMethod(method) {
2664 return t.executeWiki(ctx, actor, method, params)
2665 }
2666diff --git a/internal/daemon/grant_admission_test.go b/internal/daemon/grant_admission_test.go
2667new file mode 100644
2668index 0000000..e7e8ed7
2669--- /dev/null
2670+++ b/internal/daemon/grant_admission_test.go
2671@@ -0,0 +1,194 @@
2672+package daemon
2673+
2674+import (
2675+ "context"
2676+ "encoding/json"
2677+ "strconv"
2678+ "strings"
2679+ "testing"
2680+ "time"
2681+
2682+ "github.com/FelineStateMachine/tinyrelay/internal/community"
2683+ "github.com/FelineStateMachine/tinyrelay/internal/event"
2684+)
2685+
2686+func grantRequestEvent(t *testing.T, secret string, grant event.Event, at int64, content, grantJSON string, tags ...[]string) event.Event {
2687+ t.Helper()
2688+ base := [][]string{{"request", "grant"}, {"grant", grantJSON}, {"E", grant.ID, "", grant.PubKey}, {"K", "30392"}, {"P", grant.PubKey}, {"e", grant.ID, "", grant.PubKey}, {"k", "30392"}, {"p", grant.PubKey}}
2689+ return signedEvent(t, secret, 1111, at, append(base, tags...), content)
2690+}
2691+
2692+func grantRequestJSON(t *testing.T, grant event.Event, changes community.AgentGrantChanges) string {
2693+ t.Helper()
2694+ raw, err := json.Marshal(community.AgentGrantRequest{Base: grant.ID, Changes: changes})
2695+ if err != nil {
2696+ t.Fatal(err)
2697+ }
2698+ return string(raw)
2699+}
2700+
2701+func setGrantTag(t *testing.T, e *event.Event, name, value string) {
2702+ t.Helper()
2703+ for _, tag := range e.Tags {
2704+ if len(tag) > 1 && tag[0] == name {
2705+ tag[1] = value
2706+ return
2707+ }
2708+ }
2709+ t.Fatalf("grant replacement has no %s tag", name)
2710+}
2711+
2712+func TestGrantRequestAdmissionRequiresAgentAndValidCurrentGrant(t *testing.T) {
2713+ _, tenant := testTenant(t)
2714+ now := time.Now().Unix()
2715+ agent, _ := event.PublicKey(testAgentSecret)
2716+ member, _ := event.PublicKey(testMemberSecret)
2717+ grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"}, []string{"k", "1111"})
2718+ if err := publishAs(t, tenant, grant); err != nil {
2719+ t.Fatal(err)
2720+ }
2721+ reqJSON := grantRequestJSON(t, grant, community.AgentGrantChanges{Kinds: []int{11}})
2722+ human := grantRequestEvent(t, testMemberSecret, grant, now, "please", reqJSON)
2723+ if err := publishAs(t, tenant, human); err == nil {
2724+ t.Fatal("human request accepted")
2725+ }
2726+
2727+ for name, mutate := range map[string]func(event.Event) event.Event{
2728+ "rationale over limit": func(e event.Event) event.Event { e.Content = strings.Repeat("x", 501); return e },
2729+ "rationale whitespace": func(e event.Event) event.Event { e.Content = " \t\n "; return e },
2730+ "duplicate operator": func(e event.Event) event.Event { e.Tags = append(e.Tags, []string{"p", grant.PubKey}); return e },
2731+ "wrong operator": func(e event.Event) event.Event { e.Tags[4][1] = member; e.Tags[7][1] = member; return e },
2732+ "wrong base": func(e event.Event) event.Event {
2733+ e.Tags[2][1] = strings.Repeat("f", 64)
2734+ e.Tags[5][1] = strings.Repeat("f", 64)
2735+ return e
2736+ },
2737+ "unknown change": func(e event.Event) event.Event {
2738+ e.Tags[1][1] = `{"base":"` + grant.ID + `","changes":{"nope":true}}`
2739+ return e
2740+ },
2741+ "noop": func(e event.Event) event.Event {
2742+ e.Tags[1][1] = grantRequestJSON(t, grant, community.AgentGrantChanges{Kinds: []int{9}})
2743+ return e
2744+ },
2745+ } {
2746+ t.Run(name, func(t *testing.T) {
2747+ e := grantRequestEvent(t, testAgentSecret, grant, now+1, "please", reqJSON)
2748+ e = mutate(e)
2749+ if err := event.Sign(&e, testAgentSecret); err != nil {
2750+ t.Fatal(err)
2751+ }
2752+ if err := publishAs(t, tenant, e); err == nil {
2753+ t.Fatalf("malformed request accepted: %v", e)
2754+ }
2755+ })
2756+ }
2757+}
2758+
2759+func TestGrantRequestAdmissionRejectsPausedAndRevokedAgents(t *testing.T) {
2760+ for _, action := range []string{"pauseagent", "revokeagent"} {
2761+ t.Run(action, func(t *testing.T) {
2762+ _, tenant := testTenant(t)
2763+ now := time.Now().Unix()
2764+ agent, _ := event.PublicKey(testAgentSecret)
2765+ grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"})
2766+ if err := publishAs(t, tenant, grant); err != nil {
2767+ t.Fatal(err)
2768+ }
2769+ args := []json.RawMessage{json.RawMessage(`"` + agent + `"`)}
2770+ if _, err := tenant.Execute(context.Background(), tenant.Policy().Owner, action, args); err != nil {
2771+ t.Fatal(err)
2772+ }
2773+ req := grantRequestEvent(t, testAgentSecret, grant, now+1, "please", grantRequestJSON(t, grant, community.AgentGrantChanges{Kinds: []int{11}}))
2774+ if err := publishAs(t, tenant, req); err == nil {
2775+ t.Fatalf("request accepted after %s", action)
2776+ }
2777+ })
2778+ }
2779+}
2780+
2781+func TestGrantRequestReplacementMustMatchPreparedReview(t *testing.T) {
2782+ mutations := map[string]func(*event.Event){
2783+ "expiry": func(e *event.Event) { setGrantTag(t, e, "expiration", "9999999999") },
2784+ "name": func(e *event.Event) { setGrantTag(t, e, "name", "changed") },
2785+ "content": func(e *event.Event) { e.Content = "changed" },
2786+ "tags": func(e *event.Event) { e.Tags = append(e.Tags, []string{"note", "changed"}) },
2787+ }
2788+ for name, mutate := range mutations {
2789+ t.Run(name, func(t *testing.T) {
2790+ _, tenant := testTenant(t)
2791+ now := time.Now().Unix()
2792+ agent, _ := event.PublicKey(testAgentSecret)
2793+ grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"})
2794+ if err := publishAs(t, tenant, grant); err != nil {
2795+ t.Fatal(err)
2796+ }
2797+ req := grantRequestEvent(t, testAgentSecret, grant, now, "please", grantRequestJSON(t, grant, community.AgentGrantChanges{Kinds: []int{11}}))
2798+ if err := publishAs(t, tenant, req); err != nil {
2799+ t.Fatal(err)
2800+ }
2801+ review, err := tenant.community.ReviewAgentGrantRequest(context.Background(), req, now)
2802+ if err != nil {
2803+ t.Fatal(err)
2804+ }
2805+ replacement := review.Unsigned
2806+ mutate(&replacement)
2807+ if err := event.Sign(&replacement, testOwnerSecret); err != nil {
2808+ t.Fatal(err)
2809+ }
2810+ if err := publishAs(t, tenant, replacement); err == nil {
2811+ t.Fatalf("mutated %s replacement accepted", name)
2812+ }
2813+ })
2814+ }
2815+}
2816+
2817+func TestGrantRequestPreparedReplacementGoesStale(t *testing.T) {
2818+ for _, scenario := range []string{"pause", "revoke", "deny", "expire"} {
2819+ t.Run(scenario, func(t *testing.T) {
2820+ _, tenant := testTenant(t)
2821+ now := time.Now().Unix()
2822+ agent, _ := event.PublicKey(testAgentSecret)
2823+ grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"})
2824+ if err := publishAs(t, tenant, grant); err != nil {
2825+ t.Fatal(err)
2826+ }
2827+ tags := grantRequestJSON(t, grant, community.AgentGrantChanges{Kinds: []int{11}})
2828+ requestTags := [][]string(nil)
2829+ if scenario == "expire" {
2830+ requestTags = [][]string{{"expiration", strconv.FormatInt(now+1, 10)}}
2831+ }
2832+ request := grantRequestEvent(t, testAgentSecret, grant, now, "please", tags, requestTags...)
2833+ if err := publishAs(t, tenant, request); err != nil {
2834+ t.Fatal(err)
2835+ }
2836+ review, err := tenant.community.ReviewAgentGrantRequest(context.Background(), request, now)
2837+ if err != nil {
2838+ t.Fatal(err)
2839+ }
2840+ replacement := review.Unsigned
2841+ if scenario == "deny" {
2842+ if err := publishAs(t, tenant, signedEvent(t, testOwnerSecret, 7, now+1, [][]string{{"e", request.ID}, {"p", agent}}, "-")); err != nil {
2843+ t.Fatal(err)
2844+ }
2845+ } else if scenario != "expire" {
2846+ action := "pauseagent"
2847+ if scenario == "revoke" {
2848+ action = "revokeagent"
2849+ }
2850+ if _, err := tenant.Execute(context.Background(), tenant.Policy().Owner, action, []json.RawMessage{json.RawMessage(`"` + agent + `"`)}); err != nil {
2851+ t.Fatal(err)
2852+ }
2853+ }
2854+ if err := event.Sign(&replacement, testOwnerSecret); err != nil {
2855+ t.Fatal(err)
2856+ }
2857+ if scenario == "expire" {
2858+ time.Sleep(2 * time.Second)
2859+ }
2860+ if err := publishAs(t, tenant, replacement); err == nil {
2861+ t.Fatalf("stale %s replacement accepted", scenario)
2862+ }
2863+ })
2864+ }
2865+}
2866diff --git a/internal/daemon/grant_approvals.go b/internal/daemon/grant_approvals.go
2867new file mode 100644
2868index 0000000..1c1f6f2
2869--- /dev/null
2870+++ b/internal/daemon/grant_approvals.go
2871@@ -0,0 +1,94 @@
2872+package daemon
2873+
2874+import (
2875+ "context"
2876+ "encoding/json"
2877+ "strings"
2878+
2879+ "github.com/FelineStateMachine/tinyrelay/internal/event"
2880+)
2881+
2882+// Keep the decision visible after the addressable grant has been superseded
2883+// or expired. Only archived grants tied to these exact requests are read.
2884+func (t *Tenant) grantApprovalHistory(ctx context.Context, items []approvalItem) ([]event.Event, error) {
2885+ ids, authors := []string{}, []string{}
2886+ for _, item := range items {
2887+ if item.Type == "grant" {
2888+ ids = append(ids, item.ID)
2889+ authors = append(authors, item.Asked...)
2890+ }
2891+ }
2892+ if len(ids) == 0 {
2893+ return nil, nil
2894+ }
2895+ idJSON, err := json.Marshal(ids)
2896+ if err != nil {
2897+ return nil, err
2898+ }
2899+ authorJSON, err := json.Marshal(uniqueStrings(authors))
2900+ if err != nil {
2901+ return nil, err
2902+ }
2903+ rows, err := t.store.DB().QueryContext(ctx, `SELECT raw FROM (
2904+ SELECT raw,created_at FROM events WHERE kind=30392 AND pubkey IN (SELECT value FROM json_each(?))
2905+ UNION ALL SELECT raw,created_at FROM agent_grant_revisions WHERE author IN (SELECT value FROM json_each(?))
2906+ ) WHERE EXISTS (SELECT 1 FROM json_each(json_extract(raw,'$.tags')) AS tag
2907+ WHERE json_extract(tag.value,'$[0]')='grant-request' AND json_extract(tag.value,'$[1]') IN (SELECT value FROM json_each(?)))
2908+ ORDER BY created_at DESC LIMIT ?`, string(authorJSON), string(authorJSON), string(idJSON), approvalScan)
2909+ if err != nil {
2910+ return nil, err
2911+ }
2912+ defer rows.Close()
2913+ var grants []event.Event
2914+ for rows.Next() {
2915+ var raw string
2916+ if err := rows.Scan(&raw); err != nil {
2917+ return nil, err
2918+ }
2919+ grant, err := event.Parse([]byte(raw))
2920+ if err != nil {
2921+ return nil, err
2922+ }
2923+ grants = append(grants, grant)
2924+ }
2925+ return grants, rows.Err()
2926+}
2927+
2928+// A grant is approved only by the operator's replacement grant. Reactions
2929+// cannot confer permissions or make an unapplied request look successful.
2930+func settleGrantApproval(item *approvalItem, rows []event.Event, now int64) {
2931+ for _, row := range rows {
2932+ if row.Kind != event.KIND_AGENT_GRANT || row.PubKey == item.Asker || !containsString(item.Asked, row.PubKey) || event.Tag(row, "grant-request") != item.ID || event.Tag(row, "grant-base") != event.Tag(item.Event, "E") || event.Tag(row, "d") != item.Asker {
2933+ continue
2934+ }
2935+ item.State = "answered"
2936+ item.Answer = &approvalAnswer{ID: row.ID, Kind: row.Kind, Author: row.PubKey, Decision: "approved", CreatedAt: row.CreatedAt}
2937+ return
2938+ }
2939+ for _, row := range rows {
2940+ if row.Kind != kindReaction || strings.TrimSpace(row.Content) != "-" || !containsString(item.Asked, row.PubKey) {
2941+ continue
2942+ }
2943+ item.State = "answered"
2944+ item.Answer = &approvalAnswer{ID: row.ID, Kind: row.Kind, Author: row.PubKey, Decision: "denied", CreatedAt: row.CreatedAt}
2945+ return
2946+ }
2947+ if item.Expires > 0 && item.Expires <= now {
2948+ item.State = "expired"
2949+ }
2950+}
2951+
2952+func (t *Tenant) reviewGrantApproval(ctx context.Context, item *approvalItem, now int64) {
2953+ if item.Type != "grant" || item.State != "open" {
2954+ return
2955+ }
2956+ review, err := t.community.ReviewAgentGrantRequest(ctx, item.Event, now)
2957+ if err != nil {
2958+ item.GrantError = err.Error()
2959+ if strings.HasPrefix(item.GrantError, "conflict:") {
2960+ item.State = "expired"
2961+ }
2962+ return
2963+ }
2964+ item.Grant = &review
2965+}
2966diff --git a/internal/daemon/grant_approvals_test.go b/internal/daemon/grant_approvals_test.go
2967new file mode 100644
2968index 0000000..113d2ae
2969--- /dev/null
2970+++ b/internal/daemon/grant_approvals_test.go
2971@@ -0,0 +1,157 @@
2972+package daemon
2973+
2974+import (
2975+ "context"
2976+ "encoding/json"
2977+ "strings"
2978+ "testing"
2979+ "time"
2980+
2981+ "github.com/FelineStateMachine/tinyrelay/internal/community"
2982+ "github.com/FelineStateMachine/tinyrelay/internal/event"
2983+)
2984+
2985+func approvalGrantRequest(t *testing.T, grant event.Event, createdAt int64) event.Event {
2986+ t.Helper()
2987+ raw, err := json.Marshal(community.AgentGrantRequest{Base: grant.ID, Changes: community.AgentGrantChanges{Kinds: []int{30617, 30618}, Repos: []community.AgentRepo{{Owner: event.Tag(grant, "d"), Identifier: "seedmark", Level: "maintain"}}}})
2988+ if err != nil {
2989+ t.Fatal(err)
2990+ }
2991+ return signedEvent(t, testAgentSecret, 1111, createdAt, [][]string{{"request", "grant"}, {"grant", string(raw)}, {"E", grant.ID, "", grant.PubKey}, {"K", "30392"}, {"P", grant.PubKey}, {"e", grant.ID, "", grant.PubKey}, {"k", "30392"}, {"p", grant.PubKey}}, "Publish the Seedmark repository and its hosting manifest.")
2992+}
2993+
2994+func TestGrantApprovalAppliesAndRetainsDecision(t *testing.T) {
2995+ _, tenant := testTenant(t)
2996+ ctx := context.Background()
2997+ now := time.Now().Unix()
2998+ agent, _ := event.PublicKey(testAgentSecret)
2999+ grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"}, []string{"room", tenant.meta.Name}, []string{"sites", "*"})
3000+ if err := publishAs(t, tenant, grant); err != nil {
3001+ t.Fatal(err)
3002+ }
3003+ request := approvalGrantRequest(t, grant, now-20)
3004+ if err := publishAs(t, tenant, request); err != nil {
3005+ t.Fatalf("request with no comment scope: %v", err)
3006+ }
3007+ detail, err := approvalCall(t, tenant, grant.PubKey, "browseapproval", map[string]any{"id": request.ID})
3008+ if err != nil {
3009+ t.Fatal(err)
3010+ }
3011+ item := detail["item"].(map[string]any)
3012+ if item["type"] != "grant" || item["state"] != "open" || item["grant"] == nil {
3013+ t.Fatalf("missing actionable review: %v", item)
3014+ }
3015+ for _, actor := range []string{agent, grant.PubKey} {
3016+ if _, err := approvalCall(t, tenant, actor, "browseapproval", map[string]any{"id": request.ID}); err != nil {
3017+ t.Fatalf("participant cannot read review: %v", err)
3018+ }
3019+ }
3020+ if _, err := approvalCall(t, tenant, wikiKey(t, approvalOtherSecret), "browseapproval", map[string]any{"id": request.ID}); err == nil {
3021+ t.Fatal("stranger read grant request")
3022+ }
3023+ // A reaction alone must never make the UI claim access was granted.
3024+ positive := signedEvent(t, testOwnerSecret, 7, now-10, [][]string{{"e", request.ID}, {"p", agent}}, "+")
3025+ if err := publishAs(t, tenant, positive); err != nil {
3026+ t.Fatal(err)
3027+ }
3028+ detail, err = approvalCall(t, tenant, grant.PubKey, "browseapproval", map[string]any{"id": request.ID})
3029+ if err != nil || detail["item"].(map[string]any)["state"] != "open" {
3030+ t.Fatalf("reaction settled an unapplied grant: %v %v", detail, err)
3031+ }
3032+ review, err := tenant.community.ReviewAgentGrantRequest(ctx, request, now)
3033+ if err != nil {
3034+ t.Fatal(err)
3035+ }
3036+ replacement := review.Unsigned
3037+ if err := event.Sign(&replacement, testOwnerSecret); err != nil {
3038+ t.Fatal(err)
3039+ }
3040+ if err := publishAs(t, tenant, replacement); err != nil {
3041+ t.Fatalf("reviewed replacement rejected: %v", err)
3042+ }
3043+ current, found, err := tenant.community.AgentGrant(ctx, agent)
3044+ if err != nil || !found || !containsInt(current.Scope.Kinds, 30617) || len(current.Scope.Sites) != 1 {
3045+ t.Fatalf("grant not applied or existing site lost: %+v %v", current, err)
3046+ }
3047+ // A later ordinary edit must not erase the approval's receipt.
3048+ manual := agentGrantEvent(t, agent, now+1, now+3600, []string{"k", "9"})
3049+ if err := publishAs(t, tenant, manual); err != nil {
3050+ t.Fatal(err)
3051+ }
3052+ for _, method := range []string{"browseapproval", "browseapprovals"} {
3053+ result, err := approvalCall(t, tenant, grant.PubKey, method, map[string]any{"id": request.ID})
3054+ if err != nil {
3055+ t.Fatal(err)
3056+ }
3057+ got := result["item"]
3058+ if method == "browseapprovals" {
3059+ got = approvalByID(wikiItems(result, "items"), request.ID)
3060+ }
3061+ item := got.(map[string]any)
3062+ answer := item["answer"].(map[string]any)
3063+ if item["state"] != "answered" || answer["decision"] != "approved" || answer["id"] != replacement.ID {
3064+ t.Fatalf("lost grant approval history: %v", item)
3065+ }
3066+ }
3067+}
3068+
3069+func TestGrantApprovalDenialAndStaleReview(t *testing.T) {
3070+ for _, scenario := range []string{"deny", "supersede"} {
3071+ t.Run(scenario, func(t *testing.T) {
3072+ _, tenant := testTenant(t)
3073+ now := time.Now().Unix()
3074+ agent, _ := event.PublicKey(testAgentSecret)
3075+ grant := agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"})
3076+ if err := publishAs(t, tenant, grant); err != nil {
3077+ t.Fatal(err)
3078+ }
3079+ request := approvalGrantRequest(t, grant, now-20)
3080+ if err := publishAs(t, tenant, request); err != nil {
3081+ t.Fatal(err)
3082+ }
3083+ review, err := tenant.community.ReviewAgentGrantRequest(context.Background(), request, now)
3084+ if err != nil {
3085+ t.Fatal(err)
3086+ }
3087+ if scenario == "deny" {
3088+ if err := publishAs(t, tenant, signedEvent(t, testOwnerSecret, 7, now-10, [][]string{{"e", request.ID}, {"p", agent}}, "-")); err != nil {
3089+ t.Fatal(err)
3090+ }
3091+ } else if err := publishAs(t, tenant, agentGrantEvent(t, agent, now-5, now+3600, []string{"k", "1"})); err != nil {
3092+ t.Fatal(err)
3093+ }
3094+ replacement := review.Unsigned
3095+ if err := event.Sign(&replacement, testOwnerSecret); err != nil {
3096+ t.Fatal(err)
3097+ }
3098+ if err := publishAs(t, tenant, replacement); err == nil {
3099+ t.Fatal("obsolete request changed the grant")
3100+ }
3101+ detail, err := approvalCall(t, tenant, grant.PubKey, "browseapproval", map[string]any{"id": request.ID})
3102+ if err != nil {
3103+ t.Fatal(err)
3104+ }
3105+ item := detail["item"].(map[string]any)
3106+ if item["grant"] != nil {
3107+ t.Fatalf("inactive request still actionable: %v", item)
3108+ }
3109+ if scenario == "deny" && (item["state"] != "answered" || item["answer"].(map[string]any)["decision"] != "denied") {
3110+ t.Fatalf("denial not shown: %v", item)
3111+ }
3112+ if scenario == "supersede" && (item["state"] != "expired" || !strings.HasPrefix(item["grant_error"].(string), "conflict:")) {
3113+ t.Fatalf("stale review not marked: %v", item)
3114+ }
3115+ })
3116+ }
3117+}
3118+
3119+func TestGrantRequestNotificationRequiresReview(t *testing.T) {
3120+ _, tenant := testTenant(t)
3121+ now := time.Now().Unix()
3122+ agent, _ := event.PublicKey(testAgentSecret)
3123+ request := approvalGrantRequest(t, agentGrantEvent(t, agent, now-30, now+3600, []string{"k", "9"}), now-20)
3124+ notices := tenant.pushNotices(context.Background(), request)
3125+ if len(notices) != 1 || notices[0].recipient != tenant.Policy().Owner || notices[0].category != pushApprovals || len(notices[0].actions) != 1 || notices[0].actions[0].Action != "review" || !strings.Contains(notices[0].url, "/approvals?id="+request.ID) {
3126+ t.Fatalf("grant request notification: %+v", notices)
3127+ }
3128+}
3129diff --git a/internal/daemon/http.go b/internal/daemon/http.go
3130index 386d0a4..51b24ad 100644
3131--- a/internal/daemon/http.go
3132+++ b/internal/daemon/http.go
3133@@ -72,6 +72,10 @@ func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) {
3134 t.pushHTTP(w, r)
3135 return
3136 }
3137+ if r.URL.Path == "/chat/preferences" {
3138+ t.chatPreferencesHTTP(w, r)
3139+ return
3140+ }
3141 if r.URL.Path == "/mcp" {
3142 t.mcpHTTP(w, r)
3143 return
3144diff --git a/internal/daemon/jobs_test.go b/internal/daemon/jobs_test.go
3145index acf60ef..b64b85b 100644
3146--- a/internal/daemon/jobs_test.go
3147+++ b/internal/daemon/jobs_test.go
3148@@ -11,6 +11,7 @@ import (
3149 "time"
3150
3151 "github.com/FelineStateMachine/tinyrelay/internal/event"
3152+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"
3153 )
3154
3155 // jobTenant returns a tenant with a member who requests jobs and an agent
3156@@ -169,6 +170,43 @@ func TestMCPJobToolsBuildValidateAndPublish(t *testing.T) {
3157 }
3158 }
3159
3160+func TestMCPJobBuildersKeepRoomScope(t *testing.T) {
3161+ room := "private-work"
3162+ request, err := mcpBuildJobRequest(mcp.Call{Arguments: map[string]any{
3163+ "kind": float64(5001), "room": room,
3164+ "inputs": []any{map[string]any{"data": "hello", "type": "text"}},
3165+ }})
3166+ if err != nil {
3167+ t.Fatalf("request builder: %v", err)
3168+ }
3169+ if got := event.Tag(event.Event{Tags: request.Tags}, "h"); got != room {
3170+ t.Fatalf("request room = %q, want %q", got, room)
3171+ }
3172+
3173+ feedback, err := mcpBuildJobFeedback(mcp.Call{Arguments: map[string]any{
3174+ "e": strings.Repeat("a", 64), "p": strings.Repeat("b", 64),
3175+ "room": room, "status": "processing",
3176+ }})
3177+ if err != nil {
3178+ t.Fatalf("feedback builder: %v", err)
3179+ }
3180+ if got := event.Tag(event.Event{Tags: feedback.Tags}, "h"); got != room {
3181+ t.Fatalf("feedback room = %q, want %q", got, room)
3182+ }
3183+
3184+ requestEvent := event.Event{ID: strings.Repeat("c", 64), PubKey: strings.Repeat("d", 64), Kind: 5001, Tags: request.Tags}
3185+ result, err := mcpBuildJobResult(mcp.Call{Arguments: map[string]any{
3186+ "request": map[string]any{"id": requestEvent.ID, "pubkey": requestEvent.PubKey, "kind": float64(requestEvent.Kind), "created_at": float64(1), "tags": requestEvent.Tags, "content": ""},
3187+ "content": "done",
3188+ }})
3189+ if err != nil {
3190+ t.Fatalf("result builder: %v", err)
3191+ }
3192+ if got := event.Tag(event.Event{Tags: result.Tags}, "h"); got != room {
3193+ t.Fatalf("result room = %q, want %q", got, room)
3194+ }
3195+}
3196+
3197 func TestJobAnswersWakeTheRequesterAsMentions(t *testing.T) {
3198 _, tenant, member, agent := jobTenant(t)
3199 ctx := context.Background()
3200diff --git a/internal/daemon/mcp_grant_request.go b/internal/daemon/mcp_grant_request.go
3201new file mode 100644
3202index 0000000..866e9fd
3203--- /dev/null
3204+++ b/internal/daemon/mcp_grant_request.go
3205@@ -0,0 +1,103 @@
3206+package daemon
3207+
3208+import (
3209+ "context"
3210+ "encoding/json"
3211+ "errors"
3212+ "strings"
3213+ "time"
3214+ "unicode/utf8"
3215+
3216+ "github.com/FelineStateMachine/tinyrelay/internal/community"
3217+ "github.com/FelineStateMachine/tinyrelay/internal/event"
3218+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"
3219+)
3220+
3221+const mcpGrantRequestShape = `Expected a signed kind 1111 NIP-22 event with matching E/K/P and e/k tags for the current kind 30392 grant, one p tag for its operator, request=grant and a grant JSON tag containing the current grant id and additive changes.`
3222+
3223+func mcpGrantRequestSchema() map[string]any {
3224+ text := map[string]any{"type": "string", "minLength": 1}
3225+ repo := mcp.Object(map[string]any{
3226+ "owner": mcpPubKey, "identifier": text,
3227+ "level": map[string]any{"type": "string", "enum": []string{community.AgentRepoPropose, community.AgentRepoRead, community.AgentRepoMaintain}},
3228+ }, "owner", "identifier", "level")
3229+ site := mcp.Object(map[string]any{"label": text, "ttl": map[string]any{"type": "integer", "minimum": 1, "maximum": community.AgentSiteTTLMax}, "encrypted": map[string]any{"type": "boolean"}}, "label")
3230+ changes := mcp.Object(map[string]any{
3231+ "kinds": map[string]any{"type": "array", "items": map[string]any{"type": "integer", "minimum": 0, "maximum": 65535}},
3232+ "rooms": map[string]any{"type": "array", "items": text}, "repos": map[string]any{"type": "array", "items": repo},
3233+ "sites": map[string]any{"type": "array", "items": site}, "wiki": text, "jobs": text,
3234+ "rate": map[string]any{"type": "integer", "minimum": 1, "maximum": community.AgentRateMax},
3235+ })
3236+ return mcp.Object(map[string]any{"event": mcpEvent, "reason": map[string]any{"type": "string", "minLength": 1, "maxLength": 500}, "changes": changes})
3237+}
3238+
3239+func (t *Tenant) mcpGrantRequest(ctx context.Context, call mcp.Call) (mcp.Result, error) {
3240+ if raw, present := call.Arguments["event"]; present && raw != nil {
3241+ encoded, err := json.Marshal(raw)
3242+ if err != nil {
3243+ return mcp.Result{}, err
3244+ }
3245+ e, err := event.Parse(encoded)
3246+ if err == nil && e.PubKey != call.Actor {
3247+ err = errors.New("the event pubkey must match the authenticated agent")
3248+ }
3249+ if err == nil {
3250+ _, err = t.community.ReviewAgentGrantRequest(ctx, e, time.Now().Unix())
3251+ }
3252+ if err != nil {
3253+ return mcp.Failure("The grant request is not acceptable: "+err.Error()+"\n"+mcpGrantRequestShape, map[string]any{"expected": mcpGrantRequestShape}), nil
3254+ }
3255+ return t.mcpPublish(ctx, call, e), nil
3256+ }
3257+ unsigned, err := t.mcpBuildGrantRequest(ctx, call)
3258+ if err != nil {
3259+ return mcp.Failure(err.Error()+"\n"+mcpGrantRequestShape, map[string]any{"expected": mcpGrantRequestShape}), nil
3260+ }
3261+ return mcp.Value(map[string]any{"unsigned": unsigned, "next": mcpSignNext}), nil
3262+}
3263+
3264+func (t *Tenant) mcpBuildGrantRequest(ctx context.Context, call mcp.Call) (mcpUnsigned, error) {
3265+ reason := strings.TrimSpace(call.String("reason"))
3266+ if reason == "" || !utf8.ValidString(reason) || len([]rune(reason)) > 500 {
3267+ return mcpUnsigned{}, errors.New("reason must be 1 to 500 valid UTF-8 characters")
3268+ }
3269+ grant, ok, err := t.community.AgentGrant(ctx, call.Actor)
3270+ if err != nil {
3271+ return mcpUnsigned{}, err
3272+ }
3273+ if !ok || !grant.Active(time.Now().Unix()) {
3274+ return mcpUnsigned{}, errors.New("only an active granted agent can request more permissions")
3275+ }
3276+ changes, err := mcpGrantChanges(call.Arguments["changes"])
3277+ if err != nil {
3278+ return mcpUnsigned{}, err
3279+ }
3280+ grantJSON, err := json.Marshal(community.AgentGrantRequest{Base: grant.EventID, Changes: changes})
3281+ if err != nil {
3282+ return mcpUnsigned{}, err
3283+ }
3284+ tags := [][]string{{"E", grant.EventID, "", grant.Owner}, {"K", "30392"}, {"P", grant.Owner}, {"e", grant.EventID, "", grant.Owner}, {"k", "30392"}, {"request", "grant"}, {"grant", string(grantJSON)}, {"p", grant.Owner}}
3285+ unsigned := mcpUnsigned{Kind: 1111, CreatedAt: time.Now().Unix(), Tags: tags, Content: reason}
3286+ request := event.Event{PubKey: call.Actor, Kind: unsigned.Kind, CreatedAt: unsigned.CreatedAt, Tags: unsigned.Tags, Content: unsigned.Content}
3287+ if _, err := t.community.ReviewAgentGrantRequest(ctx, request, time.Now().Unix()); err != nil {
3288+ return mcpUnsigned{}, err
3289+ }
3290+ return unsigned, nil
3291+}
3292+
3293+func mcpGrantChanges(raw any) (community.AgentGrantChanges, error) {
3294+ encoded, err := json.Marshal(raw)
3295+ if err != nil {
3296+ return community.AgentGrantChanges{}, errors.New("changes must be an object")
3297+ }
3298+ var changes community.AgentGrantChanges
3299+ decoder := json.NewDecoder(strings.NewReader(string(encoded)))
3300+ decoder.DisallowUnknownFields()
3301+ if err := decoder.Decode(&changes); err != nil {
3302+ return community.AgentGrantChanges{}, errors.New("changes must contain only supported grant fields")
3303+ }
3304+ if changes.Kinds == nil && changes.Rooms == nil && changes.Repos == nil && changes.Sites == nil && changes.Wiki == nil && changes.Jobs == nil && changes.Rate == nil {
3305+ return community.AgentGrantChanges{}, errors.New("changes must request at least one permission")
3306+ }
3307+ return changes, nil
3308+}
3309diff --git a/internal/daemon/mcp_grant_request_test.go b/internal/daemon/mcp_grant_request_test.go
3310new file mode 100644
3311index 0000000..c7bf8ce
3312--- /dev/null
3313+++ b/internal/daemon/mcp_grant_request_test.go
3314@@ -0,0 +1,74 @@
3315+package daemon
3316+
3317+import (
3318+ "context"
3319+ "strings"
3320+ "testing"
3321+ "time"
3322+
3323+ "github.com/FelineStateMachine/tinyrelay/internal/event"
3324+ "github.com/FelineStateMachine/tinyrelay/internal/mcp"
3325+)
3326+
3327+func TestMCPGrantRequestBuildsOperatorBoundRequest(t *testing.T) {
3328+ _, tenant := testTenant(t)
3329+ agent, _ := event.PublicKey(testAgentSecret)
3330+ now := time.Now().Unix()
3331+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "1111"})); err != nil {
3332+ t.Fatal(err)
3333+ }
3334+ changes := map[string]any{"kinds": []any{9}, "rooms": []any{"work"}}
3335+ unsigned, err := tenant.mcpBuildGrantRequest(context.Background(), mcp.Call{Actor: agent, Arguments: map[string]any{"reason": "Need chat replies", "changes": changes}})
3336+ if err != nil {
3337+ t.Fatal(err)
3338+ }
3339+ if unsigned.Kind != 1111 || unsigned.Content != "Need chat replies" {
3340+ t.Fatalf("unsigned request: %+v", unsigned)
3341+ }
3342+ if event.Tag(event.Event{Tags: unsigned.Tags}, "p") != tenant.Policy().Owner || event.Tag(event.Event{Tags: unsigned.Tags}, "request") != "grant" {
3343+ t.Fatalf("request is not bound to operator: %v", unsigned.Tags)
3344+ }
3345+ grant := event.Tag(event.Event{Tags: unsigned.Tags}, "grant")
3346+ if !strings.Contains(grant, `"base"`) || !strings.Contains(grant, `"kinds":[9]`) {
3347+ t.Fatalf("grant change payload: %s", grant)
3348+ }
3349+}
3350+
3351+func TestMCPGrantRequestRejectsUnsupportedChanges(t *testing.T) {
3352+ for _, raw := range []any{map[string]any{"unknown": "x"}, map[string]any{}} {
3353+ if _, err := mcpGrantChanges(raw); err == nil {
3354+ t.Errorf("accepted invalid changes: %#v", raw)
3355+ }
3356+ }
3357+}
3358+
3359+func TestMCPGrantRequestRejectsMissingGrantAndLongReason(t *testing.T) {
3360+ _, tenant := testTenant(t)
3361+ agent, _ := event.PublicKey(testAgentSecret)
3362+ call := mcp.Call{Actor: agent, Arguments: map[string]any{"reason": "need access", "changes": map[string]any{"kinds": []any{float64(9)}}}}
3363+ if _, err := tenant.mcpBuildGrantRequest(context.Background(), call); err == nil {
3364+ t.Fatal("agent without a grant accepted")
3365+ }
3366+ now := time.Now().Unix()
3367+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"k", "1111"})); err != nil {
3368+ t.Fatal(err)
3369+ }
3370+ call.Arguments["reason"] = strings.Repeat("x", 501)
3371+ if _, err := tenant.mcpBuildGrantRequest(context.Background(), call); err == nil {
3372+ t.Fatal("501-character reason accepted")
3373+ }
3374+}
3375+
3376+func TestMCPGrantRequestSchema(t *testing.T) {
3377+ schema := mcpGrantRequestSchema()
3378+ if err := mcp.Validate(schema, map[string]any{"event": map[string]any{}}); err != nil {
3379+ t.Fatalf("signed follow-up should be schema-valid: %v", err)
3380+ }
3381+ valid := map[string]any{"reason": "Need room chat", "changes": map[string]any{"kinds": []any{float64(9)}}}
3382+ if err := mcp.Validate(schema, valid); err != nil {
3383+ t.Fatal(err)
3384+ }
3385+ if err := mcp.Validate(schema, map[string]any{"reason": "", "changes": map[string]any{"kinds": []any{float64(9)}}}); err == nil {
3386+ t.Fatal("empty reason accepted")
3387+ }
3388+}
3389diff --git a/internal/daemon/mcp_http.go b/internal/daemon/mcp_http.go
3390index f960af1..0399882 100644
3391--- a/internal/daemon/mcp_http.go
3392+++ b/internal/daemon/mcp_http.go
3393@@ -20,7 +20,7 @@ func (t *Tenant) initMCP() error {
3394 t.mcp = &mcp.Server{
3395 Tools: tools,
3396 Info: mcp.Implementation{Name: "tinyrelay", Version: t.app.cfg.Version},
3397- Instructions: "This relay hosts Nostr events, files, Git repositories, chat rooms and a wiki, and admits agents under signed grants. Read tools return the data your key may see; management tools, including the agent controls, follow the relay's roles. Write tools accept a signed Nostr event, or build the unsigned event for you to sign when called with plain fields. upload_attachment stores a file for a room and returns a descriptor for the attachments argument of post_message, start_thread or reply_in_thread; read_attachment retrieves files your key may read. request_decision asks a person for an answer that arrives as a kind 7 reaction. request_job asks for a long task; a serving agent answers it with job_feedback and job_result, and list_jobs and read_job follow the work. add_callback registers an https URL that is woken with each new event matching a filter, so an agent that cannot hold a connection open still learns of new issues, pushes, room messages and requests.",
3398+ Instructions: "This relay hosts Nostr events, files, Git repositories, chat rooms and a wiki, and admits agents under signed grants. Read tools return the data your key may see; management tools, including the agent controls, follow the relay's roles. Write tools accept a signed Nostr event, or build the unsigned event for you to sign when called with plain fields. upload_attachment stores a file for a room and returns a descriptor for the attachments argument of post_message, start_thread or reply_in_thread; read_attachment retrieves files your key may read. request_decision asks a person for an answer that arrives as a kind 7 reaction. request_grant lets an active agent ask its current grant operator for additive access and returns a kind 1111 request; only the operator can publish the replacement kind 30392 grant. request_job asks for a long task; a serving agent answers it with job_feedback and job_result, and list_jobs and read_job follow the work. add_callback registers an https URL that is woken with each new event matching a filter, so an agent that cannot hold a connection open still learns of new issues, pushes, room messages and requests.",
3399 Authenticate: t.mcpAuthenticate,
3400 Challenge: mcpChallenge,
3401 Origin: t.mcpOrigin,
3402@@ -93,7 +93,7 @@ func (t *Tenant) llmsHTTP(w http.ResponseWriter, r *http.Request) {
3403 "- Methods: server/discover, tools/list and tools/call.",
3404 "- Read tools: list_repositories, read_repository, list_issues, read_issue, list_pull_requests, read_pull_request, list_files, read_file, read_attachment, read_status, read_management, list_rooms, read_room, read_thread, list_wiki, read_wiki_page, read_merge_request, list_agents, list_callbacks, list_custom_views, list_join_requests, list_jobs and read_job.",
3405 "- Management tools: run_job, add_job, remove_job, backup_now, dump_now, set_policy, set_connections, send_test_notification, pause_agent, resume_agent, revoke_agent, pause_all_agents, resume_all_agents, approve_join, deny_join, add_callback, remove_callback, pause_callback, resume_callback, add_custom_view, remove_custom_view, pause_custom_view, resume_custom_view and run_custom_view. approve_join and deny_join answer access requests from people who asked to join without an invite. A callback POSTs each new matching event to an https URL, signed with X-Tiny-Signature. A custom view POSTs the fenced code blocks of matching events to an https transform and keeps the SVG or PNG it returns as signed artifacts served at /views/<name>/<hash>.svg.",
3406- "- Write tools take a signed event, or return the unsigned event to sign when called with plain fields: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, publish_site, create_room, request_decision, request_job, job_feedback and job_result. A decision request is answered by a kind 7 reaction from the person asked; a job request is answered by kind 7000 feedback and a result of the request kind plus 1000. publish_site takes [path, sha256] pairs for files already in the blob store and publishes a NIP-5A manifest; an agent needs a sites grant, and a grant with a ttl requires an expiration tag.",
3407+ "- Write tools take a signed event, or return the unsigned event to sign when called with plain fields: publish_event, create_issue, create_pull_request, comment, set_status, post_message, start_thread, reply_in_thread, react, publish_wiki_page, propose_wiki_merge, publish_site, create_room, request_decision, request_grant, request_job, job_feedback and job_result. request_grant lets an active agent ask its current operator for additive access; the operator must publish the resulting kind 30392 grant. A decision request is answered by a kind 7 reaction from the person asked; a job request is answered by kind 7000 feedback and a result of the request kind plus 1000. publish_site takes [path, sha256] pairs for files already in the blob store and publishes a NIP-5A manifest; an agent needs a sites grant, and a grant with a ttl requires an expiration tag.",
3408 "",
3409 "## Files",
3410 "",
3411diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go
3412index 5e99f94..d4161cb 100644
3413--- a/internal/daemon/mcp_tools.go
3414+++ b/internal/daemon/mcp_tools.go
3415@@ -71,9 +71,9 @@ const (
3416 mcpMergeShape = `Expected a signed kind 818 event with tags ["a","30818:<destination>:<page name>"], ["p","<destination>"], ["e","<proposed version id>","","source"] and optional ["e","<base version id>"], with the explanation in content.`
3417 mcpRoomShape = `Expected a signed kind 9007 event with tags ["h","<new room id>"], ["name","<name>"], optional ["about","<description>"] and ["visibility","open" or "members"].`
3418 mcpRequestShape = `Expected a signed kind 9 event with ["h","<room id>"], or a signed kind 1111 event with NIP-22 tags ["E","<root id>","","<root pubkey>"], ["K","<root kind>"], ["P","<root pubkey>"], ["e","<root id>","","<root pubkey>"] and ["k","<root kind>"], carrying ["request","approve", "decide" or "question"], ["p","<asked pubkey>"] and optional ["expiration","<unix time>"] and ["subject","<subject>"], with the question in content.`
3419- mcpJobRequestShape = `Expected a signed event of kind 5000 to 5127 or 5129 to 5999 with optional tags ["i","<data>","url" or "event" or "job" or "text","<relay>","<marker>"], ["output","<mime type>"], ["param","<key>","<value>"], ["bid","<millisats>"], ["relays","wss://..."], ["p","<provider pubkey>"] and ["expiration","<unix time>"], with content empty or the encrypted inputs.`
3420- mcpJobFeedbackShape = `Expected a signed kind 7000 event with tags ["status","payment-required" or "processing" or "error" or "success" or "partial","<info>"], ["e","<request id>"], ["p","<requester pubkey>"] and optional ["amount","<millisats>","<bolt11>"], with content empty or a partial result.`
3421- mcpJobResultShape = `Expected a signed event of the request kind plus 1000 (6000 to 6999) with tags ["e","<request id>"], ["p","<requester pubkey>"], optional ["request","<request event JSON>"], the request's ["i",...] tags and optional ["amount","<millisats>","<bolt11>"], with the output in content.`
3422+ mcpJobRequestShape = `Expected a signed event of kind 5000 to 5127 or 5129 to 5999 with optional ["h","<room id>"], ["i","<data>","url" or "event" or "job" or "text","<relay>","<marker>"], ["output","<mime type>"], ["param","<key>","<value>"], ["bid","<millisats>"], ["relays","wss://..."], ["p","<provider pubkey>"] and ["expiration","<unix time>"], with content empty or the encrypted inputs.`
3423+ mcpJobFeedbackShape = `Expected a signed kind 7000 event with tags ["status","payment-required" or "processing" or "error" or "success" or "partial","<info>"], ["e","<request id>"], ["p","<requester pubkey>"], optional ["h","<room id>"] and ["amount","<millisats>","<bolt11>"], with content empty or a partial result.`
3424+ mcpJobResultShape = `Expected a signed event of the request kind plus 1000 (6000 to 6999) with tags ["e","<request id>"], ["p","<requester pubkey>"], optional ["h","<room id>"], ["request","<request event JSON>"], the request's ["i",...] tags and optional ["amount","<millisats>","<bolt11>"], with the output in content.`
3425 mcpSiteShape = `Expected a signed kind 15128 event for your own site, or a signed kind 35128 event with ["d","<site name>"] for a named site under your key, with one ["path","/<file path>","<sha256 of the file>"] tag per file, the blobs already uploaded, and an optional ["expiration","<unix time>"] tag.`
3426 mcpSignNext = "Sign this event with your Nostr key and call the tool again with the signed event as the event argument."
3427 mcpAnswerNote = " The answer arrives as a kind 7 reaction from the asked key on the published event: + approves, - declines, and any other content is the person's reply. Read the room or thread, or query kind 7 events with #e set to the event id, to collect it."
3428@@ -230,9 +230,10 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
3429 add("publish_site", "Publish a static site manifest (NIP-5A). Upload the files to the blob store first, then pass paths as [path, sha256] pairs, an optional label (your npub for your own site, the default, or a named site label under your key) and an optional expiration, to receive the unsigned kind 15128 or 35128 event, sign it, then call again with the signed event. An agent needs a sites grant that covers the label; a grant with a ttl requires the expiration.", mcp.Object(map[string]any{"event": mcpEvent, "label": map[string]any{"type": "string", "minLength": 1, "description": "Site label: your npub, or a named site label under your key. Defaults to your own site."}, "paths": map[string]any{"type": "array", "items": map[string]any{"type": "array", "items": mcpText}, "description": "One [path, sha256] pair per file, such as [\"/index.html\", \"<sha256>\"]."}, "expiration": mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildSite, mcpCheckSite, mcpSiteShape))
3430 add("create_room", "Create a chat room with a kind 9007 event. Relay members may do this. Pass room (the new id), name and optional about and visibility (open or members) to receive the unsigned event, sign it, then call again with the signed event. The signer becomes the room owner.", mcp.Object(map[string]any{"event": mcpEvent, "room": mcpRoomID, "name": mcpText, "about": mcpText, "visibility": map[string]any{"type": "string", "enum": []string{"open", "members"}}}), mcpPublishes, t.mcpWrite(mcpBuildRoom, mcpCheckRoom, mcpRoomShape))
3431 add("request_decision", "Ask a person for an approval, a decision or an answer. Pass pubkey (the person asked), request (approve, decide or question) and content, plus room for a kind 9 room message or root, root_kind and root_pubkey for a kind 1111 comment under an issue, pull request or other event, and optional expiration and subject, to receive the unsigned event carrying a request tag, sign it, then call again with the signed event."+mcpAnswerNote, mcp.Object(map[string]any{"event": mcpEvent, "pubkey": mcpPubKey, "request": map[string]any{"type": "string", "enum": mcpRequestKinds}, "content": mcpText, "room": mcpRoomID, "root": mcpHash, "root_kind": map[string]any{"type": "integer", "minimum": 0}, "root_pubkey": mcpPubKey, "expiration": mcpUnixTime, "subject": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildRequest, mcpCheckRequest, mcpRequestShape))
3432- add("request_job", "Ask for a long task with a NIP-90 job request. Pass kind (5000 to 5127 or 5129 to 5999) and inputs, plus optional output, params, bid in millisats, relays and expiration, to receive the unsigned event, sign it, then call again with the signed event. A serving agent answers with job feedback and a result naming the request; read them with read_job.", mcp.Object(map[string]any{"event": mcpEvent, "kind": mcpJobKind, "inputs": mcpJobInputs, "output": map[string]any{"type": "string", "description": "Expected output MIME type."}, "params": map[string]any{"type": "object", "description": "Job parameters as key and string value, each becoming a param tag."}, "bid": mcpMsats, "relays": map[string]any{"type": "array", "items": mcpText}, "expiration": mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildJobRequest, mcpCheckJobRequest, mcpJobRequestShape))
3433- add("job_feedback", "Report progress on a long task with a kind 7000 job feedback event. Pass e (the request id), p (the requester) and status (payment-required, processing, error, success or partial), plus optional info, amount in millisats, invoice and content, to receive the unsigned event, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "e": mcpHash, "p": mcpPubKey, "status": mcpJobStatus, "info": mcpText, "amount": mcpMsats, "invoice": mcpText, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobFeedback, mcpCheckJobFeedback, mcpJobFeedbackShape))
3434- add("job_result", "Deliver a long task's output with a NIP-90 job result. Pass request (the job request event) or kind, e and p, plus content and optional amount and invoice, to receive the unsigned event of the request kind plus 1000, sign it, then call again with the signed event.", mcp.Object(map[string]any{"event": mcpEvent, "request": map[string]any{"type": "object", "description": "The job request event being answered."}, "kind": map[string]any{"type": "integer", "minimum": event.KIND_JOB_RESULT_MIN, "maximum": event.KIND_JOB_RESULT_MAX}, "e": mcpHash, "p": mcpPubKey, "content": mcpText, "amount": mcpMsats, "invoice": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobResult, mcpCheckJobResult, mcpJobResultShape))
3435+ add("request_job", "Ask for a long task with a NIP-90 job request. Pass kind, inputs and optional room, output, params, bid in millisats, relays and expiration. A room-scoped job keeps its h tag on feedback and results; read them with read_job.", mcp.Object(map[string]any{"event": mcpEvent, "kind": mcpJobKind, "room": mcpRoomID, "inputs": mcpJobInputs, "output": map[string]any{"type": "string", "description": "Expected output MIME type."}, "params": map[string]any{"type": "object", "description": "Job parameters as key and string value, each becoming a param tag."}, "bid": mcpMsats, "relays": map[string]any{"type": "array", "items": mcpText}, "expiration": mcpUnixTime}), mcpPublishes, t.mcpWrite(mcpBuildJobRequest, mcpCheckJobRequest, mcpJobRequestShape))
3436+ add("job_feedback", "Report progress on a long task with a kind 7000 job feedback event. Pass e, p, status and optional room, info, amount in millisats, invoice and content. For a room-scoped request, pass the same room so private feedback stays private.", mcp.Object(map[string]any{"event": mcpEvent, "e": mcpHash, "p": mcpPubKey, "room": mcpRoomID, "status": mcpJobStatus, "info": mcpText, "amount": mcpMsats, "invoice": mcpText, "content": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobFeedback, mcpCheckJobFeedback, mcpJobFeedbackShape))
3437+ add("job_result", "Deliver a long task's output with a NIP-90 job result. Pass request (the job request event) or kind, e and p, plus content and optional room, amount and invoice. When request is supplied, its room and inputs are copied into the result.", mcp.Object(map[string]any{"event": mcpEvent, "request": map[string]any{"type": "object", "description": "The job request event being answered."}, "room": mcpRoomID, "kind": map[string]any{"type": "integer", "minimum": event.KIND_JOB_RESULT_MIN, "maximum": event.KIND_JOB_RESULT_MAX}, "e": mcpHash, "p": mcpPubKey, "content": mcpText, "amount": mcpMsats, "invoice": mcpText}), mcpPublishes, t.mcpWrite(mcpBuildJobResult, mcpCheckJobResult, mcpJobResultShape))
3438+ add("request_grant", "Ask your grant operator for additional agent permissions. Pass a short reason and additive changes; the relay targets your current grant operator and returns an unsigned NIP-22 request for you to sign. The request never changes your grant or grants authority; the operator must review and publish the replacement grant.", mcpGrantRequestSchema(), mcpPublishes, t.mcpGrantRequest)
3439 return registry, err
3440 }
3441
3442@@ -917,6 +918,12 @@ func mcpBuildJobRequest(call mcp.Call) (mcpUnsigned, error) {
3443 return mcpUnsigned{}, errors.New("kind is required and must be 5000 to 5127 or 5129 to 5999")
3444 }
3445 tags := [][]string{}
3446+ if room := strings.TrimSpace(call.String("room")); room != "" {
3447+ if !community.ValidRoomID(room) {
3448+ return mcpUnsigned{}, errors.New("room must be a room id")
3449+ }
3450+ tags = append(tags, []string{"h", room})
3451+ }
3452 if inputs, ok := call.Arguments["inputs"].([]any); ok {
3453 for _, raw := range inputs {
3454 input, _ := raw.(map[string]any)
3455@@ -1010,7 +1017,14 @@ func mcpBuildJobFeedback(call mcp.Call) (mcpUnsigned, error) {
3456 if info := strings.TrimSpace(call.String("info")); info != "" {
3457 statusTag = append(statusTag, info)
3458 }
3459- tags, err := mcpJobAmount(call, [][]string{statusTag, {"e", request}, {"p", requester}})
3460+ tags := [][]string{statusTag, {"e", request}, {"p", requester}}
3461+ if room := strings.TrimSpace(call.String("room")); room != "" {
3462+ if !community.ValidRoomID(room) {
3463+ return mcpUnsigned{}, errors.New("room must be a room id")
3464+ }
3465+ tags = append(tags, []string{"h", room})
3466+ }
3467+ tags, err := mcpJobAmount(call, tags)
3468 if err != nil {
3469 return mcpUnsigned{}, err
3470 }
3471@@ -1053,12 +1067,26 @@ func mcpBuildJobResult(call mcp.Call) (mcpUnsigned, error) {
3472 }
3473 tags := [][]string{}
3474 if hasRequest {
3475+ if room := event.Tag(request, "h"); room != "" {
3476+ tags = append(tags, []string{"h", room})
3477+ }
3478 canonical, err := event.Canonical(request)
3479 if err != nil {
3480 return mcpUnsigned{}, err
3481 }
3482 tags = append(tags, []string{"request", string(canonical)})
3483 }
3484+ if room := strings.TrimSpace(call.String("room")); room != "" {
3485+ if !community.ValidRoomID(room) {
3486+ return mcpUnsigned{}, errors.New("room must be a room id")
3487+ }
3488+ if hasRequest && event.Tag(request, "h") != room {
3489+ return mcpUnsigned{}, errors.New("room must match the request")
3490+ }
3491+ if !hasRequest {
3492+ tags = append(tags, []string{"h", room})
3493+ }
3494+ }
3495 tags = append(tags, []string{"e", id})
3496 if hasRequest {
3497 for _, tag := range request.Tags {
3498diff --git a/internal/daemon/room_threads.go b/internal/daemon/room_threads.go
3499new file mode 100644
3500index 0000000..d0ba38b
3501--- /dev/null
3502+++ b/internal/daemon/room_threads.go
3503@@ -0,0 +1,93 @@
3504+package daemon
3505+
3506+import (
3507+ "context"
3508+ "errors"
3509+
3510+ "github.com/FelineStateMachine/tinyrelay/internal/event"
3511+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
3512+)
3513+
3514+func (t *Tenant) roomThreadRoot(ctx context.Context, actor, room, id string) (event.Event, error) {
3515+ seen := map[string]bool{}
3516+ for len(seen) < 64 && !seen[id] {
3517+ seen[id] = true
3518+ rows, err := t.Query(ctx, []event.Filter{{IDs: []string{id}, Limit: intPtr(1)}}, browseSession(t, actor))
3519+ if err != nil {
3520+ return event.Event{}, err
3521+ }
3522+ if len(rows) != 1 || event.Tag(rows[0], "h") != room {
3523+ break
3524+ }
3525+ root := rows[0]
3526+ if root.Kind != 9 && root.Kind != 11 && root.Kind != 12 && root.Kind != 40002 {
3527+ break
3528+ }
3529+ parent := event.RoomReplyRoot(root)
3530+ if parent == "" {
3531+ return root, nil
3532+ }
3533+ id = parent
3534+ }
3535+ return event.Event{}, errors.New("not found: thread")
3536+}
3537+
3538+// Follow indexed references so parent-only Buzz replies join the same thread
3539+// as marked root/reply pairs. The traversal is bounded against hostile trees.
3540+func (t *Tenant) roomThreadReplies(ctx context.Context, actor, room, root string) ([]event.Event, error) {
3541+ known := map[string]bool{root: true}
3542+ frontier := []string{root}
3543+ var replies []event.Event
3544+ for depth := 0; len(frontier) > 0 && depth < 64; depth++ {
3545+ var nextLevel []string
3546+ for start := 0; start < len(frontier); start += 100 {
3547+ end := min(start+100, len(frontier))
3548+ filter := event.Filter{Kinds: []int{9, 12}, Tags: map[string][]string{"h": {room}, "e": frontier[start:end]}}
3549+ var cursor *storage.EventCursor
3550+ for {
3551+ rows, next, err := t.roomMessages(ctx, actor, filter, cursor, 100)
3552+ if err != nil {
3553+ return nil, err
3554+ }
3555+ for _, row := range rows {
3556+ if known[row.ID] || !known[event.RoomReplyRoot(row)] {
3557+ continue
3558+ }
3559+ known[row.ID] = true
3560+ replies = append(replies, row)
3561+ nextLevel = append(nextLevel, row.ID)
3562+ if len(replies) > 5000 {
3563+ return nil, errors.New("restricted: thread exceeds the browser limit")
3564+ }
3565+ }
3566+ if next == "" {
3567+ break
3568+ }
3569+ cursor, err = parseCollaborationCursor(next)
3570+ if err != nil {
3571+ return nil, err
3572+ }
3573+ }
3574+ }
3575+ frontier = nextLevel
3576+ }
3577+ if len(frontier) > 0 {
3578+ return nil, errors.New("restricted: thread exceeds the nesting limit")
3579+ }
3580+ sortEventsNewestFirst(replies)
3581+ return replies, nil
3582+}
3583+
3584+func roomThreadPage(rows []event.Event, cursor *storage.EventCursor, limit int) ([]event.Event, string) {
3585+ page := make([]event.Event, 0, limit)
3586+ for _, row := range rows {
3587+ if cursor != nil && (row.CreatedAt > cursor.CreatedAt || row.CreatedAt == cursor.CreatedAt && row.ID <= cursor.ID) {
3588+ continue
3589+ }
3590+ if len(page) == limit {
3591+ return page, collaborationCursor(collaborationItemFrom(page[len(page)-1]))
3592+ }
3593+ page = append(page, row)
3594+ }
3595+ return page, ""
3596+}
3597diff --git a/internal/daemon/rooms_stream.go b/internal/daemon/rooms_stream.go
3598index afe4e60..5d2531a 100644
3599--- a/internal/daemon/rooms_stream.go
3600+++ b/internal/daemon/rooms_stream.go
3601@@ -77,7 +77,14 @@ func (t *Tenant) roomStreamHTTP(w http.ResponseWriter, r *http.Request) {
3602 events := make(chan event.Event, roomStreamBuffer)
3603 overflow := make(chan struct{}, 1)
3604 stop := t.router.Listen(func(e event.Event) {
3605- if e.Kind == event.KIND_MARMOT_GROUP || event.Tag(e, "h") != id {
3606+ globalPresence := e.Kind == 20001 && event.Tag(e, "h") == "" && actor != ""
3607+ if globalPresence {
3608+ allowed, _, err := t.community.RoomWriteAllowed(r.Context(), id, e.PubKey)
3609+ if err != nil || !allowed {
3610+ return
3611+ }
3612+ }
3613+ if e.Kind == event.KIND_MARMOT_GROUP || (event.Tag(e, "h") != id && !globalPresence) {
3614 return
3615 }
3616 select {
3617diff --git a/internal/daemon/rooms_test.go b/internal/daemon/rooms_test.go
3618index 6e05eee..db36a5c 100644
3619--- a/internal/daemon/rooms_test.go
3620+++ b/internal/daemon/rooms_test.go
3621@@ -254,6 +254,72 @@ func TestRoomsLifecycleRecordsAndBrowse(t *testing.T) {
3622 }
3623 }
3624
3625+func TestBuzzKindNineRepliesUseNIP10ThreadRoots(t *testing.T) {
3626+ h := newRoomHarness(t)
3627+ root := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "Buzz root")
3628+ parent := h.must("bob", event.KIND_CHAT, [][]string{
3629+ {"h", "main"}, {"e", root.ID, "", "reply"}, {"e", root.ID, "", "root"}, {"p", h.keys["alice"]},
3630+ }, "Buzz reply")
3631+ nested := h.must("alice", event.KIND_CHAT, [][]string{
3632+ {"h", "main"}, {"e", parent.ID, "", "reply"}, {"p", h.keys["bob"]},
3633+ }, "Nested reply")
3634+ thread, err := h.browse("bob", "browsethread", map[string]any{"id": "main", "event": root.ID, "limit": 10})
3635+ if err != nil {
3636+ t.Fatal(err)
3637+ }
3638+ replies := thread["replies"].([]any)
3639+ if len(replies) != 2 {
3640+ t.Fatalf("Buzz thread replies = %d, want 2: %v", len(replies), replies)
3641+ }
3642+ seen := map[string]bool{}
3643+ for _, row := range replies {
3644+ seen[row.(map[string]any)["id"].(string)] = true
3645+ }
3646+ if !seen[parent.ID] || !seen[nested.ID] {
3647+ t.Fatalf("Buzz thread omitted replies: %v", seen)
3648+ }
3649+ room, err := h.browse("bob", "browseroom", map[string]any{"id": "main", "limit": 100})
3650+ if err != nil {
3651+ t.Fatal(err)
3652+ }
3653+ roomSeen := map[string]int{}
3654+ for _, row := range room["messages"].([]any) {
3655+ roomSeen[row.(map[string]any)["id"].(string)]++
3656+ }
3657+ for _, id := range []string{root.ID, parent.ID, nested.ID} {
3658+ if roomSeen[id] != 1 {
3659+ t.Fatalf("room timeline count for %s = %d, want 1", id, roomSeen[id])
3660+ }
3661+ }
3662+}
3663+
3664+func TestRoomThreadRejectsCrossRoomRootsAndPaginatesNestedReplies(t *testing.T) {
3665+ h := newRoomHarness(t)
3666+ root := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}}, "root")
3667+ first := h.must("bob", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", root.ID, "", "reply"}}, "first")
3668+ second := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}, {"e", first.ID, "", "reply"}}, "second")
3669+ page, err := h.browse("bob", "browsethread", map[string]any{"id": "main", "event": root.ID, "limit": 1})
3670+ if err != nil {
3671+ t.Fatal(err)
3672+ }
3673+ if len(page["replies"].([]any)) != 1 || page["next_cursor"] == "" {
3674+ t.Fatalf("first thread page = %v", page)
3675+ }
3676+ more, err := h.browse("bob", "browsethread", map[string]any{"id": "main", "event": root.ID, "limit": 1, "cursor": page["next_cursor"]})
3677+ if err != nil || len(more["replies"].([]any)) != 1 {
3678+ t.Fatalf("second thread page = %v (%v)", more, err)
3679+ }
3680+ if page["replies"].([]any)[0].(map[string]any)["id"] == more["replies"].([]any)[0].(map[string]any)["id"] {
3681+ t.Fatal("thread cursor repeated a reply")
3682+ }
3683+ other := h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "other"}, {"visibility", "open"}}, "")
3684+ _ = other
3685+ if _, err := h.browse("bob", "browsethread", map[string]any{"id": "other", "event": root.ID}); err == nil {
3686+ t.Fatal("cross-room root was accepted")
3687+ }
3688+ _ = second
3689+}
3690+
3691 func TestTypedRoomReaderPreservesSignedEvents(t *testing.T) {
3692 h := newRoomHarness(t)
3693 message := h.must("alice", event.KIND_CHAT, [][]string{{"h", "main"}, {"imeta", "url https://files.example/report.pdf", "m application/pdf", "filename report.pdf"}}, "typed room read")
3694diff --git a/internal/daemon/services.go b/internal/daemon/services.go
3695index 261bcf7..0f05137 100644
3696--- a/internal/daemon/services.go
3697+++ b/internal/daemon/services.go
3698@@ -284,6 +284,10 @@ func (b backend) ReadAllowed(ctx context.Context, actor string) error {
3699 return b.tenant.requirePrivateAccess(ctx, actor)
3700 }
3701
3702+func (b backend) SharePresence(ctx context.Context, actor string) bool {
3703+ return b.tenant.chatSharePresence(ctx, actor)
3704+}
3705+
3706 // closeServices cancels and joins the tenant's worker, scheduler and Git
3707 // loops, then returns the worker's terminal queue error, if any. Store and
3708 // relay connection cleanup belong to Tenant.Close.
3709diff --git a/internal/daemon/session_test.go b/internal/daemon/session_test.go
3710index 0b1d9d7..f357967 100644
3711--- a/internal/daemon/session_test.go
3712+++ b/internal/daemon/session_test.go
3713@@ -103,6 +103,47 @@ func TestSignedSessionSurvivesNavigationAndRevokes(t *testing.T) {
3714 }
3715 }
3716
3717+func TestChatPreferencesArePrivateAndDurablePerAccount(t *testing.T) {
3718+ a, tenant := testTenant(t)
3719+ login := httptest.NewRequest(http.MethodPost, "http://relay.test/session", strings.NewReader(""))
3720+ signRequest(t, login, "")
3721+ loginResult := httptest.NewRecorder()
3722+ a.ServeHTTP(loginResult, login)
3723+ if loginResult.Code != http.StatusOK {
3724+ t.Fatalf("login: %d %s", loginResult.Code, loginResult.Body.String())
3725+ }
3726+ cookie := loginResult.Result().Cookies()[0]
3727+ read := httptest.NewRequest(http.MethodGet, "http://relay.test/chat/preferences", nil)
3728+ read.AddCookie(cookie)
3729+ result := httptest.NewRecorder()
3730+ a.ServeHTTP(result, read)
3731+ if result.Code != http.StatusOK || result.Body.String() != `{"share_presence":false}` {
3732+ t.Fatalf("default preference: %d %s", result.Code, result.Body.String())
3733+ }
3734+ update := httptest.NewRequest(http.MethodPost, "http://relay.test/chat/preferences", strings.NewReader(`{"share_presence":true}`))
3735+ signRequest(t, update, `{"share_presence":true}`)
3736+ updated := httptest.NewRecorder()
3737+ a.ServeHTTP(updated, update)
3738+ if updated.Code != http.StatusOK || updated.Body.String() != `{"share_presence":true}` {
3739+ t.Fatalf("updated preference: %d %s", updated.Code, updated.Body.String())
3740+ }
3741+ if !tenant.chatSharePresence(context.Background(), tenant.Policy().Owner) {
3742+ t.Fatal("preference was not persisted")
3743+ }
3744+ reloaded := httptest.NewRequest(http.MethodGet, "http://relay.test/chat/preferences", nil)
3745+ reloaded.AddCookie(cookie)
3746+ reloadedResult := httptest.NewRecorder()
3747+ a.ServeHTTP(reloadedResult, reloaded)
3748+ if reloadedResult.Code != http.StatusOK || reloadedResult.Body.String() != `{"share_presence":true}` {
3749+ t.Fatalf("reloaded preference: %d %s", reloadedResult.Code, reloadedResult.Body.String())
3750+ }
3751+ guest := httptest.NewRecorder()
3752+ a.ServeHTTP(guest, httptest.NewRequest(http.MethodGet, "http://relay.test/chat/preferences", nil))
3753+ if guest.Code != http.StatusUnauthorized {
3754+ t.Fatalf("guest preference: %d", guest.Code)
3755+ }
3756+}
3757+
3758 func TestTenantMaintenanceUsesPersistedRetention(t *testing.T) {
3759 _, tenant := testTenant(t)
3760 ctx := context.Background()
3761diff --git a/internal/daemon/webui_rooms.go b/internal/daemon/webui_rooms.go
3762index 202a08c..8d4199f 100644
3763--- a/internal/daemon/webui_rooms.go
3764+++ b/internal/daemon/webui_rooms.go
3765@@ -119,7 +119,7 @@ func (t *Tenant) readThreadCore(ctx context.Context, actor, roomID, rootID, curs
3766 if err := t.browseRead(ctx, actor); err != nil {
3767 return webui.RoomPage{}, err
3768 }
3769- room, _, err := t.roomFor(ctx, actor, roomID)
3770+ room, role, err := t.roomFor(ctx, actor, roomID)
3771 if err != nil {
3772 return webui.RoomPage{}, err
3773 }
3774@@ -130,23 +130,25 @@ func (t *Tenant) readThreadCore(ctx context.Context, actor, roomID, rootID, curs
3775 if err != nil {
3776 return webui.RoomPage{}, errors.New("invalid: thread cursor")
3777 }
3778- roots, err := t.Query(ctx, []event.Filter{{IDs: []string{rootID}, Limit: intPtr(1)}}, browseSession(t, actor))
3779+ root, err := t.roomThreadRoot(ctx, actor, room.ID, rootID)
3780 if err != nil {
3781 return webui.RoomPage{}, err
3782 }
3783- if len(roots) != 1 || event.Tag(roots[0], "h") != room.ID || (roots[0].Kind != event.KIND_THREAD && roots[0].Kind != event.KIND_CHAT && roots[0].Kind != event.KIND_RICH_CONTENT) {
3784- return webui.RoomPage{}, errors.New("not found: thread")
3785- }
3786- replies, next, err := t.roomMessages(ctx, actor, event.Filter{Kinds: []int{event.KIND_THREAD_REPLY}, Tags: map[string][]string{"h": {room.ID}, "e": {rootID}}}, position, normalizeRoomLimit(limit))
3787+ all, err := t.roomThreadReplies(ctx, actor, room.ID, root.ID)
3788 if err != nil {
3789 return webui.RoomPage{}, err
3790 }
3791- targets := append([]event.Event{roots[0]}, replies...)
3792+ replies, next := roomThreadPage(all, position, normalizeRoomLimit(limit))
3793+ targets := append([]event.Event{root}, replies...)
3794 edits, err := t.roomEdits(ctx, actor, room.ID, targets)
3795 if err != nil {
3796 return webui.RoomPage{}, err
3797 }
3798- return webui.RoomPage{Room: webuiRoomSummaryFromRoom(room), Root: &roots[0], Replies: replies, Edits: edits, NextCursor: next}, nil
3799+ members, err := t.webuiRoomMembers(ctx, room.ID, role)
3800+ if err != nil {
3801+ return webui.RoomPage{}, err
3802+ }
3803+ return webui.RoomPage{Room: webuiRoomSummaryFromRoom(room), Members: members, Root: &root, Replies: replies, Edits: edits, NextCursor: next}, nil
3804 }
3805
3806 func (t *Tenant) webuiRoomMembers(ctx context.Context, roomID, role string) ([]webui.RoomMember, error) {
3807@@ -163,7 +165,17 @@ func (t *Tenant) webuiRoomMembers(ctx context.Context, roomID, role string) ([]w
3808 if err != nil {
3809 return nil, err
3810 }
3811- members = append(members, webui.RoomMember{PubKey: row.PubKey, Role: row.Role, AddedAt: row.AddedAt, Agent: tenantRole == "agent"})
3812+ member := webui.RoomMember{PubKey: row.PubKey, Role: row.Role, AddedAt: row.AddedAt, Agent: tenantRole == "agent"}
3813+ if member.Agent {
3814+ grant, found, grantErr := t.community.AgentGrant(ctx, row.PubKey)
3815+ if grantErr != nil {
3816+ return nil, grantErr
3817+ }
3818+ if found {
3819+ member.Operator = grant.Owner
3820+ }
3821+ }
3822+ members = append(members, member)
3823 }
3824 return members, nil
3825 }
3826diff --git a/internal/event/room_reply.go b/internal/event/room_reply.go
3827new file mode 100644
3828index 0000000..2224726
3829--- /dev/null
3830+++ b/internal/event/room_reply.go
3831@@ -0,0 +1,48 @@
3832+package event
3833+
3834+import "encoding/hex"
3835+
3836+// RoomReplyRoot reads NIP-10 marked references and the older unmarked
3837+// NIP-29 thread convention. A mention or q tag never makes a reply.
3838+func RoomReplyRoot(e Event) string {
3839+ if e.Kind != KIND_CHAT && e.Kind != KIND_THREAD_REPLY {
3840+ return ""
3841+ }
3842+ var root, reply, first string
3843+ marked := false
3844+ for _, tag := range e.Tags {
3845+ if len(tag) < 2 || tag[0] != "e" || len(tag[1]) != 64 {
3846+ continue
3847+ }
3848+ if _, err := hex.DecodeString(tag[1]); err != nil {
3849+ continue
3850+ }
3851+ marker := ""
3852+ if len(tag) > 3 {
3853+ marker = tag[3]
3854+ }
3855+ if marker != "" {
3856+ marked = true
3857+ }
3858+ switch marker {
3859+ case "root":
3860+ root = tag[1]
3861+ case "reply":
3862+ reply = tag[1]
3863+ case "":
3864+ if first == "" {
3865+ first = tag[1]
3866+ }
3867+ }
3868+ }
3869+ if root != "" {
3870+ return root
3871+ }
3872+ if reply != "" {
3873+ return reply
3874+ }
3875+ if !marked {
3876+ return first
3877+ }
3878+ return ""
3879+}
3880diff --git a/internal/event/room_reply_test.go b/internal/event/room_reply_test.go
3881new file mode 100644
3882index 0000000..120bec8
3883--- /dev/null
3884+++ b/internal/event/room_reply_test.go
3885@@ -0,0 +1,30 @@
3886+package event
3887+
3888+import (
3889+ "strings"
3890+ "testing"
3891+)
3892+
3893+func TestRoomReplyRoot(t *testing.T) {
3894+ a, b := strings.Repeat("a", 64), strings.Repeat("b", 64)
3895+ for _, test := range []struct {
3896+ name string
3897+ kind int
3898+ tags [][]string
3899+ want string
3900+ }{
3901+ {"root and parent", 9, [][]string{{"e", b, "", "reply", b}, {"e", a, "", "root", a}}, a},
3902+ {"parent only", 9, [][]string{{"e", b, "", "reply"}}, b},
3903+ {"mention is not reply", 9, [][]string{{"e", a, "", "mention"}}, ""},
3904+ {"quote is not reply", 9, [][]string{{"q", a}}, ""},
3905+ {"legacy", 12, [][]string{{"e", a}}, a},
3906+ {"unmarked", 9, [][]string{{"e", a}, {"e", b}}, a},
3907+ {"reaction", 7, [][]string{{"e", a}}, ""},
3908+ } {
3909+ t.Run(test.name, func(t *testing.T) {
3910+ if got := RoomReplyRoot(Event{Kind: test.kind, Tags: test.tags}); got != test.want {
3911+ t.Fatalf("got %q want %q", got, test.want)
3912+ }
3913+ })
3914+ }
3915+}
3916diff --git a/internal/gates/gates.go b/internal/gates/gates.go
3917index 416d234..62d481c 100644
3918--- a/internal/gates/gates.go
3919+++ b/internal/gates/gates.go
3920@@ -71,6 +71,9 @@ func (g *Gate) agentAdmission(ctx context.Context, e event.Event, now int64) err
3921 return fmt.Errorf("check agent grant: %w", err)
3922 }
3923 if !ok {
3924+ if community.IsAgentGrantRequest(e) {
3925+ return errors.New("restricted: grant requests require an active agent grant")
3926+ }
3927 return nil
3928 }
3929 role, err := g.cfg.Community.Role(ctx, e.PubKey)
3930@@ -78,10 +81,20 @@ func (g *Gate) agentAdmission(ctx context.Context, e event.Event, now int64) err
3931 return fmt.Errorf("check agent role: %w", err)
3932 }
3933 if role != "" && role != "agent" {
3934+ if community.IsAgentGrantRequest(e) {
3935+ return errors.New("restricted: only the granted agent may request a grant change")
3936+ }
3937 return nil
3938 }
3939- if err := grant.Check(e, now); err != nil {
3940- return err
3941+ if community.IsAgentGrantRequest(e) {
3942+ if _, reviewErr := g.cfg.Community.ReviewAgentGrantRequest(ctx, e, now); reviewErr != nil {
3943+ return reviewErr
3944+ }
3945+ }
3946+ if !community.IsAgentGrantRequest(e) {
3947+ if err := grant.Check(e, now); err != nil {
3948+ return err
3949+ }
3950 }
3951 if err := g.jobReply(ctx, e, now, true); err != nil {
3952 return err
3953@@ -107,6 +120,11 @@ func (g *Gate) agentGrantShape(ctx context.Context, e event.Event, now int64) er
3954 if !community.CanGrantAgents(role) {
3955 return errors.New("restricted: only the owner or a moderator can grant an agent")
3956 }
3957+ if event.Tag(e, "grant-request") != "" || event.Tag(e, "grant-base") != "" {
3958+ if err := g.cfg.Community.ValidateAgentGrantReplacement(ctx, e, now); err != nil {
3959+ return err
3960+ }
3961+ }
3962 _, err = community.ParseAgentGrant(e, now)
3963 return err
3964 }
3965@@ -674,6 +692,12 @@ func (g *Gate) Read(ctx context.Context, filters []event.Filter, s relay.Session
3966 // ban, repository privacy, policy, room and recipient checks.
3967 func (g *Gate) CanSee(ctx context.Context, e event.Event, s relay.Session, f *event.Filter) bool {
3968 p := g.cfg.Policy()
3969+ if community.IsAgentGrantRequest(e) {
3970+ operator := event.Tag(e, "p")
3971+ if !contains(s.PubKeys, e.PubKey) && !contains(s.PubKeys, operator) {
3972+ return false
3973+ }
3974+ }
3975 if g.cfg.Store != nil {
3976 var held int
3977 if err := g.cfg.Store.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM (SELECT id FROM hidden_events WHERE id=? UNION ALL SELECT id FROM pending_events WHERE id=?)`, e.ID, e.ID).Scan(&held); err != nil || held > 0 {
3978diff --git a/internal/gates/jobs.go b/internal/gates/jobs.go
3979index 690833a..4220519 100644
3980--- a/internal/gates/jobs.go
3981+++ b/internal/gates/jobs.go
3982@@ -122,6 +122,9 @@ func (g *Gate) jobReply(ctx context.Context, e event.Event, now int64, agent boo
3983 if event.IsJobResult(e.Kind) && e.Kind != event.JobResultKind(request.Kind) {
3984 return fmt.Errorf("invalid: job result kind %d does not answer a kind %d request", e.Kind, request.Kind)
3985 }
3986+ if room := event.Tag(request, "h"); room != "" && event.Tag(e, "h") != room {
3987+ return errors.New("invalid: job result or feedback must keep the request's room h tag")
3988+ }
3989 if event.Tag(e, "p") != request.PubKey {
3990 return errors.New("invalid: job result or feedback p tag must name the requester")
3991 }
3992diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go
3993index 36963cc..a6ff46a 100644
3994--- a/internal/mcp/tools.go
3995+++ b/internal/mcp/tools.go
3996@@ -11,6 +11,7 @@ import (
3997 "sort"
3998 "strconv"
3999 "strings"
4000+ "unicode/utf8"
4001 )
4002
4003 // Tool is one entry in a server's tool table.
4004@@ -186,10 +187,10 @@ func validate(schema map[string]any, value any, path string) error {
4005 }
4006 }
4007 if text, ok := value.(string); ok {
4008- if min, ok := number(schema["minLength"]); ok && float64(len(text)) < min {
4009+ if min, ok := number(schema["minLength"]); ok && float64(utf8.RuneCountInString(text)) < min {
4010 return fmt.Errorf("%s must not be empty", where())
4011 }
4012- if max, ok := number(schema["maxLength"]); ok && float64(len(text)) > max {
4013+ if max, ok := number(schema["maxLength"]); ok && float64(utf8.RuneCountInString(text)) > max {
4014 return fmt.Errorf("%s is longer than %d characters", where(), int(max))
4015 }
4016 }
4017diff --git a/internal/mcp/unicode_schema_test.go b/internal/mcp/unicode_schema_test.go
4018new file mode 100644
4019index 0000000..fbd80d4
4020--- /dev/null
4021+++ b/internal/mcp/unicode_schema_test.go
4022@@ -0,0 +1,16 @@
4023+package mcp
4024+
4025+import (
4026+ "strings"
4027+ "testing"
4028+)
4029+
4030+func TestSchemaStringLengthCountsUnicodeCharacters(t *testing.T) {
4031+ schema := map[string]any{"type": "string", "minLength": 1, "maxLength": 500}
4032+ if err := Validate(schema, strings.Repeat("猫", 500)); err != nil {
4033+ t.Fatal(err)
4034+ }
4035+ if err := Validate(schema, strings.Repeat("猫", 501)); err == nil {
4036+ t.Fatal("501 characters accepted")
4037+ }
4038+}
4039diff --git a/internal/storage/maintenance.go b/internal/storage/maintenance.go
4040index 53742e4..21aff58 100644
4041--- a/internal/storage/maintenance.go
4042+++ b/internal/storage/maintenance.go
4043@@ -54,6 +54,7 @@ func (s *Store) Vanish(ctx context.Context, pubkey string, until int64) error {
4044 {"DELETE FROM events WHERE kind=1059 AND id IN(SELECT event_id FROM tags WHERE name='p' AND value=?)", []any{pubkey}},
4045 {"DELETE FROM list_history WHERE owner=?", []any{pubkey}},
4046 {"DELETE FROM wiki_revisions WHERE author=?", []any{pubkey}},
4047+ {"DELETE FROM agent_grant_revisions WHERE author=?", []any{pubkey}},
4048 }
4049 for _, q := range statements {
4050 if _, err := tx.ExecContext(ctx, q.sql, q.args...); err != nil {
4051@@ -104,7 +105,10 @@ func (s *Store) EraseAuthor(ctx context.Context, pubkey string) (int64, error) {
4052 if _, err = tx.ExecContext(ctx, "DELETE FROM list_history WHERE owner=?", pubkey); err != nil {
4053 return err
4054 }
4055- _, err = tx.ExecContext(ctx, "DELETE FROM wiki_revisions WHERE author=?", pubkey)
4056+ if _, err = tx.ExecContext(ctx, "DELETE FROM wiki_revisions WHERE author=?", pubkey); err != nil {
4057+ return err
4058+ }
4059+ _, err = tx.ExecContext(ctx, "DELETE FROM agent_grant_revisions WHERE author=?", pubkey)
4060 return err
4061 })
4062 return n, err
4063diff --git a/internal/storage/save.go b/internal/storage/save.go
4064index 89cb0ea..8ceda1c 100644
4065--- a/internal/storage/save.go
4066+++ b/internal/storage/save.go
4067@@ -182,6 +182,11 @@ func replace(ctx context.Context, tx *sql.Tx, e event.Event, now int64) error {
4068 return fmt.Errorf("archive replaced wiki revision: %w", err)
4069 }
4070 }
4071+ if e.Kind == event.KIND_AGENT_GRANT {
4072+ if _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO agent_grant_revisions(event_id,author,d,created_at,raw,superseded_by) SELECT id,pubkey,d,created_at,raw,? FROM events WHERE pubkey=? AND kind=? AND d=?", e.ID, e.PubKey, e.Kind, d); err != nil {
4073+ return fmt.Errorf("archive replaced agent grant: %w", err)
4074+ }
4075+ }
4076 _, err = tx.ExecContext(ctx, "DELETE FROM events WHERE pubkey=? AND kind=? AND d=?", e.PubKey, e.Kind, d)
4077 if err != nil {
4078 return fmt.Errorf("replace event: %w", err)
4079@@ -199,6 +204,11 @@ func applyDeletion(ctx context.Context, tx *sql.Tx, e event.Event, tag []string)
4080 if err != nil {
4081 return nil
4082 }
4083+ if kind == event.KIND_AGENT_GRANT {
4084+ if err := archiveAgentGrantDeletion(ctx, tx, e, parts[1], parts[2]); err != nil {
4085+ return err
4086+ }
4087+ }
4088 if _, err := tx.ExecContext(ctx, "DELETE FROM events WHERE kind=? AND pubkey=? AND d=? AND created_at<=?", kind, e.PubKey, parts[2], e.CreatedAt); err != nil {
4089 return fmt.Errorf("delete address: %w", err)
4090 }
4091@@ -211,6 +221,9 @@ func applyDeletion(ctx context.Context, tx *sql.Tx, e event.Event, tag []string)
4092 }
4093 }
4094 } else {
4095+ if err := archiveAgentGrantEventDeletion(ctx, tx, e, tag[1]); err != nil {
4096+ return err
4097+ }
4098 if _, err := tx.ExecContext(ctx, "DELETE FROM events WHERE id=? AND (pubkey=? OR (kind=1059 AND EXISTS(SELECT 1 FROM tags WHERE event_id=events.id AND name='p' AND value=?)))", tag[1], e.PubKey, e.PubKey); err != nil {
4099 return fmt.Errorf("delete event: %w", err)
4100 }
4101@@ -228,6 +241,22 @@ func applyDeletion(ctx context.Context, tx *sql.Tx, e event.Event, tag []string)
4102 return nil
4103 }
4104
4105+func archiveAgentGrantDeletion(ctx context.Context, tx *sql.Tx, deletion event.Event, owner, d string) error {
4106+ _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO agent_grant_revisions(event_id,author,d,created_at,raw,superseded_by) SELECT id,pubkey,d,created_at,raw,? FROM events WHERE pubkey=? AND kind=? AND d=? AND created_at<=?", deletion.ID, owner, event.KIND_AGENT_GRANT, d, deletion.CreatedAt)
4107+ if err != nil {
4108+ return fmt.Errorf("archive deleted agent grant: %w", err)
4109+ }
4110+ return nil
4111+}
4112+
4113+func archiveAgentGrantEventDeletion(ctx context.Context, tx *sql.Tx, deletion event.Event, target string) error {
4114+ _, err := tx.ExecContext(ctx, "INSERT OR IGNORE INTO agent_grant_revisions(event_id,author,d,created_at,raw,superseded_by) SELECT id,pubkey,d,created_at,raw,? FROM events WHERE id=? AND kind=? AND pubkey=?", deletion.ID, target, event.KIND_AGENT_GRANT, deletion.PubKey)
4115+ if err != nil {
4116+ return fmt.Errorf("archive deleted agent grant: %w", err)
4117+ }
4118+ return nil
4119+}
4120+
4121 func insertEvent(ctx context.Context, tx *sql.Tx, e event.Event, mode string) (int64, error) {
4122 raw, err := event.Canonical(e)
4123 if err != nil {
4124diff --git a/internal/storage/schema.go b/internal/storage/schema.go
4125index 293c9c4..9e030e4 100644
4126--- a/internal/storage/schema.go
4127+++ b/internal/storage/schema.go
4128@@ -28,6 +28,7 @@ CREATE INDEX IF NOT EXISTS marmot_principal_pk ON marmot_principals(pubkey);
4129 CREATE TABLE IF NOT EXISTS list_history(owner TEXT NOT NULL,kind INTEGER NOT NULL,d TEXT NOT NULL DEFAULT '',event_id TEXT NOT NULL,created_at INTEGER NOT NULL,saved_at INTEGER NOT NULL,expires INTEGER NOT NULL DEFAULT 0,raw TEXT NOT NULL,PRIMARY KEY(owner,kind,d,event_id));
4130 CREATE INDEX IF NOT EXISTS list_history_owner ON list_history(owner,saved_at DESC);
4131 CREATE TABLE IF NOT EXISTS wiki_revisions(event_id TEXT PRIMARY KEY,author TEXT NOT NULL,d TEXT NOT NULL,created_at INTEGER NOT NULL,raw TEXT NOT NULL,superseded_by TEXT NOT NULL);
4132+CREATE TABLE IF NOT EXISTS agent_grant_revisions(event_id TEXT PRIMARY KEY,author TEXT NOT NULL,d TEXT NOT NULL,created_at INTEGER NOT NULL,raw TEXT NOT NULL,superseded_by TEXT NOT NULL);
4133 CREATE INDEX IF NOT EXISTS wiki_revisions_page ON wiki_revisions(d,created_at DESC);
4134 CREATE INDEX IF NOT EXISTS wiki_revisions_author ON wiki_revisions(author,d,created_at DESC);
4135 CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY,value TEXT NOT NULL);
4136diff --git a/internal/storage/store_test.go b/internal/storage/store_test.go
4137index 550dcec..02562c5 100644
4138--- a/internal/storage/store_test.go
4139+++ b/internal/storage/store_test.go
4140@@ -60,6 +60,31 @@ func TestDurableAcceptanceIncludesIntents(t *testing.T) {
4141 }
4142 }
4143
4144+func TestDeletionArchivesAgentGrant(t *testing.T) {
4145+ s := openTestStore(t)
4146+ ctx := context.Background()
4147+ agent := "0000000000000000000000000000000000000000000000000000000000000002"
4148+ grant := sampleEvent(t, event.KIND_AGENT_GRANT, 100, [][]string{{"d", agent}, {"p", agent}, {"expiration", "200"}})
4149+ if _, err := s.Save(ctx, grant, SaveOptions{Now: 100}); err != nil {
4150+ t.Fatal(err)
4151+ }
4152+ del := sampleEvent(t, event.KIND_DELETION, 110, [][]string{{"e", grant.ID}})
4153+ del.PubKey = grant.PubKey
4154+ if err := event.Sign(&del, "0000000000000000000000000000000000000000000000000000000000000001"); err != nil {
4155+ t.Fatal(err)
4156+ }
4157+ if _, err := s.Save(ctx, del, SaveOptions{Now: 110}); err != nil {
4158+ t.Fatal(err)
4159+ }
4160+ var raw string
4161+ if err := s.DB().QueryRowContext(ctx, "SELECT raw FROM agent_grant_revisions WHERE event_id=?", grant.ID).Scan(&raw); err != nil {
4162+ t.Fatal(err)
4163+ }
4164+ if raw == "" {
4165+ t.Fatal("deleted grant revision is empty")
4166+ }
4167+}
4168+
4169 func TestEventAndIntentRollbackTogether(t *testing.T) {
4170 s := openTestStore(t)
4171 e := sampleEvent(t, 1, 100, nil)
4172diff --git a/internal/webui/approvals.go b/internal/webui/approvals.go
4173index 18a2b7e..02baa76 100644
4174--- a/internal/webui/approvals.go
4175+++ b/internal/webui/approvals.go
4176@@ -3,6 +3,7 @@ package webui
4177 import (
4178 "context"
4179 "encoding/json"
4180+ "strconv"
4181 "strings"
4182 )
4183
4184@@ -23,8 +24,17 @@ type approvalView struct {
4185 Decision string
4186 AnsweredAt int64
4187 AnswerContent string
4188+ Grant *grantRequestView
4189 }
4190
4191+type grantRequestView struct {
4192+ Raw string
4193+ Error string
4194+ Rows []grantScopeRow
4195+}
4196+
4197+type grantScopeRow struct{ Label, Before, After string }
4198+
4199 // approvalViews returns the items in the given states, in the order the
4200 // backend listed them.
4201 func approvalViews(value any, base string, states ...string) []approvalView {
4202@@ -81,9 +91,91 @@ func approvalViewFrom(item map[string]any, base string) approvalView {
4203 if answer := valueMap(item["answer"]); len(answer) > 0 {
4204 view.Decision, view.AnsweredAt, view.AnswerContent = plainString(answer["decision"]), unixSeconds(answer["created_at"]), strings.TrimSpace(plainString(answer["content"]))
4205 }
4206+ if view.Type == "grant" {
4207+ raw := valueMap(item["grant"])
4208+ rows := []grantScopeRow{}
4209+ if len(raw) > 0 {
4210+ rows = grantScopeRows(raw)
4211+ } else {
4212+ raw = map[string]any{"request_id": view.ID, "agent": view.Asker, "operator": joinJSONValues(item["asked"])}
4213+ }
4214+ encoded, _ := json.Marshal(raw)
4215+ view.Grant = &grantRequestView{Raw: string(encoded), Error: plainString(item["grant_error"]), Rows: rows}
4216+ }
4217 return view
4218 }
4219
4220+func grantScopeRows(review map[string]any) []grantScopeRow {
4221+ before := valueMap(review["before"])
4222+ after := valueMap(review["after"])
4223+ bs, as := valueMap(before["scope"]), valueMap(after["scope"])
4224+ rows := []grantScopeRow{{"agent", shortID(plainString(before["pubkey"])), shortID(plainString(after["pubkey"]))}, {"name", plainString(before["name"]), plainString(after["name"])}, {"rooms", joinJSONValues(bs["rooms"]), joinJSONValues(as["rooms"])}, {"repos", joinGrantRepos(bs["repos"]), joinGrantRepos(as["repos"])}, {"wiki", plainString(bs["wiki"]), plainString(as["wiki"])}, {"jobs", plainString(bs["jobs"]), plainString(as["jobs"])}, {"sites", joinGrantSites(bs["sites"]), joinGrantSites(as["sites"])}, {"kinds", joinJSONValues(bs["kinds"]), joinJSONValues(as["kinds"])}, {"rate", plainString(bs["rate"]), plainString(as["rate"])}}
4225+ for i := range rows {
4226+ if rows[i].Before == "" {
4227+ rows[i].Before = "none"
4228+ }
4229+ if rows[i].After == "" {
4230+ rows[i].After = "none"
4231+ }
4232+ }
4233+ return rows
4234+}
4235+
4236+func joinJSONValues(value any) string {
4237+ // JSON numbers do not unmarshal into []string. Render both numeric kind
4238+ // lists and textual lists through their JSON representation so the review
4239+ // table never silently loses a proposed permission.
4240+ var values []any
4241+ if encoded, err := json.Marshal(value); err == nil {
4242+ _ = json.Unmarshal(encoded, &values)
4243+ }
4244+ parts := make([]string, 0, len(values))
4245+ for _, item := range values {
4246+ switch typed := item.(type) {
4247+ case string:
4248+ parts = append(parts, typed)
4249+ case float64:
4250+ parts = append(parts, strconv.FormatInt(int64(typed), 10))
4251+ default:
4252+ if encoded, err := json.Marshal(item); err == nil {
4253+ parts = append(parts, string(encoded))
4254+ }
4255+ }
4256+ }
4257+ return strings.Join(parts, ", ")
4258+}
4259+
4260+func joinGrantRepos(value any) string {
4261+ var repos []map[string]any
4262+ if encoded, err := json.Marshal(value); err == nil {
4263+ _ = json.Unmarshal(encoded, &repos)
4264+ }
4265+ parts := make([]string, 0, len(repos))
4266+ for _, repo := range repos {
4267+ parts = append(parts, plainString(repo["owner"])+":"+plainString(repo["identifier"])+":"+plainString(repo["level"]))
4268+ }
4269+ return strings.Join(parts, ", ")
4270+}
4271+
4272+func joinGrantSites(value any) string {
4273+ var sites []map[string]any
4274+ if encoded, err := json.Marshal(value); err == nil {
4275+ _ = json.Unmarshal(encoded, &sites)
4276+ }
4277+ parts := make([]string, 0, len(sites))
4278+ for _, site := range sites {
4279+ part := plainString(site["label"])
4280+ if ttl := plainString(site["ttl"]); ttl != "" && ttl != "0" {
4281+ part += " ttl=" + ttl
4282+ }
4283+ if site["encrypted"] == true {
4284+ part += " encrypted"
4285+ }
4286+ parts = append(parts, part)
4287+ }
4288+ return strings.Join(parts, ", ")
4289+}
4290+
4291 // includeApproval makes sure the request a notification opened is on the
4292 // page: when the listing does not carry it, the single request is read and
4293 // placed first. A request that cannot be read leaves the listing as it is.
4294diff --git a/internal/webui/approvals_test.go b/internal/webui/approvals_test.go
4295index 049c34f..a4fc524 100644
4296--- a/internal/webui/approvals_test.go
4297+++ b/internal/webui/approvals_test.go
4298@@ -105,6 +105,20 @@ func TestApprovalsPageRendersRequestsForTheOwner(t *testing.T) {
4299 }
4300 }
4301
4302+func TestGrantScopeRowsRenderNumericKinds(t *testing.T) {
4303+ review := map[string]any{
4304+ "before": map[string]any{"agent": strings.Repeat("a", 64), "scope": map[string]any{"kinds": []any{9.0, 30617.0}}},
4305+ "after": map[string]any{"agent": strings.Repeat("a", 64), "scope": map[string]any{"kinds": []any{9.0, 30617.0, 30618.0}}},
4306+ }
4307+ rows := grantScopeRows(review)
4308+ if got := rows[7].Before; got != "9, 30617" {
4309+ t.Fatalf("before kinds = %q", got)
4310+ }
4311+ if got := rows[7].After; got != "9, 30617, 30618" {
4312+ t.Fatalf("after kinds = %q", got)
4313+ }
4314+}
4315+
4316 func TestApprovalsPageAsksGuestsToSignIn(t *testing.T) {
4317 backend := &approvalsBackend{fakeBackend: &fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
4318 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return "", nil }})
4319diff --git a/internal/webui/browse.html b/internal/webui/browse.html
4320index 71d3c77..a531382 100644
4321--- a/internal/webui/browse.html
4322+++ b/internal/webui/browse.html
4323@@ -55,13 +55,14 @@
4324
4325 {{define "approvals"}}<section>
4326 <h1>Approvals</h1>
4327-<p>Requests that wait for your decision. Each answer is a signed reaction; a reply opens the thread.</p>
4328+<p>Requests that wait for your decision. Review requests, answer questions and approve proposed changes.</p>
4329 {{if not .Actor}}<p><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to see the requests addressed to your key.</p>
4330 {{else if .Error}}{{template "alert" .Error}}
4331 {{else}}{{$open := approvalViews .Event .URL "open"}}{{$done := approvalViews .Event .URL "answered" "expired"}}{{if $open}}{{range $item := $open}}<approval-item id="approval-{{$item.ID}}" data-type="{{$item.Type}}" data-state="{{$item.State}}">
4332 <header>{{template "nostrKey" $item.Asker}} in {{$item.Where}} | <time datetime="{{datetime $item.CreatedAt}}">{{when $item.CreatedAt}}</time>{{if $item.Expires}} | expires <time datetime="{{datetime $item.Expires}}">{{when $item.Expires}}</time>{{end}}</header>
4333 <p>{{$item.Title}}</p>{{with $item.Body}}<p>{{.}}</p>{{end}}{{with $item.About}}<pre>{{.}}</pre>{{end}}
4334-<footer>{{if ne $item.Type "question"}}<nostr-react event="{{$item.ID}}" pubkey="{{$item.Asker}}" kind="{{$item.Kind}}"><button name="reaction" value="+">{{if eq $item.Type "wiki-proposal"}}Accept{{else if eq $item.Type "wiki-merge"}}Accept{{else}}Approve{{end}}</button> <button name="reaction" value="-">{{if eq $item.Type "wiki-proposal"}}Reject{{else if eq $item.Type "wiki-merge"}}Reject{{else}}Deny{{end}}</button></nostr-react> {{end}}<details><summary>Reply</summary><nostr-compose kind="1111" root="{{$item.ID}}" root-pubkey="{{$item.Asker}}" root-kind="{{$item.Kind}}"{{with $item.Coordinate}} coordinate="{{.}}"{{end}}><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details> <a href="{{$item.Open}}">open</a></footer>
4335+{{with $item.Grant}}<agent-grant-request request="{{$item.ID}}" data-review="{{.Raw}}"{{if .Error}} data-stale="true"{{end}}><h4>Proposed grant change</h4><table><thead><tr><th>scope</th><th>current</th><th>proposed</th></tr></thead><tbody>{{range .Rows}}{{if ne .Before .After}}<tr><th>{{.Label}}</th><td>{{.Before}}</td><td>{{.After}}</td></tr>{{end}}{{end}}</tbody></table>{{with .Error}}<p role="alert">{{.}}</p>{{else}}<button type="button" data-grant-approve disabled>Approve and grant</button>{{end}}<button type="button" data-grant-deny disabled>Deny</button></agent-grant-request>{{end}}
4336+<footer>{{if and (ne $item.Type "question") (ne $item.Type "grant")}}<nostr-react event="{{$item.ID}}" pubkey="{{$item.Asker}}" kind="{{$item.Kind}}"><button name="reaction" value="+">{{if eq $item.Type "wiki-proposal"}}Accept{{else if eq $item.Type "wiki-merge"}}Accept{{else}}Approve{{end}}</button> <button name="reaction" value="-">{{if eq $item.Type "wiki-proposal"}}Reject{{else if eq $item.Type "wiki-merge"}}Reject{{else}}Deny{{end}}</button></nostr-react> {{end}}<details><summary>Reply</summary><nostr-compose kind="1111" root="{{$item.ID}}" root-pubkey="{{$item.Asker}}" root-kind="{{$item.Kind}}"{{with $item.Coordinate}} coordinate="{{.}}"{{end}}><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details> <a href="{{$item.Open}}">open</a></footer>
4337 </approval-item>
4338 {{end}}{{else}}<p>Nothing waits for your decision.</p>
4339 {{end}}{{if $done}}<h3>Answered</h3>
4340diff --git a/internal/webui/chat-activity.go b/internal/webui/chat-activity.go
4341new file mode 100644
4342index 0000000..eb752de
4343--- /dev/null
4344+++ b/internal/webui/chat-activity.go
4345@@ -0,0 +1,184 @@
4346+package webui
4347+
4348+import (
4349+ "bytes"
4350+ "context"
4351+ "encoding/json"
4352+ "net/http"
4353+ "net/url"
4354+ "sort"
4355+ "strings"
4356+ "time"
4357+)
4358+
4359+type ChatActivityReader interface {
4360+ ReadChatActivity(context.Context, string, string, string) (any, error)
4361+}
4362+
4363+type chatActivityItem struct {
4364+ Type, ID, Author, Kind, State, Status, Title, Content, Room, Decision string
4365+ CreatedAt, Expires int64
4366+ Answer string
4367+ CanDecide, Question, Encrypted bool
4368+ Result *chatActivityResult
4369+}
4370+type chatActivityResult struct{ ID, Content string }
4371+type chatActivityPage struct {
4372+ Items []chatActivityItem
4373+ Endpoint, Room, Actor, Error string
4374+}
4375+
4376+func chatActivityView(jobs, approvals any, actor, endpoint, room string) []chatActivityItem {
4377+ items := []chatActivityItem{}
4378+ for _, raw := range collaborationSlice(jobs, "items") {
4379+ item := valueMap(raw)
4380+ id := plainString(item["id"])
4381+ if !eventIDPattern.MatchString(id) {
4382+ continue
4383+ }
4384+ ev := valueMap(item["event"])
4385+ title := firstString(tagValues(ev, "subject"))
4386+ if title == "" {
4387+ title = strings.TrimSpace(plainString(item["content"]))
4388+ }
4389+ if title == "" {
4390+ for _, raw := range collaborationSlice(item, "inputs") {
4391+ input := valueMap(raw)
4392+ if plainString(input["type"]) == "text" {
4393+ title = plainString(input["data"])
4394+ break
4395+ }
4396+ }
4397+ }
4398+ if title == "" {
4399+ title = "Agent task"
4400+ }
4401+ title = truncateActivity(title, 100)
4402+ row := chatActivityItem{Type: "job", ID: id, Author: plainString(item["requester"]), Kind: plainString(item["kind"]), State: plainString(item["state"]), Status: plainString(item["status"]), Title: title, Content: plainString(item["content"]), Room: room, CreatedAt: unixSeconds(item["created_at"])}
4403+ row.Encrypted, _ = item["encrypted"].(bool)
4404+ if row.Encrypted {
4405+ row.Title = "Encrypted task"
4406+ row.Content = "Open the task in its originating client."
4407+ }
4408+ if feedback := valueMap(item["feedback"]); len(feedback) > 0 && !row.Encrypted {
4409+ row.Author = plainString(feedback["provider"])
4410+ row.Content = strings.TrimSpace(plainString(feedback["info"]) + "\n" + plainString(feedback["content"]))
4411+ }
4412+ if result := valueMap(item["result"]); eventIDPattern.MatchString(plainString(result["id"])) {
4413+ row.Result = &chatActivityResult{ID: plainString(result["id"]), Content: plainString(result["content"])}
4414+ row.Status = "success"
4415+ if row.Encrypted {
4416+ row.Result.Content = ""
4417+ }
4418+ }
4419+ if row.Status == "" {
4420+ row.Status = "queued"
4421+ }
4422+ items = append(items, row)
4423+ }
4424+ for _, raw := range collaborationSlice(approvals, "items") {
4425+ item := valueMap(raw)
4426+ id := plainString(item["id"])
4427+ if !eventIDPattern.MatchString(id) || actor == "" {
4428+ continue
4429+ }
4430+ asker := plainString(item["asker"])
4431+ can := approvalActorMayDecide(item, actor)
4432+ if !can && asker != actor {
4433+ continue
4434+ }
4435+ title := strings.TrimSpace(plainString(item["subject"]))
4436+ if title == "" {
4437+ title = plainString(item["content"])
4438+ }
4439+ state := plainString(item["state"])
4440+ expires := unixSeconds(item["expires"])
4441+ if state == "open" && expires > 0 && expires <= time.Now().Unix() {
4442+ state = "expired"
4443+ }
4444+ answer := valueMap(item["answer"])
4445+ decision := plainString(answer["decision"])
4446+ items = append(items, chatActivityItem{Type: "approval", ID: id, Author: asker, Kind: plainString(item["kind"]), State: state, Title: truncateActivity(title, 150), Content: plainString(item["content"]), Answer: plainString(answer["content"]), Room: room, CreatedAt: unixSeconds(item["created_at"]), Expires: expires, CanDecide: can && state == "open", Question: plainString(item["type"]) == "question", Decision: decision})
4447+ }
4448+ sort.SliceStable(items, func(i, j int) bool {
4449+ if items[i].CreatedAt == items[j].CreatedAt {
4450+ return items[i].ID < items[j].ID
4451+ }
4452+ return items[i].CreatedAt < items[j].CreatedAt
4453+ })
4454+ return items
4455+}
4456+func truncateActivity(value string, max int) string {
4457+ runes := []rune(value)
4458+ if len(runes) > max {
4459+ return string(runes[:max]) + "…"
4460+ }
4461+ return value
4462+}
4463+func approvalActorMayDecide(item map[string]any, actor string) bool {
4464+ if actor == "" || plainString(item["asker"]) == actor {
4465+ return false
4466+ }
4467+ for _, key := range stringValues(item["asked"]) {
4468+ if key == actor {
4469+ return true
4470+ }
4471+ }
4472+ return false
4473+}
4474+func stringValues(value any) []string {
4475+ raw, err := json.Marshal(value)
4476+ if err != nil {
4477+ return nil
4478+ }
4479+ var values []string
4480+ if json.Unmarshal(raw, &values) != nil {
4481+ return nil
4482+ }
4483+ return values
4484+}
4485+
4486+func (a *App) chatActivity(ctx context.Context, actor, room, root string) chatActivityPage {
4487+ endpoint := "/chat/activity?room=" + url.QueryEscape(room)
4488+ if root != "" {
4489+ endpoint += "&root=" + url.QueryEscape(root)
4490+ }
4491+ page := chatActivityPage{Endpoint: endpoint, Room: room, Actor: actor}
4492+ reader, ok := a.backend.(ChatActivityReader)
4493+ if !ok {
4494+ return page
4495+ }
4496+ result, err := reader.ReadChatActivity(ctx, actor, room, root)
4497+ if err != nil {
4498+ page.Error = "Agent activity is unavailable."
4499+ return page
4500+ }
4501+ data := valueMap(result)
4502+ page.Items = chatActivityView(map[string]any{"items": data["jobs"]}, map[string]any{"items": data["approvals"]}, actor, endpoint, room)
4503+ return page
4504+}
4505+func (a *App) chatActivityHTTP(w http.ResponseWriter, r *http.Request) {
4506+ w.Header().Set("Cache-Control", "private, no-store")
4507+ actor, err := a.resolveActor(r)
4508+ if err != nil {
4509+ http.Error(w, "Sign in to see chat activity.", http.StatusUnauthorized)
4510+ return
4511+ }
4512+ room, root := r.URL.Query().Get("room"), r.URL.Query().Get("root")
4513+ if !roomIDPattern.MatchString(room) || root != "" && !eventIDPattern.MatchString(root) {
4514+ http.Error(w, "Invalid room or thread.", http.StatusBadRequest)
4515+ return
4516+ }
4517+ page := a.chatActivity(r.Context(), actor, room, root)
4518+ if page.Error != "" {
4519+ http.Error(w, page.Error, http.StatusForbidden)
4520+ return
4521+ }
4522+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
4523+ var body bytes.Buffer
4524+ if err := a.tmpl.ExecuteTemplate(&body, "chatActivityResponse", page); err != nil {
4525+ http.Error(w, "Activity rendering failed.", 500)
4526+ return
4527+ }
4528+ _, _ = w.Write([]byte(injectBase(body.String(), requestPrefix(r))))
4529+}
4530diff --git a/internal/webui/chat-activity.html b/internal/webui/chat-activity.html
4531new file mode 100644
4532index 0000000..b28e482
4533--- /dev/null
4534+++ b/internal/webui/chat-activity.html
4535@@ -0,0 +1,8 @@
4536+{{define "chatActivity"}}<chat-activity id="chat-activity" endpoint="{{.Endpoint}}" room="{{.Room}}" actor="{{.Actor}}" fx-action="{{.Endpoint}}" fx-trigger="tiny:activity-refresh" fx-target="#chat-activity-cards" fx-swap="morph"><div id="chat-activity-cards">{{template "chatActivityCards" .}}</div></chat-activity>{{end}}
4537+{{define "chatActivityResponse"}}<div id="chat-activity-cards">{{template "chatActivityCards" .}}</div>{{end}}
4538+{{define "chatActivityCards"}}{{if .Error}}<p role="status">{{.Error}}</p>{{else}}{{range .Items}}<article id="activity-{{.ID}}" data-activity="{{.Type}}" data-state="{{.State}}" data-event="{{.ID}}">
4539+{{if eq .Type "approval"}}{{if ne .State "open"}}<details data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></summary>{{end}}<header><strong>{{if .Question}}Question{{else}}Approval request{{end}}</strong> <small>{{if .Decision}}{{.Decision}}{{else}}{{.State}}{{end}}</small></header><p>{{.Title}}</p>{{if ne .Title .Content}}<details><summary>Details</summary><p>{{.Content}}</p></details>{{end}}
4540+<p><small>from {{template "nostrName" .Author}}</small> <a href="/approvals?id={{.ID}}">Open request</a></p>
4541+{{with .Answer}}<blockquote>{{.}}</blockquote>{{end}}{{if .CanDecide}}<chat-decision event="{{.ID}}" pubkey="{{.Author}}" kind="{{.Kind}}" room="{{.Room}}" actor="{{$.Actor}}" expires="{{.Expires}}"{{if .Question}} question{{end}}>{{if .Question}}<label>Your answer <textarea name="content" required maxlength="8000" rows="2"></textarea></label><button type="submit">Reply</button>{{else}}<button type="submit" name="reaction" value="+">Approve</button><button type="submit" name="reaction" value="-">Deny</button>{{end}}</chat-decision><noscript><p>Answering requires JavaScript and a connected signer.</p></noscript>{{end}}{{if ne .State "open"}}</details>{{end}}
4542+{{else}}<details id="activity-details-{{.ID}}" data-task="{{.ID}}"><summary><strong>{{.Title}}</strong> <small data-status="{{.Status}}">{{.Status}}</small></summary><p><small>{{template "nostrName" .Author}}</small></p><div>{{chatMarkdown .Content}}</div>{{with .Result}}{{if .Content}}<h4>Result</h4><div>{{chatMarkdown .Content}}</div>{{end}}<p><a href="/e/{{.ID}}">Open result</a></p>{{end}}<p><a href="/e/{{.ID}}">Open task</a></p></details>{{end}}
4543+</article>{{end}}{{end}}{{end}}
4544diff --git a/internal/webui/chat-activity.js b/internal/webui/chat-activity.js
4545new file mode 100644
4546index 0000000..f44e1cb
4547--- /dev/null
4548+++ b/internal/webui/chat-activity.js
4549@@ -0,0 +1,83 @@
4550+(() => {
4551+ "use strict";
4552+ const tiny=globalThis.tiny;
4553+ class ChatActivity extends HTMLElement {
4554+ connectedCallback() {
4555+ if (this.running) return;
4556+ this.running=true; this.stopped=false; this.version="";
4557+ this.onEvent=e=>{ if (e.detail?.room===this.getAttribute("room")) this.schedule(); };
4558+ this.onVisibility=()=>{ if (!document.hidden) this.refresh(); };
4559+ this.onLogout=()=>{ this.stopped=true; this.abort?.(); this.replaceChildren(); };
4560+ this.onConfig=e=>{
4561+ const cfg=e.detail?.cfg;
4562+ if(e.target!==this || cfg?.trigger?.target!==this) return;
4563+ const force=this.forceRefresh;
4564+ cfg.credentials="same-origin"; cfg.cache="no-store"; cfg.transition=false;
4565+ cfg.headers={...cfg.headers,Accept:"text/html"};
4566+ this.abort=cfg.abort;
4567+ cfg.swap=()=>this.swap(cfg,force);
4568+ };
4569+ this.onFinally=e=>{ if(e.target===this) { this.loading=false; this.abort=null; queueMicrotask(()=>{this.resolveRefresh?.();this.resolveRefresh=null;}); } };
4570+ this.onError=e=>{ if(e.target===this && e.detail?.error?.name!=="AbortError") this.setAttribute("title","Chat activity could not be refreshed."); };
4571+ document.addEventListener("tiny:room-event",this.onEvent);
4572+ document.addEventListener("visibilitychange",this.onVisibility);
4573+ document.addEventListener("tiny:logout",this.onLogout);
4574+ this.addEventListener("fx:config",this.onConfig);
4575+ this.addEventListener("fx:finally",this.onFinally);
4576+ this.addEventListener("fx:error",this.onError);
4577+ this.timer=setInterval(()=>this.refresh(),15000);
4578+ queueMicrotask(()=>{if(this.isConnected && !this.stopped){this.dispatchEvent(new CustomEvent("fx:process",{bubbles:true}));this.refresh();}});
4579+ }
4580+ disconnectedCallback() {
4581+ this.running=false;this.stopped=true;this.abort?.();clearInterval(this.timer);clearTimeout(this.debounce);
4582+ document.removeEventListener("tiny:room-event",this.onEvent);document.removeEventListener("visibilitychange",this.onVisibility);document.removeEventListener("tiny:logout",this.onLogout);
4583+ this.removeEventListener("fx:config",this.onConfig);this.removeEventListener("fx:finally",this.onFinally);this.removeEventListener("fx:error",this.onError);
4584+ this.resolveRefresh?.();this.resolveRefresh=null;this.loading=false;
4585+ }
4586+ schedule() { if(!this.debounce)this.debounce=setTimeout(()=>{this.debounce=null;this.refresh();},400); }
4587+ async read() {
4588+ const url=new URL(tiny.localPath(this.getAttribute("endpoint")),location.href);
4589+ if(url.origin!==location.origin)throw Error("Invalid activity address.");
4590+ const response=await fetch(url,{credentials:"same-origin",cache:"no-store"});
4591+ if(!response.ok)throw Error("Chat activity could not be refreshed.");
4592+ return response.text();
4593+ }
4594+ async refresh(force=false) {
4595+ if(this.loading || this.stopped || document.hidden || (!force && this.contains(document.activeElement)) || !this.__fixi)return;
4596+ this.loading=true;this.forceRefresh=force;
4597+ return new Promise(resolve=>{this.resolveRefresh=resolve;this.dispatchEvent(new CustomEvent("tiny:activity-refresh"));});
4598+ }
4599+ swap(cfg,force=false) {
4600+ if(this.stopped || !this.isConnected || !cfg.response.ok || cfg.text===this.version || (!force && this.contains(document.activeElement)))return;
4601+ const opened=new Set([...this.querySelectorAll("details[open][data-task]")].map(el=>el.dataset.task));
4602+ morph(cfg.target,cfg.text);
4603+ cfg.target.querySelectorAll("details[data-task]").forEach(el=>{el.open=opened.has(el.dataset.task);});
4604+ cfg.target.querySelectorAll("chat-decision").forEach(el=>{if(!el.form?.isConnected){el.form=null;el.output=null;el.connectedCallback();}});
4605+ this.version=cfg.text;
4606+ }
4607+ }
4608+ class ChatDecision extends tiny.ui.FormElement {
4609+ connectedCallback() { if(this.form) return; super.connectedCallback(); }
4610+ async submit(form) {
4611+ const host=this.closest("chat-activity"),id=this.getAttribute("event"),author=this.getAttribute("pubkey"),actor=this.getAttribute("actor"),room=this.getAttribute("room");
4612+ const signer=tiny.signer();
4613+ if(!signer || await signer.getPublicKey()!==actor || tiny.signer()!==signer) throw Error("Connect the signer for this account.");
4614+ const fresh=document.createElement("template"); fresh.innerHTML=await host.read();
4615+ const allowed=[...fresh.content.querySelectorAll("chat-decision")].find(el=>el.getAttribute("event")===id && el.getAttribute("actor")===actor && el.getAttribute("pubkey")===author);
4616+ if(!allowed) { host.schedule(); throw Error("This request is no longer waiting for your answer."); }
4617+ let unsigned;
4618+ if(this.hasAttribute("question")) {
4619+ const content=(form.elements.content?.value || "").trim(); if(!content) throw Error("Write an answer first.");
4620+ unsigned={kind:1111,created_at:Math.floor(Date.now()/1000),tags:[["h",room],["E",id,"",author],["K",this.getAttribute("kind")],["P",author],["e",id,"",author],["k",this.getAttribute("kind")],["p",author]],content};
4621+ } else {
4622+ const choice=this.submitter?.value;
4623+ if(!["+","-"].includes(choice)) throw Error("Choose Approve or Deny.");
4624+ unsigned={kind:7,created_at:Math.floor(Date.now()/1000),tags:[["h",room],["e",id,"",author],["p",author],["k",this.getAttribute("kind")]],content:choice};
4625+ }
4626+ this.report("Signing…"); await tiny.signing.publish(unsigned);
4627+ this.report("Answer sent."); this.querySelectorAll("button,textarea").forEach(el=>el.disabled=true);
4628+ await host.refresh(true);
4629+ }
4630+ }
4631+ customElements.define("chat-activity",ChatActivity); customElements.define("chat-decision",ChatDecision);
4632+})();
4633diff --git a/internal/webui/chat-presence.js b/internal/webui/chat-presence.js
4634new file mode 100644
4635index 0000000..cff7ab6
4636--- /dev/null
4637+++ b/internal/webui/chat-presence.js
4638@@ -0,0 +1,159 @@
4639+(() => {
4640+ "use strict";
4641+ const tiny = globalThis.tiny;
4642+ const PRESENCE = 20001, TYPING = 20002;
4643+ const hex = value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
4644+ const tag = (event, name) => (event.tags || []).find(item => item[0] === name)?.[1] || "";
4645+ const seconds = () => Math.floor(Date.now() / 1000);
4646+ const parse = (event, room, now = seconds()) => {
4647+ if (!event || ![PRESENCE, TYPING].includes(event.kind) || !hex(event.pubkey)) return null;
4648+ const scope = tag(event, "h");
4649+ if (scope !== room && !(event.kind === PRESENCE && !scope)) return null;
4650+ const ttl = event.kind === PRESENCE ? 180 : 5;
4651+ if (!Number.isSafeInteger(event.created_at) || event.created_at > now + 5 || event.created_at + ttl <= now) return null;
4652+ let status = String(event.content || "").trim();
4653+ try { const data = JSON.parse(status); if (typeof data?.status === "string") status = data.status; if (typeof data?.typing === "boolean") status = data.typing ? "typing" : "stopped"; } catch {}
4654+ if (event.kind === TYPING) status = ["stopped", "false", "idle", "offline"].includes(status.toLowerCase()) ? "stopped" : "typing";
4655+ return {pubkey:event.pubkey, kind:event.kind, status:status.slice(0,128) || "online", at:event.created_at, expires:event.created_at+ttl};
4656+ };
4657+ const readPreference = async signal => {
4658+ const response = await fetch(tiny.localPath("/chat/preferences"), {credentials:"same-origin",cache:"no-store",signal});
4659+ if (!response.ok) throw Error("Chat preferences could not be loaded.");
4660+ return (await response.json()).share_presence === true;
4661+ };
4662+ class ChatPreferences extends HTMLElement {
4663+ connectedCallback() {
4664+ this.actor = this.getAttribute("actor"); this.input = this.querySelector("input"); this.output = this.querySelector("output");
4665+ if (!hex(this.actor) || !this.input) return;
4666+ this.controller = new AbortController(); this.input.disabled = true;
4667+ this.onChange = () => this.save();
4668+ this.input.addEventListener("change",this.onChange);
4669+ this.onLogout = () => { this.controller.abort(); this.input.disabled = true; };
4670+ document.addEventListener("tiny:logout",this.onLogout);
4671+ this.load();
4672+ }
4673+ disconnectedCallback() { this.controller?.abort(); this.input?.removeEventListener("change",this.onChange); document.removeEventListener("tiny:logout",this.onLogout); }
4674+ async load() {
4675+ try {
4676+ this.saved = await readPreference(this.controller.signal);
4677+ if (!this.isConnected || this.controller.signal.aborted) return;
4678+ this.input.checked = this.saved; this.input.disabled = false;
4679+ } catch(error) { if (error.name !== "AbortError") this.output.textContent = error.message; }
4680+ }
4681+ async save() {
4682+ if (this.saving) return;
4683+ this.saving = true; this.input.disabled = true;
4684+ try {
4685+ const signer = tiny.signer();
4686+ if (!signer || await signer.getPublicKey() !== this.actor || tiny.signer() !== signer) throw Error("Connect the signer for this account.");
4687+ if (!this.isConnected || this.controller.signal.aborted) return;
4688+ const response = await tiny.signedFetch("/chat/preferences","POST",JSON.stringify({share_presence:this.input.checked}),{contentType:"application/json",signal:this.controller.signal});
4689+ this.saved = (await response.json()).share_presence === true;
4690+ if (!this.isConnected || this.controller.signal.aborted) return;
4691+ this.input.checked = this.saved; this.output.textContent = "Saved.";
4692+ document.dispatchEvent(new CustomEvent("tiny:chat-preferences",{detail:{actor:this.actor,share_presence:this.saved}}));
4693+ } catch(error) {
4694+ this.input.checked = this.saved;
4695+ if (error.name !== "AbortError") this.output.textContent = error.message;
4696+ } finally { this.saving = false; this.input.disabled = this.controller.signal.aborted; }
4697+ }
4698+ }
4699+ class ChatPresence extends HTMLElement {
4700+ connectedCallback() {
4701+ this.room = this.getAttribute("room"); this.actor = this.getAttribute("actor");
4702+ if (!/^[a-z0-9_-]{1,64}$/.test(this.room || "")) return;
4703+ this.values = new Map(); this.entries = new Map(); this.last = {}; this.pending = new Set(); this.generation = 0;
4704+ this.status = this.querySelector("output"); this.controller = new AbortController();
4705+ this.enabled = false;
4706+ this.onEvent = event => { if (event.detail?.room === this.room) this.receive(event.detail.event); };
4707+ this.onInput = event => { if (event.target.matches('room-compose textarea')) { this.inputAt = seconds(); this.publish(TYPING, event.target.value ? "typing" : "stopped"); } };
4708+ this.onVisibility = () => { this.generation++; this.values.clear(); this.paint(); if (document.hidden) { this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true); } else { this.loadPreference(); this.tick(); } };
4709+ this.onIdentity = () => { this.enabled = false; this.generation++; this.values.clear(); this.paint(); };
4710+ this.onSigner = () => { this.onIdentity(); this.failed = false; this.loadPreference(); };
4711+ this.onPreference = event => { if (event.detail?.actor === this.actor) this.applyPreference(event.detail.share_presence === true); };
4712+ document.addEventListener("tiny:room-event", this.onEvent);
4713+ document.addEventListener("input", this.onInput);
4714+ document.addEventListener("visibilitychange", this.onVisibility);
4715+ document.addEventListener("tiny:signer", this.onSigner);
4716+ document.addEventListener("tiny:logout", this.onIdentity);
4717+ document.addEventListener("tiny:chat-preferences",this.onPreference);
4718+ this.loadPreference();
4719+ this.timer = setInterval(() => this.tick(),1000);
4720+ }
4721+ disconnectedCallback() {
4722+ this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true);
4723+ this.enabled = false; this.generation++; this.controller?.abort(); clearInterval(this.timer);
4724+ document.removeEventListener("tiny:room-event",this.onEvent); document.removeEventListener("input",this.onInput);
4725+ document.removeEventListener("visibilitychange",this.onVisibility); document.removeEventListener("tiny:signer",this.onSigner); document.removeEventListener("tiny:logout",this.onIdentity);
4726+ document.removeEventListener("tiny:chat-preferences",this.onPreference); this.values?.clear();
4727+ }
4728+ applyPreference(enabled) {
4729+ if (this.enabled === enabled) return;
4730+ this.enabled = enabled; this.generation++;
4731+ if (enabled) this.tick();
4732+ else { this.publish(TYPING,"stopped",true); this.publish(PRESENCE,"offline",true); }
4733+ }
4734+ async loadPreference() {
4735+ if (!hex(this.actor) || this.loadingPreference || this.failed || this.controller.signal.aborted) return;
4736+ this.loadingPreference = true; const generation = this.generation;
4737+ try {
4738+ const enabled = await readPreference(this.controller.signal);
4739+ if (this.isConnected && !this.controller.signal.aborted && generation === this.generation) this.applyPreference(enabled);
4740+ } catch { this.applyPreference(false); }
4741+ finally { this.loadingPreference = false; this.preferenceCheckedAt = seconds(); }
4742+ }
4743+ receive(event) {
4744+ const value = parse(event,this.room); if (!value || value.pubkey === this.actor) return;
4745+ const id = value.kind+":"+value.pubkey, previous = this.values.get(id);
4746+ if (previous && previous.at > value.at) return;
4747+ this.values.set(id,value);
4748+ while (this.values.size > 256) this.values.delete(this.values.keys().next().value);
4749+ this.paint();
4750+ }
4751+ tick() {
4752+ if (document.hidden) return;
4753+ this.paint();
4754+ if (seconds()-(this.preferenceCheckedAt || 0) >= 60) this.loadPreference();
4755+ if (this.enabled) this.publish(PRESENCE,"online");
4756+ }
4757+ async publish(kind, content, leaving = false) {
4758+ if ((!this.enabled && !leaving) || (!this.last[kind] && leaving) || (document.hidden && !leaving) || !hex(this.actor) || this.pending.has(kind)) return;
4759+ const at = seconds(), interval = kind === PRESENCE ? 60 : 3;
4760+ if (!leaving && at-(this.last[kind] || 0) < interval) return;
4761+ const signer = tiny.signer?.(); if (!signer?.signEvent) return;
4762+ const generation = this.generation;
4763+ this.pending.add(kind);
4764+ try {
4765+ if (await signer.getPublicKey() !== this.actor || tiny.signer() !== signer) return;
4766+ if (!leaving && (generation !== this.generation || !this.isConnected || !this.enabled || document.hidden)) return;
4767+ await tiny.signing.publish({kind, created_at:at, tags:[["h",this.room]], content});
4768+ this.last[kind] = at;
4769+ } catch {
4770+ this.enabled = false; this.failed = true;
4771+ if (this.status) this.status.textContent = "Presence could not be shared. Reconnect your signer to try again.";
4772+ } finally { this.pending.delete(kind); }
4773+ }
4774+ paint() {
4775+ if (!this.status) return;
4776+ const now = seconds(); for (const [id,value] of this.values) if (value.expires <= now) this.values.delete(id);
4777+ const values = [...this.values.values()].filter(v => v.status !== "offline" && v.status !== "stopped"), typers = new Set(values.filter(v => v.kind === TYPING).map(v => v.pubkey));
4778+ const visible = values.filter(v => v.kind === TYPING || !typers.has(v.pubkey)).sort((a,b) => a.pubkey.localeCompare(b.pubkey)).slice(0,8);
4779+ const active = new Set(visible.map(value => value.pubkey));
4780+ for (const [key, entry] of this.entries) if (!active.has(key)) { entry.node.remove(); this.entries.delete(key); }
4781+ visible.forEach((value,index) => {
4782+ let entry = this.entries.get(value.pubkey);
4783+ if (!entry) {
4784+ const node = document.createElement("span"), name = document.createElement("nostr-name"), status = document.createElement("span");
4785+ name.setAttribute("pubkey",value.pubkey); name.textContent = value.pubkey.slice(0,12);
4786+ node.append(name,status); entry = {node,status}; this.entries.set(value.pubkey,entry);
4787+ }
4788+ const text = " "+(value.kind === TYPING ? "is typing…" : value.status);
4789+ if (entry.status.textContent !== text) entry.status.textContent = text;
4790+ if (this.status.children[index] !== entry.node) this.status.insertBefore(entry.node,this.status.children[index] || null);
4791+ });
4792+ }
4793+ }
4794+ tiny.roomPresence = Object.freeze({parse, PRESENCE,TYPING});
4795+ customElements.define("chat-presence",ChatPresence);
4796+ customElements.define("chat-preferences",ChatPreferences);
4797+})();
4798diff --git a/internal/webui/chat_activity_http_test.go b/internal/webui/chat_activity_http_test.go
4799new file mode 100644
4800index 0000000..e8ada7e
4801--- /dev/null
4802+++ b/internal/webui/chat_activity_http_test.go
4803@@ -0,0 +1,83 @@
4804+package webui
4805+
4806+import (
4807+ "context"
4808+ "errors"
4809+ "net/http"
4810+ "net/http/httptest"
4811+ "strings"
4812+ "testing"
4813+
4814+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
4815+)
4816+
4817+type activityHTTPBackend struct {
4818+ roomsBackend
4819+ seenActor, seenRoom, seenRoot string
4820+ denied bool
4821+}
4822+
4823+func (b *activityHTTPBackend) ReadChatActivity(_ context.Context, actor, room, root string) (any, error) {
4824+ b.seenActor, b.seenRoom, b.seenRoot = actor, room, root
4825+ if b.denied {
4826+ return nil, errors.New("private details that must stay hidden")
4827+ }
4828+ return map[string]any{"approvals": []any{map[string]any{"id": roomReply, "asker": roomAgent, "asked": []string{roomOwner}, "kind": 9, "state": "open", "subject": "Approve <script>bad()</script>", "content": "Review the preview"}}}, nil
4829+}
4830+
4831+func TestChatActivityEndpointScopesAndEscapesServerRenderedCards(t *testing.T) {
4832+ b := &activityHTTPBackend{roomsBackend: roomsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(roomOwner)}}}
4833+ actor := roomOwner
4834+ app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return actor, nil }})
4835+ if err != nil {
4836+ t.Fatal(err)
4837+ }
4838+ req := httptest.NewRequest(http.MethodGet, "/chat/activity?room=general&root="+roomThread, nil)
4839+ req.RequestURI = "/r/team/chat/activity?room=general&root=" + roomThread
4840+ res := httptest.NewRecorder()
4841+ app.ServeHTTP(res, req)
4842+ body := res.Body.String()
4843+ if res.Code != 200 || res.Header().Get("Cache-Control") != "private, no-store" || b.seenActor != roomOwner || b.seenRoom != "general" || b.seenRoot != roomThread {
4844+ t.Fatalf("request context: %d %v %#v", res.Code, res.Header(), b)
4845+ }
4846+ for _, want := range []string{`<chat-decision`, `href="/r/team/approvals?id=` + roomReply, `Approve &lt;script&gt;bad()&lt;/script&gt;`, `<noscript>`} {
4847+ if !strings.Contains(body, want) {
4848+ t.Errorf("missing %q in %s", want, body)
4849+ }
4850+ }
4851+ if strings.Contains(body, "class=") || strings.Contains(body, "<script>bad()") {
4852+ t.Fatal("unsafe markup")
4853+ }
4854+ actor = roomGuest
4855+ res = httptest.NewRecorder()
4856+ app.ServeHTTP(res, req)
4857+ if strings.Contains(res.Body.String(), "chat-decision") || strings.Contains(res.Body.String(), "Review the preview") {
4858+ t.Fatal("approval shown to unaddressed actor")
4859+ }
4860+ b.denied = true
4861+ res = httptest.NewRecorder()
4862+ app.ServeHTTP(res, req)
4863+ if res.Code != http.StatusForbidden || strings.Contains(res.Body.String(), "private details") {
4864+ t.Fatalf("private error leaked: %d %s", res.Code, res.Body.String())
4865+ }
4866+}
4867+
4868+func TestChatActivityEndpointRejectsInvalidRoutesAndWrites(t *testing.T) {
4869+ app, _ := roomsApp(t, roomOwner)
4870+ for _, test := range []struct {
4871+ method, path string
4872+ status int
4873+ }{
4874+ {http.MethodPost, "/chat/activity?room=general", 405},
4875+ {http.MethodGet, "/chat/activity?room=../private", 400},
4876+ {http.MethodGet, "/chat/activity?room=general&root=bad", 400},
4877+ } {
4878+ t.Run(test.method+test.path, func(t *testing.T) {
4879+ res := httptest.NewRecorder()
4880+ app.ServeHTTP(res, httptest.NewRequest(test.method, test.path, nil))
4881+ if res.Code != test.status {
4882+ t.Fatalf("status %d", res.Code)
4883+ }
4884+ })
4885+ }
4886+}
4887diff --git a/internal/webui/chat_activity_test.go b/internal/webui/chat_activity_test.go
4888new file mode 100644
4889index 0000000..3fcdff3
4890--- /dev/null
4891+++ b/internal/webui/chat_activity_test.go
4892@@ -0,0 +1,58 @@
4893+package webui
4894+
4895+import (
4896+ "strings"
4897+ "testing"
4898+)
4899+
4900+func TestChatActivityViewScopesDecisionsAndPreservesJobSummary(t *testing.T) {
4901+ actor := strings.Repeat("a", 64)
4902+ other := strings.Repeat("b", 64)
4903+ jobs := map[string]any{"items": []any{map[string]any{"id": strings.Repeat("1", 64), "requester": other, "status": "working", "content": "Indexing files"}}}
4904+ approvals := map[string]any{"items": []any{
4905+ map[string]any{"id": strings.Repeat("2", 64), "asker": other, "asked": []any{actor}, "subject": "Publish result", "state": "open", "kind": "4550"},
4906+ map[string]any{"id": strings.Repeat("3", 64), "asker": other, "asked": []any{other}, "subject": "Private request", "state": "open", "kind": "4550"},
4907+ }}
4908+ items := chatActivityView(jobs, approvals, actor, "/chat/activity", "general")
4909+ if len(items) != 2 {
4910+ t.Fatalf("got %d activity items, want job and addressed approval", len(items))
4911+ }
4912+ if items[0].Type != "job" || items[0].Status != "working" || items[0].Title != "Indexing files" {
4913+ t.Fatalf("unexpected job card: %#v", items[0])
4914+ }
4915+ if items[1].Type != "approval" || !items[1].CanDecide || items[1].ID != strings.Repeat("2", 64) {
4916+ t.Fatalf("unexpected approval card: %#v", items[1])
4917+ }
4918+}
4919+
4920+func TestChatActivityViewDisablesAnsweredApproval(t *testing.T) {
4921+ actor := strings.Repeat("a", 64)
4922+ approvals := map[string]any{"items": []any{map[string]any{"id": strings.Repeat("2", 64), "asker": actor, "asked": []any{actor}, "content": "Done", "state": "answered"}}}
4923+ items := chatActivityView(nil, approvals, actor, "/chat/activity", "general")
4924+ if len(items) != 1 || items[0].CanDecide {
4925+ t.Fatalf("answered approval remained actionable: %#v", items)
4926+ }
4927+}
4928+
4929+func TestChatActivityAskerCannotApproveOwnRequest(t *testing.T) {
4930+ actor := strings.Repeat("a", 64)
4931+ approvals := map[string]any{"items": []any{map[string]any{"id": strings.Repeat("2", 64), "asker": actor, "asked": []string{actor, strings.Repeat("b", 64)}, "state": "open"}}}
4932+ items := chatActivityView(nil, approvals, actor, "/chat/activity", "main")
4933+ if len(items) != 1 || items[0].CanDecide {
4934+ t.Fatalf("asker allowed to decide: %#v", items)
4935+ }
4936+}
4937+
4938+func TestChatActivityPreservesAnsweredQuestion(t *testing.T) {
4939+ actor := strings.Repeat("a", 64)
4940+ other := strings.Repeat("b", 64)
4941+ approvals := map[string]any{"items": []any{map[string]any{
4942+ "id": strings.Repeat("4", 64), "asker": other, "asked": []string{actor},
4943+ "type": "question", "subject": "Which preview?", "state": "answered",
4944+ "answer": map[string]any{"decision": "replied", "content": "Use the dark preview."},
4945+ }}}
4946+ items := chatActivityView(nil, approvals, actor, "/chat/activity", "general")
4947+ if len(items) != 1 || items[0].Answer != "Use the dark preview." || items[0].CanDecide {
4948+ t.Fatalf("answered question = %#v", items)
4949+ }
4950+}
4951diff --git a/internal/webui/chat_pages.go b/internal/webui/chat_pages.go
4952index 3752224..3c74706 100644
4953--- a/internal/webui/chat_pages.go
4954+++ b/internal/webui/chat_pages.go
4955@@ -23,6 +23,10 @@ func (a *App) handleChatRoute(w http.ResponseWriter, r *http.Request) bool {
4956 http.Redirect(w, r, target, http.StatusMovedPermanently)
4957 return true
4958 }
4959+ if path == "/chat/activity" {
4960+ a.chatActivityHTTP(w, r)
4961+ return true
4962+ }
4963 if path == "/chat/events" {
4964 a.directMessagesJSON(w, r)
4965 return true
4966diff --git a/internal/webui/components.js b/internal/webui/components.js
4967index 6e69eea..f26b5cd 100644
4968--- a/internal/webui/components.js
4969+++ b/internal/webui/components.js
4970@@ -1667,7 +1667,7 @@
4971 if (root && !(url.pathname === root || url.pathname.startsWith(root + "/"))) return null;
4972 return root ? url.pathname.slice(root.length) || "/" : url.pathname;
4973 };
4974- const allowedRoute = path => /^(?:\/(?:inbox|approvals|profile|outbox|search|social|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki|rooms)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki|social)\/.+|\/chat\/dm\/[0-9a-f]{64}|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || "");
4975+ const allowedRoute = path => /^(?:\/(?:inbox|approvals|profile|account|outbox|search|social|articles|private|chat|media|sites|marmot|grasp|terms|signin|connect|tools|repo|repos|file|files|wiki|rooms)?\/?|\/manage(?:\/(?:people|agents|moderation|rules|identity|connect|data|sync|views|health|owner|status))?\/?|\/(?:invite|e|a|wiki|social)\/.+|\/chat\/dm\/[0-9a-f]{64}|\/rooms\/[a-z0-9_-]{1,64}(?:\/thread\/[0-9a-f]{64})?\/?)$/.test(path || "");
4976 let navigationSerial = 0, activeAbort;
4977 const streams = new Set();
4978 const closeStreams = () => {
4979@@ -1682,7 +1682,7 @@
4980 document.addEventListener("fx:sse:close", event => streams.delete(event.detail.cfg));
4981 window.addEventListener("pagehide", closeStreams);
4982 const repairComponents = () => {
4983- document.querySelectorAll("rpc-form,view-form,signed-form,publish-list,agent-grant,profile-form,nostr-react,social-compose,social-reaction,wiki-compose,room-compose,room-create,room-action").forEach(node => {
4984+ document.querySelectorAll("rpc-form,view-form,signed-form,publish-list,agent-grant,profile-form,nostr-react,social-compose,social-reaction,wiki-compose,room-compose,room-create,room-action,chat-decision").forEach(node => {
4985 if (node.form?.isConnected) return;
4986 node.form = null;
4987 node.output = null;
4988diff --git a/internal/webui/grant-request.js b/internal/webui/grant-request.js
4989new file mode 100644
4990index 0000000..fa90671
4991--- /dev/null
4992+++ b/internal/webui/grant-request.js
4993@@ -0,0 +1,64 @@
4994+(() => {
4995+ "use strict";
4996+ const tiny = globalThis.tiny;
4997+ const stable = value => JSON.stringify(value, function(key,item) { return item && !Array.isArray(item) && typeof item === "object" ? Object.fromEntries(Object.keys(item).sort().map(name=>[name,item[name]])) : item; });
4998+ class GrantRequest extends HTMLElement {
4999+ connectedCallback() {
5000+ if (!this.onClick) {
5001+ this.onClick = event => {
5002+ const button = event.target.closest("[data-grant-approve], [data-grant-deny]");
5003+ if (button && this.contains(button)) this.decide(button, button.hasAttribute("data-grant-approve"));
5004+ };
5005+ this.addEventListener("click", this.onClick);
5006+ }
5007+ this.setBusy(false);
5008+ }
5009+ disconnectedCallback() { this.controller?.abort(); }
5010+ setBusy(busy) {
5011+ this.busy = busy;
5012+ this.querySelectorAll("button").forEach(button => {
5013+ button.disabled = busy || (button.hasAttribute("data-grant-approve") && this.hasAttribute("data-stale"));
5014+ });
5015+ }
5016+ report(text) {
5017+ let output = this.querySelector("output");
5018+ if (!output) { output = document.createElement("output"); output.setAttribute("role", "status"); this.append(output); }
5019+ output.textContent = text;
5020+ }
5021+ async latest(id) {
5022+ const query = new URLSearchParams({method: "browseapproval", params: JSON.stringify([{id}])});
5023+ const response = await fetch(tiny.localPath("/webmcp/query") + "?" + query, {credentials: "same-origin", cache: "no-store", signal: this.controller.signal});
5024+ const value = await response.json();
5025+ if (!response.ok || value?.error) throw Error(value?.error?.message || value?.error || "Could not refresh this request.");
5026+ return value.result || value;
5027+ }
5028+ async checkSigner(signer, operator) {
5029+ if (!signer?.getPublicKey || await signer.getPublicKey() !== operator || tiny.signer() !== signer) throw Error("Connect the requested operator's signer.");
5030+ if (!this.isConnected || this.controller.signal.aborted) throw Error("This request is no longer open.");
5031+ }
5032+ async decide(button, approve) {
5033+ if (this.busy) return;
5034+ this.setBusy(true);
5035+ this.controller = new AbortController();
5036+ try {
5037+ const review = JSON.parse(this.getAttribute("data-review") || "{}");
5038+ const signer = tiny.signer();
5039+ await this.checkSigner(signer, review.operator);
5040+ this.report("Checking current permissions…");
5041+ const {item} = await this.latest(review.request_id), fresh = item?.grant;
5042+ if (!item || item.id !== review.request_id || item.type !== "grant" || item.state !== "open" || item.asker !== review.agent || item.asked?.length !== 1 || item.asked[0] !== review.operator) throw Error("This request is no longer waiting for your decision.");
5043+ if (approve && (!fresh || item.grant_error || fresh.request_id !== review.request_id || fresh.base !== review.base || fresh.agent !== review.agent || fresh.operator !== review.operator || stable(fresh.before) !== stable(review.before) || stable(fresh.after) !== stable(review.after))) throw Error("Permissions changed. Reload and review this request again.");
5044+ await this.checkSigner(signer, review.operator);
5045+ const unsigned = approve ? fresh.unsigned : {kind: 7, created_at: Math.floor(Date.now()/1000), tags: [["e", review.request_id, "", review.agent], ["p", review.agent], ["k", "1111"]], content: "-"};
5046+ if (approve && (unsigned?.kind !== 30392 || unsigned.pubkey !== review.operator || !unsigned.tags.some(tag => tag[0] === "grant-request" && tag[1] === review.request_id) || !unsigned.tags.some(tag => tag[0] === "grant-base" && tag[1] === review.base))) throw Error("The grant review is incomplete.");
5047+ this.report(approve ? "Signing grant…" : "Signing denial…");
5048+ await tiny.signing.publish({kind:unsigned.kind,created_at:unsigned.created_at,tags:unsigned.tags,content:unsigned.content});
5049+ this.report(approve ? "Grant updated." : "Request denied.");
5050+ if (this.isConnected) await tiny.navigate(location.href);
5051+ } catch (error) {
5052+ if (this.isConnected) { this.report(error.message || String(error)); this.setBusy(false); }
5053+ }
5054+ }
5055+ }
5056+ customElements.define("agent-grant-request", GrantRequest);
5057+})();
5058diff --git a/internal/webui/page.html b/internal/webui/page.html
5059index ec0e301..6c4aea5 100644
5060--- a/internal/webui/page.html
5061+++ b/internal/webui/page.html
5062@@ -49,6 +49,7 @@ then feature modules are loaded for the current page. */}}
5063 {{else if eq .Tab "rules"}}{{template "rules" .}}
5064 {{else if eq .Tab "identity"}}{{template "identity" .}}
5065 {{else if eq .Tab "signin"}}{{template "signin" .}}
5066+{{else if eq .Tab "account"}}{{template "account" .}}
5067 {{else if eq .Tab "connect"}}{{template "connect" .}}
5068 {{else if eq .Tab "data"}}{{template "data" .}}
5069 {{else if eq .Tab "sync"}}{{template "sync" .}}
5070@@ -73,7 +74,7 @@ then feature modules are loaded for the current page. */}}
5071 {{end}}</div></td>
5072 <td id="aside"><div id="panel">{{template "panel" .}}</div></td>
5073 </tr></tbody></table>
5074-<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; <page-link url="{{pageAddress .URL .Path .Query}}" title="Copy the page address">{{prompt .Path .Query}}</page-link></span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as {{template "nostrName" .Actor}} (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="{{signinURL .Base .Path .Query}}">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><span id="navigation-status" role="status" aria-live="polite"></span></td></tr></table><button id="install" type="button" hidden>install app</button></footer>
5075+<footer id="footer"><table><tr><td><span id="prompt"><b>{{.Slug}}</b> &raquo; <page-link url="{{pageAddress .URL .Path .Query}}" title="Copy the page address">{{prompt .Path .Query}}</page-link></span></td><td><span id="who">{{if .Private}}private relay{{else if .Actor}}signed in as <a href="/account">{{template "nostrName" .Actor}}</a> (<button id="session-logout" type="button">sign out</button>){{else}}browsing as guest (<a href="{{signinURL .Base .Path .Query}}">Sign in</a>){{end}}</span> <span id="session-status" role="status"></span><span id="navigation-status" role="status" aria-live="polite"></span></td></tr></table><button id="install" type="button" hidden>install app</button></footer>
5076 <script src="{{scriptURL "tiny.js"}}"></script>
5077 <script src="{{scriptURL "bridge.js"}}"></script>
5078 {{if or (eq .Tab "files") (eq .Tab "file")}}<script src="{{scriptURL "blossom-encryption.js"}}"></script>
5079@@ -88,6 +89,9 @@ then feature modules are loaded for the current page. */}}
5080 <script src="{{scriptURL "direct-messages.js"}}"></script>
5081 <script src="{{scriptURL "chat-files.js"}}"></script>
5082 <script src="{{scriptURL "chat.js"}}"></script>
5083+<script src="{{scriptURL "chat-presence.js"}}"></script>
5084+<script src="{{scriptURL "chat-activity.js"}}"></script>
5085+<script src="{{scriptURL "grant-request.js"}}"></script>
5086 {{if eq .Tab "connect"}}<script src="{{scriptURL "private-services.js"}}"></script>
5087 {{end}}</body>
5088 </html>
5089@@ -109,7 +113,7 @@ then feature modules are loaded for the current page. */}}
5090 <nav id="room-list">{{range .Rooms}}{{$r := asMap .}}<a href="/rooms/{{str (index $r "id")}}"{{if eq (str (index $r "id")) $.View}} aria-current="page"{{end}}>{{or (str (index $r "name")) (str (index $r "id"))}}{{with age (index $r "last_message_at")}} <small>{{.}}</small>{{end}}</a>{{else}}<a href="/chat">Browse rooms</a>{{end}}</nav>
5091 <p><a href="/chat#new-room">+ new room</a></p>
5092 {{else}}<header><span><a id="mark" href="/" title="{{.Slug}}"><i>t</i><i>i</i><i>n</i><i>y</i></a></span></header>
5093-{{range $group := navGroups relayItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}
5094+{{range $group := navGroups relayItems}}<nav>{{range $item := $group}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>{{end}}{{if .Actor}}<p id="personal-nav"><a href="/account"{{if eq .Tab "account"}} aria-current="page"{{end}}>account</a></p>{{end}}
5095 {{end}}<p id="rail-tools">{{if eq $kind "repo"}}<a href="/repos">&larr; all repositories</a>{{else if or (eq $kind "manage") (eq $kind "rooms")}}<a href="/">&larr; relay</a>{{end}}<button id="theme" type="button" aria-label="Switch theme">theme</button></p>{{end}}
5096
5097 {{/* The panel holds facts and filters for the current page. */}}
5098@@ -126,6 +130,7 @@ then feature modules are loaded for the current page. */}}
5099 {{else if eq .Tab "approvals"}}{{template "panel-approvals" .}}
5100 {{else if or (eq .Tab "wiki") (eq .Tab "wikipage")}}{{template "panel-wiki" .}}
5101 {{else if eq .Tab "signin"}}{{template "panel-signin" .}}
5102+{{else if eq .Tab "account"}}{{template "panel-account" .}}
5103 {{else}}{{template "panel-relay" .}}
5104 {{end}}{{end}}
5105
5106@@ -136,6 +141,8 @@ then feature modules are loaded for the current page. */}}
5107 {{define "panel-signin"}}<section><h4>Session</h4><table><tr><th>status</th><td>{{if .Actor}}signed in{{else}}guest{{end}}</td></tr><tr><th>method</th><td>NIP-98</td></tr></table></section>
5108 <section><h4>After sign in</h4><ul><li>join chats</li><li>manage members and policy</li><li>upload files</li><li>push repositories</li></ul></section>{{end}}
5109
5110+{{define "panel-account"}}<section><h4>Personal</h4><table><tr><th>identity</th><td>{{template "nostrKey" .Actor}}</td></tr><tr><th>profile</th><td><a href="/profile">edit</a></td></tr></table></section><section><h4>Relay</h4><p><small>Your relay lists and chat preferences apply to this account.</small></p></section>{{end}}
5111+
5112 {{define "panel-repos"}}<section><h4>Hosting</h4><table><tr><th>server</th><td><code>{{.URL}}</code></td></tr><tr><th>protocol</th><td>GRASP-01</td></tr></table></section>
5113 <section><h4>Publish</h4><p><small>Announce with ngit and push with plain git. Every push needs the owner's signed state.</small></p></section>{{end}}
5114
5115@@ -167,8 +174,9 @@ then feature modules are loaded for the current page. */}}
5116 <section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/chat#new-room">new room</a></li>{{else}}<li><a href="{{signinURL .Base .Path .Query}}">sign in</a></li>{{end}}</ul></section>
5117 {{else if .Error}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{.View}}</code></td></tr></table></section>
5118 {{else}}{{$data := asMap .Event}}{{$room := asMap (index $data "room")}}{{$id := str (index $room "id")}}{{$role := str (index $room "role")}}<section><h4>Room</h4><table><tr><th>id</th><td><code>{{$id}}</code></td></tr><tr><th>access</th><td>{{if eq (str (index $room "access")) "members"}}members only{{else}}open{{end}}</td></tr><tr><th>created</th><td>{{short (index $room "created_at")}}</td></tr>{{if $role}}<tr><th>you</th><td>{{$role}}</td></tr>{{end}}</table></section>
5119+<section><h4>Members</h4><ul id="members">{{range roomMembers .Event}}{{$m := asMap .}}<li{{if index $m "agent"}} data-agent{{with str (index $m "operator")}} data-operator="{{.}}"{{end}}{{end}}>{{template "nostrName" (str (index $m "pubkey"))}} <small>{{if index $m "agent"}}{{if index $m "operator"}}{{template "nostrName" (str (index $m "operator"))}}'s agent{{else}}agent{{end}}{{else}}{{str (index $m "role")}}{{end}}</small></li>{{else}}<li>{{str (index $room "members")}} members</li>{{end}}</ul></section>
5120 {{if eq .Tab "thread"}}<section><h4>Actions</h4><ul><li><a href="/rooms/{{$id}}">open room</a></li></ul></section>
5121-{{else}}<section><h4>Members</h4><ul id="members">{{range roomMembers .Event}}{{$m := asMap .}}<li{{if index $m "agent"}} data-agent{{end}}>{{template "nostrName" (str (index $m "pubkey"))}} <small>{{str (index $m "role")}}{{if index $m "agent"}} | agent{{end}}</small></li>{{else}}<li>{{str (index $room "members")}} members</li>{{end}}</ul></section>
5122+{{else}}
5123 {{if .Actor}}<section><h4>Actions</h4>{{if roomAdmin $role}}<details><summary>invite member</summary><room-action room="{{$id}}" kind="9000"><label>pubkey <input name="pubkey" placeholder="npub or 64 hex" required></label><label>role <select name="role"><option value="member">member</option><option value="admin">admin</option>{{if eq $role "owner"}}<option value="owner">owner</option>{{end}}</select></label><button>Add member</button></room-action></details>
5124 <details><summary>room settings</summary><room-action room="{{$id}}" kind="9002"><label>name <input name="name" value="{{str (index $room "name")}}" required maxlength="200"></label><label>about <input name="about" value="{{str (index $room "about")}}" maxlength="2000"></label><label>picture <input name="picture" value="{{str (index $room "picture")}}" placeholder="https://"></label><label>access <select name="access"><option value="open"{{if ne (str (index $room "access")) "members"}} selected{{end}}>open</option><option value="members"{{if eq (str (index $room "access")) "members"}} selected{{end}}>members only</option></select></label><button>Save</button></room-action></details>
5125 {{end}}{{if $role}}<room-action room="{{$id}}" kind="9022"><button>Leave room</button></room-action>{{else}}<room-action room="{{$id}}" kind="9021"><button>Join room</button></room-action>{{end}}<noscript><p>Room actions need JavaScript and a connected signer.</p></noscript></section>
5126diff --git a/internal/webui/private.go b/internal/webui/private.go
5127index 2125cfe..339450c 100644
5128--- a/internal/webui/private.go
5129+++ b/internal/webui/private.go
5130@@ -19,7 +19,7 @@ type PrivateReader interface {
5131 func privatePolicyEnabled(p policy.Policy) bool { return p.Features.Grasp08 }
5132
5133 func privateShellPath(path string) bool {
5134- if path == "/" || path == "/signin" || path == "/manage/connect" {
5135+ if path == "/" || path == "/signin" || path == "/account" || path == "/manage/connect" {
5136 return true
5137 }
5138 if path == "/manage/rpc" || path == "/manage/jobs/status" {
5139diff --git a/internal/webui/public.html b/internal/webui/public.html
5140index 796d75c..5eae294 100644
5141--- a/internal/webui/public.html
5142+++ b/internal/webui/public.html
5143@@ -29,26 +29,29 @@
5144 <h1>{{if .Actor}}Your account{{else}}Sign in{{end}}</h1>
5145 {{if .Notice}}<p role="status">{{.Notice}}</p>{{end}}
5146 {{if .Actor}}<p>Signed in as {{template "nostrKey" .Actor}}.</p>
5147-<p><a href="/manage/people">Manage relay</a> | <a href="/tools">Browser tools</a> | <a href="/chat">Chat</a></p>
5148+<p><a href="/account">Open your account</a> | <a href="/chat">Chat</a></p>
5149 <p>Use the sign out control in the footer to end this session.</p>
5150-<details id="relay-setup"><summary>Your relay lists (optional)</summary>
5151-<p>Choose where other Nostr clients find your notes, messages and files. Add this relay to any lists you want to use. Existing entries stay.</p>
5152+{{else}}<p>Use a browser extension, or connect Amber on your phone. The relay never sees a private key.</p>
5153+{{template "signerControls" .}}
5154+{{end}}</section>
5155+{{end}}
5156+
5157+{{define "account"}}<section id="account">
5158+<h1>Account</h1>
5159+<p>Manage your profile, chat preferences and relay lists.</p>
5160+<table id="account-identity"><tbody><tr><th>signed in as</th><td>{{template "nostrName" .Actor}}</td></tr><tr><th>profile</th><td><a href="/profile">Edit your profile</a></td></tr></tbody></table>
5161+<h2>Chat preferences</h2>
5162+<chat-preferences actor="{{.Actor}}"><label><input type="checkbox" name="share_presence"{{if .SharePresence}} checked{{end}} disabled> Share presence and typing</label><p>Show when you are online or typing in chat.</p><output role="status"></output></chat-preferences><noscript><p>Changing preferences requires JavaScript and a connected signer.</p></noscript>
5163+<h2>Your relay lists</h2>
5164+<p>Choose where other Nostr clients find your notes, messages and files. Existing entries stay.</p>
5165 <relay-lists relay="{{wsURL .URL}}" server="{{.URL}}" pubkey="{{.Actor}}">
5166-<table id="rows">
5167-<tbody>
5168+<table id="rows"><tbody>
5169 <tr data-kind="10002" data-tag="r"><td>Relay list <small>NIP-65</small></td><td>where clients read your notes and send you mentions</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr>
5170 <tr data-kind="10050" data-tag="relay"><td>DM inbox <small>NIP-17</small></td><td>where people send you private messages</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr>
5171 <tr data-kind="10007" data-tag="relay"><td>Search relays <small>NIP-51</small></td><td>where clients run your searches</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr>
5172 <tr data-kind="10063" data-tag="server" data-value="server"><td>Blossom servers <small>BUD-03</small></td><td>where clients upload and look for your files</td><td><button type="button" name="check">check</button> <button type="button" name="add">add this relay</button></td></tr>
5173-</tbody>
5174-</table>
5175-<output role="status"></output>
5176-</relay-lists>
5177-</details>
5178-{{else}}<p>Use a browser extension, or connect Amber on your phone. The relay never sees a private key.</p>
5179-{{template "signerControls" .}}
5180-{{end}}</section>
5181-{{end}}
5182+</tbody></table><output role="status"></output></relay-lists>
5183+</section>{{end}}
5184
5185 {{define "signerControls"}}<p><button id="session-login" type="button">Sign in with connected signer</button></p>
5186 <h2>Phone signer</h2>
5187diff --git a/internal/webui/read_contracts.go b/internal/webui/read_contracts.go
5188index 54a712d..58b4a3e 100644
5189--- a/internal/webui/read_contracts.go
5190+++ b/internal/webui/read_contracts.go
5191@@ -55,10 +55,11 @@ type RoomSummary struct {
5192 }
5193
5194 type RoomMember struct {
5195- PubKey string `json:"pubkey"`
5196- Role string `json:"role,omitempty"`
5197- Agent bool `json:"agent,omitempty"`
5198- AddedAt int64 `json:"added_at,omitempty"`
5199+ PubKey string `json:"pubkey"`
5200+ Role string `json:"role,omitempty"`
5201+ Agent bool `json:"agent,omitempty"`
5202+ Operator string `json:"operator,omitempty"`
5203+ AddedAt int64 `json:"added_at,omitempty"`
5204 }
5205
5206 type RoomMessage = event.Event
5207diff --git a/internal/webui/rooms.go b/internal/webui/rooms.go
5208index 28f2670..9e448ee 100644
5209--- a/internal/webui/rooms.go
5210+++ b/internal/webui/rooms.go
5211@@ -6,8 +6,11 @@ import (
5212 "html/template"
5213 "regexp"
5214 "sort"
5215+ "strconv"
5216 "strings"
5217 "time"
5218+
5219+ "github.com/FelineStateMachine/tinyrelay/internal/event"
5220 )
5221
5222 // roomPath is one rooms page: the list, one room, or one thread in a room.
5223@@ -123,12 +126,8 @@ func memberIndex(value any) map[string]map[string]any {
5224 func replyCounts(value any) map[string]int {
5225 counts := map[string]int{}
5226 for _, row := range roomSlice(value, "messages") {
5227- e := valueMap(row)
5228- if plainString(e["kind"]) != "12" {
5229- continue
5230- }
5231- if roots := tagValues(row, "e"); len(roots) > 0 {
5232- counts[roots[0]]++
5233+ if root := webRoomReplyRoot(row); root != "" {
5234+ counts[root]++
5235 }
5236 }
5237 return counts
5238@@ -289,13 +288,13 @@ func clock(value any) string {
5239 // roomItem is one message as the page shows it: the event's fields, the
5240 // author's markers, the thread state and the reactions it has received.
5241 type roomItem struct {
5242- ID, PubKey, Avatar, Kind, Content, Room, Root, Quote, Role, Notice string
5243- CreatedAt, UpdatedAt int64
5244- Agent, InThread, Edited, Own bool
5245- Mentions []string
5246- Replies int
5247- Reactions []reaction
5248- Attachments []roomAttachment
5249+ ID, PubKey, Avatar, Kind, Content, Room, Root, Quote, Role, Operator, Notice string
5250+ CreatedAt, UpdatedAt int64
5251+ Agent, InThread, Edited, Own bool
5252+ Mentions []string
5253+ Replies int
5254+ Reactions []reaction
5255+ Attachments []roomAttachment
5256 }
5257
5258 // roomItems turns a browse result's messages or replies into view items,
5259@@ -312,7 +311,7 @@ func roomItems(value any, field string, actors ...string) []roomItem {
5260 items := make([]roomItem, 0, len(rows))
5261 for i := len(rows) - 1; i >= 0; i-- {
5262 item, ok := newRoomItem(rows[i], room, members)
5263- if !ok {
5264+ if !ok || field == "messages" && item.Root != "" {
5265 continue
5266 }
5267 // Only the author edits their own message, and only forward in time.
5268@@ -374,10 +373,8 @@ func newRoomItem(row any, room string, members map[string]map[string]any) (roomI
5269 if item.Kind == "44101" {
5270 item.Notice = "left the room"
5271 }
5272- case "12":
5273- if roots := tagValues(row, "e"); len(roots) > 0 {
5274- item.Root = roots[0]
5275- }
5276+ case "9", "12":
5277+ item.Root = webRoomReplyRoot(row)
5278 }
5279 if len(item.PubKey) >= 2 {
5280 item.Avatar = strings.ToUpper(item.PubKey[:2])
5281@@ -385,6 +382,7 @@ func newRoomItem(row any, room string, members map[string]map[string]any) (roomI
5282 if member := members[item.PubKey]; member != nil {
5283 item.Role = plainString(member["role"])
5284 item.Agent, _ = member["agent"].(bool)
5285+ item.Operator = plainString(member["operator"])
5286 }
5287 if item.Notice == "" {
5288 for _, mention := range tagValues(row, "p") {
5289@@ -401,3 +399,11 @@ func roomAdmin(role any) bool {
5290 value := plainString(role)
5291 return value == "owner" || value == "admin"
5292 }
5293+
5294+func webRoomReplyRoot(row any) string {
5295+ kind, err := strconv.Atoi(plainString(valueMap(row)["kind"]))
5296+ if err != nil {
5297+ return ""
5298+ }
5299+ return event.RoomReplyRoot(event.Event{Kind: kind, Tags: roomTags(row)})
5300+}
5301diff --git a/internal/webui/rooms.html b/internal/webui/rooms.html
5302index d601ac3..145f737 100644
5303--- a/internal/webui/rooms.html
5304+++ b/internal/webui/rooms.html
5305@@ -31,10 +31,12 @@ establishes the initial state. */}}
5306 {{with str (index $room "about")}}<p>{{.}}</p>{{end}}
5307 {{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}}
5308 {{if .Query.Get "cursor"}}<p id="newest"><a href="/rooms/{{$id}}">newest messages</a></p>{{end}}
5309+{{template "chatActivity" .Activity}}
5310 <div id="messages">{{range roomItems .Event "messages" .Actor}}
5311 {{template "roomMessage" .}}{{else}}<p id="empty">No messages yet.</p>{{end}}
5312 </div>
5313 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}"></room-live>
5314+{{template "chatPresence" .}}
5315 {{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}">{{if .Policy.Features.Files}}<room-files aria-label="Attachments" aria-live="polite"></room-files>{{end}}<room-compose-bar>{{if .Policy.Features.Files}}{{template "roomFilePicker" .}}{{end}}<textarea name="content" rows="1" aria-label="Message" placeholder="Message…" title="Mention someone with @npub or @hex" required></textarea><button type="submit" aria-label="Send" title="Send message">{{template "roomSendIcon" .}}</button></room-compose-bar></room-compose>
5316 <noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>
5317 {{else}}<p id="guest"><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to send messages.</p>
5318@@ -49,12 +51,14 @@ establishes the initial state. */}}
5319 <p id="crumbs"><a href="/rooms/{{$id}}">{{or (str (index $room "name")) $id}}</a></p>
5320 <h1>Thread <small>{{$root.Replies}} {{if eq $root.Replies 1}}reply{{else}}replies{{end}}</small></h1>
5321 <div id="root">{{template "roomMessage" $root}}</div>
5322+{{template "chatActivity" .Activity}}
5323 <div id="messages">{{range roomItems .Event "replies" .Actor}}
5324 {{template "roomMessage" .}}{{else}}<p id="empty">No replies yet.</p>{{end}}
5325 </div>
5326 {{with index $data "next_cursor"}}<p id="pages"><a href="/rooms/{{$id}}/thread/{{$root.ID}}?cursor={{urlquery (str .)}}">load earlier</a></p>{{end}}
5327 {{if not (.Query.Get "cursor")}}<room-live room="{{$id}}" root="{{$root.ID}}"></room-live>
5328-{{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}" kind="12" root="{{$root.ID}}" root-pubkey="{{$root.PubKey}}">{{if .Policy.Features.Files}}<room-files aria-label="Attachments" aria-live="polite"></room-files>{{end}}<room-compose-bar>{{if .Policy.Features.Files}}{{template "roomFilePicker" .}}{{end}}<textarea name="content" rows="1" aria-label="Reply" placeholder="Reply in this thread…" required></textarea><button type="submit" aria-label="Reply" title="Send reply">{{template "roomSendIcon" .}}</button></room-compose-bar></room-compose>
5329+{{template "chatPresence" .}}
5330+{{if .Actor}}<room-compose room="{{$id}}" pubkey="{{.Actor}}" kind="{{if eq $root.Kind "11"}}12{{else}}9{{end}}" root="{{$root.ID}}" root-pubkey="{{$root.PubKey}}">{{if .Policy.Features.Files}}<room-files aria-label="Attachments" aria-live="polite"></room-files>{{end}}<room-compose-bar>{{if .Policy.Features.Files}}{{template "roomFilePicker" .}}{{end}}<textarea name="content" rows="1" aria-label="Reply" placeholder="Reply in this thread…" required></textarea><button type="submit" aria-label="Reply" title="Send reply">{{template "roomSendIcon" .}}</button></room-compose-bar></room-compose>
5331 <noscript><p>Replying needs JavaScript and a connected signer.</p></noscript>
5332 {{else}}<p id="guest"><a href="{{signinURL .Base .Path .Query}}">Sign in</a> to reply.</p>
5333 {{end}}{{end}}</section>
5334@@ -65,7 +69,9 @@ establishes the initial state. */}}
5335
5336 {{/* One message. room-live builds the same markup for messages that arrive on the stream. */}}
5337 {{define "roomMessage"}}<room-message id="msg-{{.ID}}" data-id="{{.ID}}" data-kind="{{.Kind}}" data-updated-at="{{.UpdatedAt}}" data-pubkey="{{.PubKey}}"{{if .Own}} data-own{{end}}{{if .Agent}} data-agent{{end}}{{if .Notice}} data-notice{{end}}{{if .Edited}} data-edited{{end}}><nostr-avatar pubkey="{{.PubKey}}" aria-hidden="true">{{.Avatar}}</nostr-avatar>
5338-<header><b>{{template "nostrName" .PubKey}}</b>{{if .Own}}<span data-own-label>you</span>{{end}}{{if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header>
5339+<header><b>{{template "nostrName" .PubKey}}</b>{{if .Own}}<span data-own-label>you</span>{{end}}{{if .Agent}} | {{if .Operator}}{{template "nostrName" .Operator}}'s agent{{else}}agent{{end}}{{else if .Role}} | {{.Role}}{{end}}<small><time datetime="{{datetime .CreatedAt}}" title="{{when .CreatedAt}}">{{clock .CreatedAt}}</time></small></header>
5340 {{if .Notice}}<p>{{.Notice}}</p>{{else}}{{if .Quote}}<blockquote data-quote><a href="/open?target={{urlquery (printf "nostr:%s" .Quote)}}">quoted event {{shortID .Quote}}</a></blockquote>{{end}}<div>{{chatMarkdown (roomAttachmentContent .Content .Attachments)}}</div>{{roomAttachmentMarkup .Attachments}}{{end}}
5341-{{if or .Mentions (and (not .InThread) (or (eq .Kind "11") .Root)) .Reactions .Edited}}<footer>{{if .Mentions}}<span>to {{range .Mentions}}{{template "nostrName" .}} {{end}}</span>{{end}}{{if not .InThread}}{{if eq .Kind "11"}}<a href="/rooms/{{.Room}}/thread/{{.ID}}">thread{{if .Replies}} | {{.Replies}} {{if eq .Replies 1}}reply{{else}}replies{{end}}{{end}}</a>{{else if .Root}}<a href="/rooms/{{.Room}}/thread/{{.Root}}">in thread</a>{{end}}{{end}}{{range .Reactions}}<span data-reaction="{{.Content}}">{{.Content}} {{.Count}}</span>{{end}}{{if .Edited}}<span data-edited>edited</span>{{end}}</footer>{{end}}
5342+{{if or .Mentions (and (not .InThread) (or (eq .Kind "11") (eq .Kind "9") (eq .Kind "40002") .Root)) .Reactions .Edited}}<footer>{{if .Mentions}}<span>to {{range .Mentions}}{{template "nostrName" .}} {{end}}</span>{{end}}{{if not .InThread}}{{if or (eq .Kind "11") (eq .Kind "9") (eq .Kind "40002")}}<a href="/rooms/{{.Room}}/thread/{{.ID}}">thread{{if .Replies}} | {{.Replies}} {{if eq .Replies 1}}reply{{else}}replies{{end}}{{end}}</a>{{else if .Root}}<a href="/rooms/{{.Room}}/thread/{{.Root}}">in thread</a>{{end}}{{end}}{{range .Reactions}}<span data-reaction="{{.Content}}">{{.Content}} {{.Count}}</span>{{end}}{{if .Edited}}<span data-edited>edited</span>{{end}}</footer>{{end}}
5343 </room-message>{{end}}
5344+
5345+{{define "chatPresence"}}<chat-presence room="{{.View}}" actor="{{.Actor}}"><output aria-live="polite"></output></chat-presence>{{end}}
5346diff --git a/internal/webui/rooms.js b/internal/webui/rooms.js
5347index aeae3fa..8d4a245 100644
5348--- a/internal/webui/rooms.js
5349+++ b/internal/webui/rooms.js
5350@@ -325,10 +325,23 @@
5351 const members = {};
5352 document.querySelectorAll("#members li").forEach(item => {
5353 const hex = item.querySelector("nostr-name")?.getAttribute("pubkey") || item.querySelector("nostr-key")?.getAttribute("hex");
5354- if (hex) members[hex] = {role: (item.querySelector("small")?.textContent || "").split("|")[0].trim(), agent: item.hasAttribute("data-agent")};
5355+ if (hex) members[hex] = {role: (item.querySelector("small")?.textContent || "").split("|")[0].trim(), agent: item.hasAttribute("data-agent"), operator: item.getAttribute("data-operator") || ""};
5356 });
5357 return members;
5358 };
5359+ const replyRoot = event => {
5360+ if (![9, 12].includes(event?.kind)) return "";
5361+ let root = "", reply = "", first = "", marked = false;
5362+ for (const tag of event.tags || []) {
5363+ if (tag[0] !== "e" || !isHex64(tag[1])) continue;
5364+ const marker = tag[3] || "";
5365+ marked ||= Boolean(marker);
5366+ if (marker === "root") root = tag[1];
5367+ else if (marker === "reply") reply = tag[1];
5368+ else if (!marker && !first) first = tag[1];
5369+ }
5370+ return root || reply || (!marked ? first : "");
5371+ };
5372 const messageNode = (event, {members = {}, room = "", inThread = false} = {}) => {
5373 const node = el("room-message");
5374 const notice = event.kind === 44100 ? "joined the room" : event.kind === 44101 ? "left the room" : "";
5375@@ -354,7 +367,12 @@
5376 small.append(time);
5377 header.append(name);
5378 if (own) { const label = el("span", "you"); label.dataset.ownLabel = ""; header.append(label); }
5379- header.append(member?.role ? " | " + member.role : "", small);
5380+ if (member?.agent) {
5381+ header.append(" | ");
5382+ if (member.operator) header.append(nameNode(member.operator), "'s agent");
5383+ else header.append("agent");
5384+ } else if (member?.role) header.append(" | " + member.role);
5385+ header.append(small);
5386 const attachments = notice ? [] : roomAttachments(event);
5387 const body = notice ? el("p", notice) : chatMarkdown(el("div"), attachmentContent(event.content || "", attachments));
5388 node.append(avatar, header);
5389@@ -365,9 +383,9 @@
5390 const footer = el("footer");
5391 const mentions = notice ? [] : (event.tags || []).filter(tag => tag[0] === "p" && isHex64(tag[1]) && tag[1] !== pubkey).map(tag => tag[1]);
5392 if (mentions.length) { const span = el("span", "to "); mentions.forEach(key => span.append(nameNode(key), " ")); footer.append(span); }
5393- if (!inThread && room && event.kind === 11) { const link = el("a", "thread"); link.href = roomPath(room, "/thread/" + event.id); footer.append(link); }
5394- const root = tagValue(event, "e");
5395- if (!inThread && room && event.kind === 12 && isHex64(root)) { const link = el("a", "in thread"); link.href = roomPath(room, "/thread/" + root); footer.append(link); }
5396+ if (!inThread && room && [9, 11, 40002].includes(event.kind)) { const link = el("a", "thread"); link.href = roomPath(room, "/thread/" + event.id); footer.append(link); }
5397+ const root = replyRoot(event);
5398+ if (!inThread && room && isHex64(root)) { const link = el("a", "in thread"); link.href = roomPath(room, "/thread/" + root); footer.append(link); }
5399 if (footer.childNodes.length) node.append(footer);
5400 return node;
5401 };
5402@@ -388,6 +406,16 @@
5403 const roomAppend = (event, {room = "", inThread = false, own = false} = {}) => {
5404 const list = document.getElementById("messages");
5405 if (!list || !isHex64(event?.id) || document.getElementById("msg-" + event.id)) return null;
5406+ list.roomSeen ||= new Set();
5407+ if (list.roomSeen.has(event.id)) return null;
5408+ list.roomSeen.add(event.id);
5409+ while (list.roomSeen.size > 2000) list.roomSeen.delete(list.roomSeen.values().next().value);
5410+ const reference = replyRoot(event);
5411+ if (reference && !inThread) {
5412+ const link = document.querySelector("#msg-" + reference + " > footer > a");
5413+ if (link) { const count = Number((link.textContent.match(/(\d+) repl/) || [])[1] || 0) + 1; link.textContent = "thread | " + count + (count === 1 ? " reply" : " replies"); }
5414+ return null;
5415+ }
5416 const follow = own || roomNearBottom();
5417 list.querySelector("#empty")?.remove();
5418 const node = messageNode(event, {members: panelMembers(), room, inThread});
5419@@ -575,10 +603,10 @@
5420 const kind = Number(this.getAttribute("kind") || 9);
5421 if (kind !== 9 && kind !== 12) throw Error("Unsupported message kind.");
5422 const tags = [["h", room]];
5423- if (kind === 12) {
5424+ if (kind === 12 || this.getAttribute("root")) {
5425 const root = this.getAttribute("root"), author = this.getAttribute("root-pubkey");
5426 if (!isHex64(root)) throw Error("The thread root is missing.");
5427- tags.push(["e", root]);
5428+ tags.push(kind === 12 ? ["e", root] : ["e", root, "", "root"]);
5429 if (isHex64(author) && author !== this.getAttribute("pubkey")) tags.push(["p", author]);
5430 }
5431 const quote = this.getAttribute("quote");
5432@@ -650,7 +678,7 @@
5433 this.renderFiles();
5434 this.resizeContent();
5435 this.report("");
5436- roomAppend(event, {room: this.getAttribute("room"), inThread: event.kind === 12, own: true});
5437+ roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(this.getAttribute("root")), own: true});
5438 } finally { this.uploadController = null; }
5439 }
5440 }
5441@@ -760,16 +788,22 @@
5442
5443 receive(event) {
5444 if (!event || typeof event !== "object") return;
5445+ const room = this.getAttribute("room");
5446+ if (tagValue(event, "h") !== room && !(event.kind === 20001 && !tagValue(event, "h"))) return;
5447+ if (typeof CustomEvent === "function") document.dispatchEvent?.(new CustomEvent("tiny:room-event", {detail: {room, event}}));
5448 if (event.kind === 7) { roomReact(event); return; }
5449 if (event.kind === 40003) { roomEdit(event); return; }
5450 if (!roomKinds.includes(event.kind)) return;
5451 const root = this.getAttribute("root");
5452- if (root && (event.kind !== 12 || tagValue(event, "e") !== root)) return;
5453+ if (root) {
5454+ const reference = replyRoot(event);
5455+ if (reference !== root && !document.getElementById("msg-" + reference)) return;
5456+ }
5457 roomAppend(event, {room: this.getAttribute("room"), inThread: Boolean(root)});
5458 }
5459 }
5460
5461- tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, messageNode, roomAppend, roomReact, roomEdit, linkify, chatMarkdown, roomAttachments, attachmentContent, attachmentNodes});
5462+ tiny.rooms = Object.freeze({keyHex, roomMentions, roomID, replyRoot, messageNode, roomAppend, roomReact, roomEdit, linkify, chatMarkdown, roomAttachments, attachmentContent, attachmentNodes});
5463 tiny.roomElements = Object.freeze({RoomCompose, RoomCreate, RoomAction, RoomLive});
5464 customElements.define("room-compose", RoomCompose);
5465 customElements.define("room-create", RoomCreate);
5466diff --git a/internal/webui/rooms_test.go b/internal/webui/rooms_test.go
5467index 9794e64..3ad6ddb 100644
5468--- a/internal/webui/rooms_test.go
5469+++ b/internal/webui/rooms_test.go
5470@@ -66,7 +66,7 @@ func (b *roomsBackend) Query(_ context.Context, method string, params []json.Raw
5471 default:
5472 return nil, nil
5473 }
5474- members := []any{map[string]any{"pubkey": roomOwner, "role": "owner", "added_at": 1757200000}, map[string]any{"pubkey": roomAgent, "role": "member", "added_at": 1757200100, "agent": true}}
5475+ members := []any{map[string]any{"pubkey": roomOwner, "role": "owner", "added_at": 1757200000}, map[string]any{"pubkey": roomAgent, "role": "member", "added_at": 1757200100, "agent": true, "operator": roomOwner}}
5476 root := roomEvent(roomThread, roomAgent, 11, 1757203000, "Release notes draft", []string{"h", id})
5477 reply := roomEvent(roomReply, roomOwner, 12, 1757203300, "Looks good", []string{"h", id}, []string{"e", roomThread}, []string{"p", roomAgent})
5478 if method == "browsethread" {
5479@@ -156,11 +156,10 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
5480 `<span data-reaction="&#43;1">&#43;1 1</span>`,
5481 `<div><p>hello again, see <a href="https://example.com/docs" rel="noopener">`, `<span data-edited>edited</span>`,
5482 `<a href="/rooms/general/thread/` + roomThread + `">thread | 1 reply</a>`,
5483- `<a href="/rooms/general/thread/` + roomThread + `">in thread</a>`,
5484 `<room-live room="general"></room-live>`,
5485 `<room-compose room="general" pubkey="` + roomOwner + `">`,
5486 `<noscript><p>Sending needs JavaScript and a connected signer.</p></noscript>`,
5487- `<ul id="members"><li><nostr-name pubkey="` + roomOwner + `"`, `<li data-agent><nostr-name pubkey="` + roomAgent + `"`, `<small>member | agent</small>`,
5488+ `<ul id="members"><li><nostr-name pubkey="` + roomOwner + `"`, `<li data-agent data-operator="` + roomOwner + `"><nostr-name pubkey="` + roomAgent + `"`, `<small><nostr-name pubkey="` + roomOwner + `" title="` + roomOwner + `">` + shortID(roomOwner) + `</nostr-name>'s agent</small>`,
5489 `<room-action room="general" kind="9000">`, `<room-action room="general" kind="9002">`, `<room-action room="general" kind="9022">`,
5490 `<nav id="room-list"><a href="/rooms/general" aria-current="page">General`,
5491 `&raquo; <page-link url="http://relay.example/rooms/general" title="Copy the page address">rooms/general</page-link></span>`,
5492@@ -170,7 +169,7 @@ func TestRoomPageRendersMessagesOldestFirstWithMarkers(t *testing.T) {
5493 }
5494 }
5495 hello, chat, thread, reply := strings.Index(body, `id="msg-`+roomHello), strings.Index(body, `id="msg-`+roomChat), strings.Index(body, `id="msg-`+roomThread), strings.Index(body, `id="msg-`+roomReply)
5496- if !(hello < chat && chat < thread && thread < reply) {
5497+ if !(hello < chat && chat < thread && reply == -1) {
5498 t.Fatalf("messages are not oldest first: %d %d %d %d", hello, chat, thread, reply)
5499 }
5500 if strings.Contains(body, `data-kind="7"`) || strings.Contains(body, "<script>alert") {
5501diff --git a/internal/webui/shell_test.go b/internal/webui/shell_test.go
5502index abe2b53..aebe23a 100644
5503--- a/internal/webui/shell_test.go
5504+++ b/internal/webui/shell_test.go
5505@@ -309,7 +309,12 @@ func TestGuestsSeeSignInInsteadOfManagementPages(t *testing.T) {
5506 }
5507 }
5508
5509-type connectionsBackend struct{ fakeBackend }
5510+type connectionsBackend struct {
5511+ fakeBackend
5512+ sharePresence bool
5513+}
5514+
5515+func (b *connectionsBackend) SharePresence(context.Context, string) bool { return b.sharePresence }
5516
5517 func (b *connectionsBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) {
5518 if method == "connections" {
5519@@ -319,19 +324,27 @@ func (b *connectionsBackend) Query(_ context.Context, method string, _ []json.Ra
5520 }
5521
5522 func TestAccountPageOffersRelayListsAndHomeShowsConnectCards(t *testing.T) {
5523- backend := &connectionsBackend{fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
5524+ backend := &connectionsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}, sharePresence: true}
5525 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
5526 if err != nil {
5527 t.Fatal(err)
5528 }
5529 recorder := httptest.NewRecorder()
5530- app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/signin", nil))
5531+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/account", nil))
5532 body := recorder.Body.String()
5533 for _, want := range []string{`<relay-lists relay="ws://relay.example" server="http://relay.example" pubkey="` + backend.policy.Owner + `">`, `data-kind="10002" data-tag="r"`, `data-kind="10050"`, `data-kind="10007"`, `data-kind="10063" data-tag="server"`} {
5534 if !strings.Contains(body, want) {
5535 t.Errorf("account page missing %q", want)
5536 }
5537 }
5538+ if !strings.Contains(body, `<input type="checkbox" name="share_presence" checked disabled>`) {
5539+ t.Error("account page did not show saved presence preference")
5540+ }
5541+ recorder = httptest.NewRecorder()
5542+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/signin", nil))
5543+ if strings.Contains(recorder.Body.String(), "<relay-lists") {
5544+ t.Fatal("sign-in page still exposes account relay settings")
5545+ }
5546 if !strings.Contains(servedScript(t, app, "/scripts/components.js"), `customElements.define("relay-lists"`) {
5547 t.Error("components script missing relay-lists")
5548 }
5549diff --git a/internal/webui/style.css b/internal/webui/style.css
5550index 54916e6..c27b12d 100644
5551--- a/internal/webui/style.css
5552+++ b/internal/webui/style.css
5553@@ -324,7 +324,6 @@ room-message>div pre{margin:0 0 6px;padding:6px 8px;background:var(--rail);borde
5554 room-message>[data-room-attachments]{margin-top:8px}room-message>[data-room-attachments] figure{display:inline-block;vertical-align:top;margin:0 12px 12px 0;max-width:100%;min-width:0}room-message>[data-room-attachments] img{display:block;width:auto;max-width:100%;max-height:360px;border:1px solid var(--line);object-fit:contain}room-message>[data-room-attachments] video{display:block;width:420px;max-width:100%;max-height:360px}room-message>[data-room-attachments] audio{display:block;width:360px;max-width:100%}room-message>[data-room-attachments] figcaption{margin-top:4px;font:12px var(--mono);overflow-wrap:anywhere}room-message>[data-room-attachments] a[data-download]{color:var(--accent)}room-message>[data-room-attachments] small{color:var(--muted);font:11px var(--mono)}
5555 room-message>footer{font:12px var(--mono);color:var(--muted);margin-top:4px}
5556 room-message>footer>*+*::before{content:" | "}
5557-room-message[data-agent]>header b::after{content:" agent";color:var(--bark);font-size:11px}
5558 room-message[data-notice]>p{color:var(--muted);font-size:13px}
5559 room-live{display:block;font:11px var(--mono);color:var(--muted);text-align:right}room-live:empty{display:none}
5560 room-compose{display:block;position:sticky;bottom:0;margin:8px -22px 0;padding:10px 22px 8px;background:var(--paper);border-top:1px solid var(--line)}
5561@@ -522,3 +521,36 @@ direct-file article{padding:0;margin:0;border:0}
5562 direct-file button{display:block;margin:8px 0;padding:8px 12px;min-height:40px}
5563 direct-file output,direct-settings output{display:block;font-size:12px;color:var(--muted);margin-top:6px}
5564 direct-file pre{max-height:360px;overflow:auto;padding:10px;background:var(--rail);font:13px/1.5 var(--mono)}
5565+
5566+/* Chat activity stays compact; details and decisions share the room width. */
5567+chat-activity,chat-presence{display:block;min-width:0}
5568+chat-activity:empty{display:none}
5569+chat-activity>article{display:block;margin:10px 0;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--rail);overflow-wrap:anywhere}
5570+chat-activity header,chat-activity summary{font:13px/1.6 var(--sans)}
5571+chat-activity header small,chat-activity summary small{font:11px var(--mono);color:var(--muted)
Diff preview truncated (256 KiB or 10,000 lines).

About

ownerc01503e8035f
visibilitypublic
head7d475627c988

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs