@elu-labs/elu 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/README.md +340 -2
  4. package/SECURITY.md +333 -0
  5. package/THIRD_PARTY_NOTICES.md +62 -0
  6. package/bin/elu-code.mjs +209 -0
  7. package/bin/elu.cjs +10 -0
  8. package/bin/start.cjs +6 -0
  9. package/docs/peer-network.md +257 -0
  10. package/docs/permissions.md +285 -0
  11. package/docs/vendors/antigravity.md +131 -0
  12. package/docs/vendors/claude.md +124 -0
  13. package/docs/vendors/codex.md +64 -0
  14. package/docs/vendors/grok.md +151 -0
  15. package/lib/acp-client.mjs +100 -0
  16. package/lib/adapters/agy.mjs +283 -0
  17. package/lib/adapters/claude.mjs +311 -0
  18. package/lib/adapters/codex.mjs +137 -0
  19. package/lib/adapters/grok.mjs +384 -0
  20. package/lib/adapters/index.mjs +13 -0
  21. package/lib/agents.mjs +77 -0
  22. package/lib/agy-hook.mjs +53 -0
  23. package/lib/approval-server.mjs +85 -0
  24. package/lib/approvals.mjs +128 -0
  25. package/lib/awake.mjs +45 -0
  26. package/lib/board.mjs +261 -0
  27. package/lib/bot-models.mjs +71 -0
  28. package/lib/bot-schedules.mjs +291 -0
  29. package/lib/bots.mjs +361 -0
  30. package/lib/changes.mjs +168 -0
  31. package/lib/checks.mjs +102 -0
  32. package/lib/conductor.mjs +337 -0
  33. package/lib/config.mjs +147 -0
  34. package/lib/conformance.mjs +109 -0
  35. package/lib/default-bots.mjs +150 -0
  36. package/lib/detect.mjs +108 -0
  37. package/lib/diff.mjs +217 -0
  38. package/lib/editor.mjs +165 -0
  39. package/lib/format.mjs +51 -0
  40. package/lib/git.mjs +173 -0
  41. package/lib/init.mjs +129 -0
  42. package/lib/install.mjs +130 -0
  43. package/lib/kanban.mjs +180 -0
  44. package/lib/mcp.mjs +79 -0
  45. package/lib/memory.mjs +35 -0
  46. package/lib/models.mjs +144 -0
  47. package/lib/modes.mjs +95 -0
  48. package/lib/never-list.mjs +110 -0
  49. package/lib/never-shims.mjs +311 -0
  50. package/lib/paste.mjs +56 -0
  51. package/lib/peer/a2a-http.mjs +166 -0
  52. package/lib/peer/a2a.mjs +357 -0
  53. package/lib/peer/asks.mjs +329 -0
  54. package/lib/peer/canonical.mjs +72 -0
  55. package/lib/peer/cli.mjs +154 -0
  56. package/lib/peer/daemon.mjs +525 -0
  57. package/lib/peer/identity.mjs +81 -0
  58. package/lib/peer/index.mjs +291 -0
  59. package/lib/peer/intake.mjs +207 -0
  60. package/lib/peer/invite.mjs +76 -0
  61. package/lib/peer/paths.mjs +7 -0
  62. package/lib/peer/peerdata.mjs +21 -0
  63. package/lib/peer/pictures.mjs +295 -0
  64. package/lib/peer/plans.mjs +182 -0
  65. package/lib/peer/relay.mjs +31 -0
  66. package/lib/peer/replies.mjs +128 -0
  67. package/lib/peer/roster.mjs +256 -0
  68. package/lib/peer/rpc.mjs +88 -0
  69. package/lib/peer/secrets.mjs +88 -0
  70. package/lib/peer/settings.mjs +45 -0
  71. package/lib/peer/shares.mjs +120 -0
  72. package/lib/peer/status.mjs +84 -0
  73. package/lib/peer/taskstore.mjs +108 -0
  74. package/lib/peer/transport.mjs +120 -0
  75. package/lib/pictures.mjs +104 -0
  76. package/lib/plan.mjs +238 -0
  77. package/lib/policy.mjs +98 -0
  78. package/lib/process.mjs +92 -0
  79. package/lib/project-settings.mjs +55 -0
  80. package/lib/project-trust.mjs +230 -0
  81. package/lib/projects.mjs +668 -0
  82. package/lib/publish.mjs +105 -0
  83. package/lib/runner.mjs +874 -0
  84. package/lib/sandbox.mjs +102 -0
  85. package/lib/schedules.mjs +270 -0
  86. package/lib/selection.mjs +202 -0
  87. package/lib/shell.mjs +101 -0
  88. package/lib/state.mjs +247 -0
  89. package/lib/terminal-bots.mjs +239 -0
  90. package/lib/terminal-commands.mjs +165 -0
  91. package/lib/terminal-plan.mjs +37 -0
  92. package/lib/terminal-visuals.mjs +270 -0
  93. package/lib/terminal.mjs +3181 -0
  94. package/lib/unit-file.mjs +17 -0
  95. package/lib/usage.mjs +249 -0
  96. package/lib/util.mjs +86 -0
  97. package/lib/vendor-check.mjs +96 -0
  98. package/lib/vendors.mjs +191 -0
  99. package/lib/workspace.mjs +168 -0
  100. package/lib/yaml.mjs +624 -0
  101. package/npm-shrinkwrap.json +827 -0
  102. package/package.json +70 -4
@@ -0,0 +1,257 @@
1
+ # Peer network
2
+
3
+ How two ELU Code installs find each other, what they send, and how a team keeps its list of
4
+ members. This page is also the A2A custom binding document (A2A v1.0.1 §12 requires one for a
5
+ custom binding). The commands a person types are in the [README](../README.md#teammates).
6
+
7
+ ## What it does
8
+
9
+ 1. Your agents talk to your teammates' agents, machine to machine, with no company server.
10
+ - **ask:** a question, answered by a read-only run in a project they shared.
11
+ - **ask a bot:** a question to one teammate can name a bot they share. That bot answers it in place of their model.
12
+ - **review:** a diff sent for a read-only review.
13
+ - **pictures:** a message you type to a teammate or a channel takes along the pictures it names. See [Pictures](#pictures).
14
+ - **handoff:** a goal for their machine. Any message can be answered (read-only) or worked on in a mode the owner picks; work never starts on its own.
15
+ - **status and overlap:** teammates see each other's live agents and get a warning when two agents edit the same file.
16
+ - Agents can draft asks themselves. A draft is sent only when you approve it, or automatically to teammates you set to auto.
17
+ 2. A2A v1.0 data model end to end. A local A2A JSON-RPC endpoint on 127.0.0.1, off by default, lets other A2A tools reach your agent and, through it, your teammates.
18
+ 3. Sessions shows peer items that need you. **Chats** shows channels, teammates, their agents and threads.
19
+
20
+ Not supported:
21
+
22
+ - One device per person; no multi-device.
23
+ - Offline catch-up only through the sender's outbox, with no always-on mirror.
24
+ - Handoffs are text goals, not patches.
25
+ - Only pictures travel with a message (PNG, JPEG, GIF, WebP). No other file does.
26
+ - No Windows support.
27
+ - A share grants the whole repository (tracked files minus sensitive ones), not per-path globs.
28
+
29
+ ## Layers
30
+
31
+ ```
32
+ identity ed25519 key per install (Node crypto), shown as hex + 8-char fingerprint
33
+ roster per team: signed append-only log of create/admit/remove/role/relays/leave/channel ops
34
+ discovery each daemon announces its own key on HyperDHT; teammates are dialled by key
35
+ transport Hyperswarm (Noise IK, UDP holepunch) -> blind relay you run -> outbox retry
36
+ binding newline-delimited JSON-RPC 2.0 over the Noise stream (this document)
37
+ messages A2A v1.0 Message/Task/Artifact + extension urn:elu-code:a2a-ext:peer:v1
38
+ channels team: pairwise A2A tasks + status notifications
39
+ conductor peer daemon <-> conductor through files only (requests, outbox, commands)
40
+ ```
41
+
42
+ There is no ELU server anywhere. Defaults that touch third parties:
43
+
44
+ - the HyperDHT bootstrap nodes. HyperDHT's own public nodes are used unless `bootstrap` in `peer/settings.json` names others; the routing table is cached after the first join.
45
+ - other HyperDHT nodes. Announcing the key and dialling a teammate send requests to DHT nodes that anyone may run, and they see this machine's IP address and port.
46
+ - The daemon's own DHT node is ephemeral: it is never added to other nodes' routing tables, so strangers' lookups, announcements and connections are not routed through it, however long it runs.
47
+ - A relay you run keeps HyperDHT's default instead: on a reachable server it can join other nodes' routing tables after about 20 minutes and route lookups for anyone.
48
+
49
+ ## Binding: `urn:elu-code:a2a-binding:hyperswarm-ndjson:v1`
50
+
51
+ - **Transport.** A Hyperswarm connection. Noise IK authenticates both device keys. Each side `listen()`s on its own key pair, and a teammate is dialled with `swarm.joinPeer(key)`.
52
+ - **Framing.** One UTF-8 JSON object per line; a frame may be at most 1 MiB. A larger frame closes the connection.
53
+ - **JSON-RPC 2.0, both directions.** Either side may send requests; responses are matched by `id`. Notifications have no `id`. Requests time out after 30 s.
54
+ - **First frame.** Each side's first frame must be `elu.hello` with params `{binding, a2aVersion: "1.0", name, teams: [{id, seq, head, ops}], handles, plans}`. `plans: true` says this build reads [Tasks](#tasks); a build that does not know a field ignores it. With no valid hello within 5 s, the connection closes.
55
+ - The hello is written for the receiver's key, which Noise has proved before the first frame. `teams` lists only the sender's teams that this key is an active member of in the sender's log. `name` is the sender's display name when `teams` lists one, and empty otherwise.
56
+ - `ops` (at most 8 per team) are the roster ops that admit the sender, so a teammate admitted elsewhere can prove it. They are merged only for a key the receiver has never seen in that team.
57
+ - `handles` (at most 20) are the sender's share handles that the receiver may ask about: the projects shared with a team both machines are on. A teammate admitted by this same exchange gets them on the next connect.
58
+ - **Service parameters.** A2A-Version travels as `a2aVersion`; any `1.x` is accepted, and another major version closes the connection with a logged reason. The only extension, `urn:elu-code:a2a-ext:peer:v1`, is listed in each `Message.extensions` (A2A-Extensions has no other carrier here).
59
+ - **Admission.** The Hyperswarm firewall admits every key. The application layer then accepts a connection only if:
60
+ - the remote key is an active member of a team we share; or
61
+ - its hello carries roster ops that validly extend our log and make it a member; or
62
+ - it opens with `elu.join` carrying a pending invite.
63
+
64
+ Anything else is closed. The decision is logged.
65
+
66
+ Before admission, a key on none of this machine's teams (a stranger, someone removed from them, a joiner) is sent only the hello `{binding, a2aVersion, name: "", teams: [], handles: [], plans: true}`: no team, no member and no name. A joiner gets the roster in the reply to `elu.join`. On a direct connection the handshake also shows the other side this machine's IP address and port.
67
+ - **Methods.**
68
+ - **A2A core:** `SendMessage`, `GetTask`, `CancelTask`. The push-config methods return PushNotificationNotSupported; any other core method returns UnsupportedOperation.
69
+ - `SendMessage` never blocks: it returns the Task (SUBMITTED, or REJECTED with a reason) at once, whatever `returnImmediately` says. Later states arrive as `elu.taskUpdate` or through `GetTask`.
70
+ - Unknown fields in A2A objects and params are ignored (A2A §5.7); size limits still apply.
71
+ - **Extensions:** `elu.hello`, `elu.join` `{teamId, secret, name}`, `elu.roster.get` `{teamId, after}`, `elu.roster.push` `{teamId, ops}`, `elu.picture.put` `{id, seq, last, size, sha256, data}` (see [Pictures](#pictures)).
72
+ - **Notifications:** `elu.status` `{rows, box, bots: [{name, about}]}`, `elu.plans` `{tasks}` and `elu.taskUpdate` `{task}`. `box` and `bots` are described under [Status](#status), `elu.plans` under [Tasks](#tasks).
73
+ - **Errors.** JSON-RPC error objects; `data` is a list of `google.rpc.ErrorInfo` with `reason` set to the code's name. Messages carry no local paths, unless the caller is a peer I trust (`trusted` in `peers.json`), who gets them as written.
74
+
75
+ | code | meaning | the asker |
76
+ |---|---|---|
77
+ | -32000 | too many requests (more than 20 in 10 s; more than 40 closes the connection) | retries with backoff |
78
+ | -32001 | TaskNotFound | marks the ask failed |
79
+ | -32002 | TaskNotCancelable | stops cancelling |
80
+ | -32003 | PushNotificationNotSupported | |
81
+ | -32004 | UnsupportedOperation | marks the ask failed |
82
+ | -32009 | VersionNotSupported | |
83
+ | -32602 | invalid params, including every refusal | marks the ask failed |
84
+ | -32603 | internal error | retries with backoff |
85
+
86
+ - **Idempotency.** `SendMessage` is idempotent on (sender key, `messageId`). A repeat returns the original Task. The sender stores the server's `taskId` in `asks/` and polls `GetTask` when it reconnects.
87
+ - **Exact names.** JSON field names, enum strings and method names must match A2A v1.0.1 (`specification/a2a.proto` and `docs/specification.md` at tag v1.0.1). `lib/peer/a2a.mjs` cites the section each one comes from.
88
+
89
+ Extension `urn:elu-code:a2a-ext:peer:v1`:
90
+
91
+ - It is listed in `Message.extensions`.
92
+ - Its data lives in `metadata["urn:elu-code:a2a-ext:peer:v1"]` = `{kind, project, hop, from: {key, name}, team?, room?, bot?}`. `team: true` marks a team ask; older peers drop it and show a plain ask.
93
+ - `kind` is one of `ask`, `review`, `handoff`, `answer`.
94
+ - `project` is a share handle or repository id, or null for the team's project.
95
+ - The receiver takes it only for a project it shared with a team both machines are on. A project shared with another team is refused in the same words as one that is not shared.
96
+ - A share saved by a build from before shares kept their teams is open to every team of the receiver's until it is shared again.
97
+ - `room: {team, channel}` says where a team ask was sent. `team` is the team id. `channel` is the channel id, or null for `#general`.
98
+ - The receiver keeps the room only when both machines are on that team and, for a channel, both are in it. Otherwise it drops the room and shows a team ask with no channel.
99
+ - A room whose ids are malformed is refused as invalid params.
100
+ - Older peers drop the field.
101
+ - `bot` names one of the receiver's shared bots, the one the question is for.
102
+ - It is a bot name: 1 to 30 lowercase letters, digits or `-`, starting with a letter. Anything else is refused as invalid params.
103
+ - Only an `ask` to one teammate may carry it. With another kind, or with `team: true`, the message is refused as invalid params.
104
+ - Older peers drop the field. There the question reaches the person as a plain ask.
105
+
106
+ Task state mapping:
107
+
108
+ | ELU Code | A2A |
109
+ |---|---|
110
+ | waiting for the owner to accept | SUBMITTED |
111
+ | run in progress | WORKING |
112
+ | reply held for the owner's approval | WORKING, with ext metadata `{substate: "awaiting-owner"}` |
113
+ | reply sent | COMPLETED, with the reply as one Artifact (text part) |
114
+ | owner declined, or blocked by policy or caps | REJECTED, with a reason message |
115
+ | run failed | FAILED |
116
+ | asker cancelled | CANCELED |
117
+
118
+ ## Pictures
119
+
120
+ A message you type to a teammate or a channel takes along the pictures it names.
121
+
122
+ **What is sent**
123
+
124
+ - Write or drag a picture's path into the message box. A path with its spaces escaped (as a terminal writes a dropped file), a quoted path and a `~/` path all work.
125
+ - The path in the text becomes the picture's file name. The teammate never sees your folders.
126
+ - A file counts as a picture when its first bytes say PNG, JPEG, GIF or WebP. Its ending does not matter. Any other path stays the text it was. Nothing else in the file is checked, and it is sent unchanged: what a photo records about the place, the device and the time goes with it.
127
+ - Only a message you typed carries pictures. A model's draft (sent by hand or by itself), a review, the command line and the local A2A endpoint never attach a file, so a model cannot send a file out by naming its path.
128
+ - A path in text you pasted into the message box is a path you typed. The line that says how many pictures went (`with 2 pictures`) is shown after the message is sent.
129
+ - A picture in a folder that holds secrets (`~/.ssh`, `~/.aws`, `~/.config` and the rest of that list, and ELU Code's own state folder) is refused. `~/Library` is the one folder of the list that stays open, because screenshot tools save there. A hard link in another folder to a file in such a folder is not recognized, and is attached.
130
+
131
+ **Limits**
132
+
133
+ | | limit |
134
+ |---|---|
135
+ | one picture | 8 MB |
136
+ | pictures in one message | 4 |
137
+ | all pictures in one message | 16 MB |
138
+
139
+ Over a limit, the message is not sent, it stays in the message box, and one line says why, for example `shot.png is 14 MB; a picture can be up to 8 MB.`
140
+
141
+ **On the wire**
142
+
143
+ 1. The app you typed in copies each picture to `pictures/out/<set>/<n>` in the state folder. The daemon sends only from there, never from a path in a command.
144
+ 2. Before the message, the daemon sends each picture in pieces of 700 KiB with `elu.picture.put` `{id, seq, last, size, sha256, data}`. `data` is base64, so a piece stays under the 1 MiB frame. Pieces count toward the same request rate as everything else, and one message with pictures goes to a teammate at a time.
145
+ 3. The message then names each picture as an A2A file part by reference: `{url: "urn:elu-code:picture:<id>", filename, mediaType, metadata: {"urn:elu-code:a2a-ext:peer:v1": {size, sha256}}}`. A file part does not count toward the text limit.
146
+ 4. The Task that `SendMessage` returns says how many pictures were kept, in its extension metadata as `pictures`.
147
+
148
+ **On the receiving side**
149
+
150
+ - Pieces are taken only from a teammate who is not muted. Each picture is checked again: its first bytes, its size and its SHA-256.
151
+ - A finished picture waits in `pictures/in/<sender key>/` for the message that names it. One that waits longer than 10 minutes is removed.
152
+ - When the message is accepted, its pictures move to `pictures/kept/<sender key>/<task id>/1.png` and so on: a folder only you can read, names made here, never the sender's path. The sender's file name is kept as cleaned text. A kept file is known as a picture by its first bytes only; it is readable by you alone (0600) and ELU Code never runs it.
153
+ - In Chats, the row says how many pictures came and how large (`@kim asks you with 2 pictures (1.5 MB)`), and the opened message lists each name and size. The sender's row says `with 2 pictures`.
154
+ - A refused message keeps no pictures. Kept pictures are removed with the message's record.
155
+ - Caps, so a teammate cannot fill the disk: 32 MB or 16 pictures on their way from one teammate, 64 MB kept from one teammate, 256 MB kept in all. Over the first, the sender tries again later. Over the others, the text arrives without the pictures and the sender's row says so.
156
+
157
+ **When the question runs**
158
+
159
+ - The pictures are copied to a folder of their own under `elu-code-pictures` in the temporary directory. The folder is yours alone (0700) and holds only that question's pictures.
160
+ - A question run reads the shared project and, when the message came with pictures, that one folder. Nothing else changes about what a teammate's question can read.
161
+ - The pictures are named inside the teammate's data block, so the model treats them as the teammate's material and never as instructions.
162
+ - The copies are removed when the run ends. While it runs, another session of yours that can read the temporary directory could read them.
163
+ - If the copies cannot be made, the model is told the pictures' names and that it could not see them.
164
+ - A message you take on as your own session, in a mode you pick, gets the same copies as paths in the teammate's data, like a picture you name in your own session. Those copies stay in the temporary directory, as the copy of your own picture does.
165
+ - With another model than Claude, a teammate's message can only be taken on as your own session, so that is the one way its pictures reach that model.
166
+
167
+ **Older builds**
168
+
169
+ - A teammate on a build from before pictures answers `elu.picture.put` with "Method not found". The sender then sends the text alone, with the pictures' names in it, and the row says `@kim's ELU Code can't take pictures yet. The text went without them.`
170
+ - A message from such a build has no file part and is taken as before.
171
+
172
+ ## Roster
173
+
174
+ - **Ops.** `{teamId, id: sha256(canonical body), type, by: hexKey, at, body, sig}`. `sig` is ed25519 over the canonical JSON of everything except `sig`; canonical means sorted keys and no whitespace.
175
+ - **Types.**
176
+ - `create`: by the founder; body `{name}`. The teamId is the id of this op.
177
+ - `admit`: body `{key, name, role: "member"|"admin", invite: sha256(secret)}`.
178
+ - `remove`: body `{key}`.
179
+ - `role`: body `{key, role}`.
180
+ - `relays`: body `{keys}`.
181
+ - `leave`: by the member leaving.
182
+ - `channel`: by a member, with body `{id, name, members}`.
183
+ - `id` is 16 hex characters.
184
+ - `name` is 1 to 30 lowercase letters, digits, `-` or `_`. It starts with a letter or digit, and `general` is taken.
185
+ - `members` is a list of up to 20 distinct keys.
186
+ - **Merge.** A pushed op is stored only if the fold applies it. Ops over 10 minutes in the future, and every op from a key the receiver's log already shows removed or departed (whatever its `at`), are dropped. Two logs can then differ by refused ops; sync keeps offering them and the receiver keeps refusing.
187
+ - **Fold.**
188
+ - The log is a grow-only set. State is computed by sorting ops by `(at, id)` and applying them in that order.
189
+ - An op is applied only if, at that point in the fold, its signer holds the needed role: the founder for create; an admin for admit, remove, role and relays; anyone for their own leave; a member for a channel op, under the channel rules below.
190
+ - Removal wins: a removed key's later ops are ignored, and a removed key is never re-admitted with the same key. The founder cannot be removed.
191
+ - The fold returns `channels: [{id, name, members, createdBy, createdAt}]`, oldest first.
192
+ - **Channels.** A channel is a named group inside a team. `#general` is everyone on the team and needs no op.
193
+ - One `channel` op sets the whole channel: its name and its members. An op with no members deletes the channel.
194
+ - Any active member may create a channel that lists them.
195
+ - A channel's members and the team's admins may change it.
196
+ - Every key listed must be an active member of the team.
197
+ - No two channels share a name.
198
+ - A member who is removed from the team, or leaves it, drops out of its channels. A channel with nobody left is gone, and its name is free again.
199
+ - Limits: 50 channels per team, and 200 channel ops per signer. An op past a limit is not applied.
200
+ - A channel op syncs like every other op, so the people added get the channel from any teammate.
201
+ - A teammate on an older build refuses the op and sees no channels.
202
+ - **Sync.**
203
+ - On every connection, each side compares `{seq, head}`, where head is the sha256 of the sorted op ids.
204
+ - The side with ops the other lacks pushes them with `elu.roster.push`. The receiver validates every op and drops invalid ones.
205
+ - A team both are on that the other side's hello left out (it did not know this machine was on it yet) is synced too.
206
+ - New ops are also pushed to every connected teammate, including ops another process (the CLI) wrote to the log.
207
+ - **Invites.**
208
+ - The code is `elu-team:v1:` + base32 of `{teamId, inviter key, secret(32), expiresAt}`. It is single use and expires after 24 h by default.
209
+ - Only `sha256(secret)` is stored, in the inviter's `invites.json`.
210
+ - The joiner dials the inviter and calls `elu.join {teamId, secret, name}`. The inviter checks the hash, the expiry and that it is unused, appends `admit`, and returns the full log. Until then neither hello names the team, since the joiner is not on it.
211
+ - The inviter must be online for the join.
212
+
213
+ ## Status
214
+
215
+ - Status is on by default when a project is shared; `elu share --no-status` turns it off. While it is on, `elu.status` carries one row for each live task that edits the project (preparing, running, ready or waiting for input), at most 50 rows in all: `{repoId, handle, taskId, label, now, files}`. A session that only answers has no row.
216
+ - `label` is the first line of the task's request, up to 80 characters.
217
+ - `now` is what the task is doing: its state and who is at work (a role such as `lead`, a shared bot's `@name`, or `bot` for a bot that is not shared), up to 80 characters.
218
+ - `files` is up to 200 paths, relative to the project, of the files the task changed from the commit it started from, and of the new files Git does not ignore.
219
+ - The rows go only to connected teammates on a team the project is shared with, as its name does. A project with no `origin` remote has no `repoId` and sends no rows.
220
+ - Every 10 s the daemon builds the rows again and sends them when they changed.
221
+ - `repoId` is the sha256 of the normalised `origin` remote URL, so the same repository matches across machines.
222
+ - Overlap is computed locally: my live tasks' changed files against teammates' rows with the same repoId. No query is sent for it.
223
+ - Status is also sent on every connection, even with no rows, and a teammate's saved rows are shown only while they are connected.
224
+ - `elu.status` also carries `box`, the name of the machine, and `bots`, the bots its owner shares: `[{name, about}]`, at most 20. These two go to every connected teammate, also when no project is shared or status is off.
225
+ - The box name is the peer setting `box`. With none set it is the first label of the host name, without any character a box name cannot have and cut to 32 characters, or `box` when nothing is left.
226
+ - The daemon reads the box name and the bots file again on every tick. When either changes, every connected teammate gets a new `elu.status` on that tick.
227
+ - The receiver checks each field on its own. A box name that is not valid reads as none. A bot with a bad name, or an `about` that is not text, is dropped. An `about` is cut to one line of 120 characters.
228
+ - The daemon keeps what each teammate last said in `state.json`, so it is still known after a restart while they are offline.
229
+ - A machine on an older build reads only `rows` and sends neither field. It shows with no box name and no bots.
230
+
231
+ ## Tasks
232
+
233
+ A Task is a session's approved plan: what the owner agreed the model should do. For a project shared with a team, each machine tells the teammates on that team which Tasks its sessions are working on.
234
+
235
+ - **What is told.** For each session of the project, the Task whose work is going on (its newest approved one), and any Task finished within the last 24 hours. Each is `{repoId, handle, id, revision, title, description, goal, objectives, priority, severity, state (approved or done), startedBy (owner, schedule or handoff), who, subtasks: [{text, state}], met, approvedAt, doneAt, updatedAt}`. `who` is the model's name, a shared bot's `@name`, or `bot` for a bot that is not shared.
236
+ - **What is never told.** A Task that is only proposed or was rejected, the request and repository that approval starts, the chat, file contents, and any path of the machine. While status is on, a [status row](#status) does carry the first line of that request and the changed paths inside the project.
237
+ - **To whom.** Only a teammate on a team the project is shared with, as for its name and status rows. A project that is not shared tells nothing. A share with `status: false` tells nothing, and neither does a project with no `origin` remote, since it has no `repoId`.
238
+ - **When.** `elu.plans` carries the whole present list for that teammate. It is sent on connect, and within 5 s of a change (so at most one every 5 s to each teammate, well inside the 20 requests in 10 s a connection allows): an approval, a subtask's state, a finished Task, a new revision. A Task that ended, or whose share ended, is absent from the next list, and the receiver replaces what it had.
239
+ - **Size.** At most 30 Tasks and 512 KiB in one message; the oldest are left out first.
240
+ - **Receiving.** Every field is checked against the same lists and limits, control characters and terminal escapes are removed, unknown fields are dropped, and a Task that fails a check is dropped. The list is kept whole in `peer/plans/<key>.json` (mode 0600), at most 30 Tasks per teammate across all projects. At most 20 of one teammate's Tasks are shown in one project.
241
+ - **Where it counts.** A teammate's Tasks are used only in a project this machine also shares with a team that teammate is on. Tasks named for any other project are kept and never shown or used.
242
+ - **On the board.** In that project the Kanban tab shows a section under its four columns, `Teammates' Tasks N · read-only`: a row with each teammate's name and whether they are online, then one row for each of their Tasks with its title, priority · severity, state, subtasks done, who works on it, and how long ago it changed. Enter opens the whole Task to read. No key changes a teammate's Task or makes a card of it. A shared project with no `origin` remote shows one line there instead: its Tasks are not shown to teammates.
243
+ - **For the models.** Each turn of the owner's own lead, and of a bot, in that project is given a block headed "What teammates are working on in this project". One line of ours says it is information to avoid doing the same work twice, never a request. Each teammate's Tasks follow inside a peer-data block, the kind used for any teammate's text: title, state, subtasks done, priority, severity, who works on it, and the goal cut to 300 characters. No description. At most 8 teammates, 6 Tasks each and 6000 characters in all. A teammate's question running on this machine is given none of it, and neither is a reviewer. With peer mode off nobody is.
244
+ - **Ageing.** A teammate who is offline keeps their Tasks, marked as last heard, for 48 hours after they were last connected. They are deleted at once when that person is no longer on a team with this machine. A finished Task leaves 24 hours after it was done.
245
+ - **Older builds.** A machine says `plans: true` in its `elu.hello` when it reads Tasks. One that does not say so is sent no `elu.plans`, and sends none.
246
+
247
+ ## Local A2A endpoint
248
+
249
+ - Off by default. It listens on 127.0.0.1 on a random port; port and token are in `local-a2a.json` (0600).
250
+ - Every request needs `Authorization: Bearer <token>`. The Host must be `127.0.0.1:<port>` or `localhost:<port>`, and requests carrying an `Origin` header are refused.
251
+ - **Agent Card.** Served at `/.well-known/agent-card.json` with a JSONRPC interface. One skill per share, plus one skill per teammate.
252
+ - **Routing.**
253
+ - `SendMessage` at `/a2a` goes to my own agent: an owner-authorised request in the named project (`metadata.project`), queued, not read-only.
254
+ - `SendMessage` at `/a2a/peer/<name>` becomes an ask from me to that teammate.
255
+ - `GetTask` works for both, also after the conductor drops the request row (`local-tasks/`).
256
+ - A client `contextId` is kept (it becomes the ask's thread). A message with `taskId` gets UnsupportedOperation. `SendMessage` returns at once; poll `GetTask`.
257
+ - **Conformance.** Tested with the official `@a2a-js/sdk` 1.3.0 client (a dev dependency).
@@ -0,0 +1,285 @@
1
+ # Permissions
2
+
3
+ A turn runs in one of four profiles. You choose one of three modes when you start a
4
+ session; a turn that is not your own session's runs read only.
5
+
6
+ ## Modes
7
+
8
+ | | Read only | Ask for approval | Approve for me | Full access |
9
+ | --- | --- | --- | --- | --- |
10
+ | Who gets it | bots that review or answer, teammate questions, tasks saved without a mode | your session | your session | your session |
11
+ | Reads | the workspace; answer turns may read your home | without asking: the workspace, folders and, for answer turns, your home (below) | same | anything |
12
+ | Edits in the workspace | edit turns only | yes | yes | yes |
13
+ | Edits outside it | no | asks | a reviewer model decides; on Antigravity refused, and on Claude Code an answer turn's edit asks | yes |
14
+ | Shell inside the vendor sandbox | no | yes; Antigravity runs no sandbox, so every command asks | yes | yes, no sandbox |
15
+ | Shell outside it, network, web tools | no | asks | a reviewer model decides | yes |
16
+ | Secrets and ELU Code state | no | no | no | file tools denied; a shell can read |
17
+ | Never list (push, tag, merge, deploy, publish) | blocked | blocked; every escape asks | stand-in commands and, except on Codex, deny rules; a command that gets past them gets through if the reviewer allows it | stand-in commands, prompt text and, except on Codex, deny rules; no sandbox behind them |
18
+ | Runs in | worktree | worktree or a named branch | same | also here |
19
+ | Checks | sandboxed, no network, structural limits | same | no network; dependency files may change | network on; only secret paths and deleted tests block |
20
+
21
+ "Asks" is a live approval in your terminal where the vendor has a relay: Claude
22
+ Code, and Antigravity and Grok Build once the `relay` step of their one-time check
23
+ has passed on this machine. Elsewhere the action is denied and reported, and the
24
+ model returns `needs_input` naming what it needs. "A reviewer model decides" is the
25
+ vendor's own reviewer, such as Claude Code's auto mode.
26
+
27
+ A reply ELU Code cannot read is asked for once more. Claude Code is asked in the
28
+ same session, which has done the work already, and the chat says it was asked for
29
+ its reply again. Another vendor, or a Claude Code session that cannot be picked
30
+ up, starts once more for the same message. The chat then says it was started
31
+ twice, because anything the first one ran the second may have run again. A
32
+ `needs_input` reply that names what it needs in its summary and asks no question
33
+ is read as it is.
34
+
35
+ Read only has no shell unless the project's settings file names command prefixes
36
+ for the model (`roles.lead.commands` in `.elu-code/config.yaml`, for example
37
+ `npm test`). ELU Code never adds one by itself. The test and build scripts it
38
+ finds in a project run as checks after an edit, inside the check sandbox. A
39
+ settings file counts only after you have approved it on this machine: ELU Code
40
+ shows it and asks when it is new or changed. Until you approve a change, the
41
+ settings you approved last stay in use.
42
+
43
+ The workspace is the place you picked: a worktree on a new task branch, a
44
+ worktree with a named branch checked out, or, in Full access, the checkout
45
+ itself with its current branch and a dirty tree allowed. Folders are extra
46
+ directories in your home that the model may read this session, added with
47
+ `/permissions folders add <path>`; they are never a secret folder or ELU Code's
48
+ state. A folder that holds one of those is refused too, and a link counts as
49
+ where it leads: with `~/.config` linked to `~/dotfiles/config`, `~/dotfiles` is
50
+ refused. In Ask for approval and Approve for me a folder is read and never written:
51
+ Claude Code denies writes there to its file tools and to sandboxed commands, Codex gives
52
+ the folder read access only, and Antigravity denies writes there to its file tool. On
53
+ Claude Code and Antigravity a folder that holds the workspace or lies inside it, such as
54
+ the folder of all your projects (a worktree sits beside its project), gets no such deny,
55
+ because a deny there would refuse every edit in the workspace. Full access adds no rule
56
+ for folders. Answer turns have no workspace: they run in the project
57
+ folder, and in Ask and Auto a write there asks like any outside write.
58
+
59
+ In Ask for approval and Approve for me, answer turns read your whole home folder
60
+ without asking on Claude Code, Codex and Grok Build: your session's own, and a working
61
+ bot's when you write to it. Claude Code is given the home folder with `--add-dir` for
62
+ those turns; Codex reads anywhere in every turn; Grok Build allows `Read` and `Grep`
63
+ with no path. On Claude Code a command that stays in the sandbox also reads outside the
64
+ workspace without asking. Antigravity keeps its file tool to the workspace and folders.
65
+ The secret folders (`~/.ssh`, `~/.aws`, `~/.config`, `~/Library` and the rest) and ELU
66
+ Code's state stay closed, as the table says. Your session's answer turns, and a working
67
+ bot's until the session's work is published, also get a list of up to 60 Git
68
+ repositories found in your home, so the model can find the code you mean.
69
+
70
+ ## Which profile a turn runs with
71
+
72
+ In order: a bot that reviews or answers runs read only; a teammate's question or
73
+ review, or a read-only turn, runs read only; otherwise the session's mode. A task
74
+ saved without a mode runs read only.
75
+
76
+ A bot that works (one with a schedule, and `@lead`, `@qa` and each bot you make) runs in
77
+ the session's mode when you write to it, and so do the helpers it hands work to. On Claude
78
+ Code a working bot can start helpers when its instructions ask for them. They run on the
79
+ bot's own model unless the bot was saved with another for them, as `@lead` was, with the
80
+ tools and the denied commands of the bot's turn, and they start no helpers of their own.
81
+ A bot that is allowed to merge pull requests starts helpers only when it was saved with a
82
+ model for them. A turn its schedule started runs with Approve for me at most, because
83
+ nobody may be watching.
84
+
85
+ One entry of the never list can open: a bot that is allowed to merge pull requests
86
+ (`@elu` comes allowed; a bot you make is not) may run `gh pr merge` in its own working
87
+ turn on `claude`. Ask for approval still asks you before each merge. Push, tag,
88
+ `git merge`, deploy and publish stay blocked for that bot and for everyone else.
89
+
90
+ A longer command line that holds a never-list command is refused whole. The chat
91
+ then names the part, as in `Blocked: git tag is never allowed, in any mode`, and
92
+ does not point at `/permissions`. Each turn's instructions name the commands once
93
+ and say how to list tags without `git tag`: `git for-each-ref
94
+ --sort=-creatordate refs/tags` or `git describe --tags`.
95
+
96
+ A rule on the command's text does not see what a script runs, and Codex has no
97
+ deny rules. So every session with permissions also gets stand-in commands, on
98
+ every vendor; on Codex they alone hold the never list. A
99
+ private folder goes first on the session's PATH with one small script per command
100
+ word (`git`, `gh`, `npm` and the rest). Each refuses the never-list call by name,
101
+ in the same words, whatever runs it: the model, a script, `sh -c`, `npm test`.
102
+ The command is read as the tool reads it, so `git -C dir push` is refused and
103
+ `git merge-base` or `git stash push` is not. Every other call goes to the real
104
+ command with its arguments untouched; `git tag` alone and `git tag -l` only list
105
+ tags and pass. The chat shows the refusal as a Blocked line even when the turn
106
+ went on to answer.
107
+
108
+ Pushing to a folder on this machine and working in a scratch repository under the
109
+ temporary directory are not publishing, and a project's own tests do both, so the
110
+ stand-in for `git` lets them through:
111
+
112
+ - `git push` passes when every place it would push to is a path or a `file://` URL
113
+ on this machine. git itself is asked, so the remote's `pushurl`, every
114
+ `url.<base>.insteadOf` and `pushInsteadOf` rule and the remote of a push that
115
+ names none are read as git reads them. A destination on another machine, one git
116
+ cannot place, or an option the stand-in does not know (`--receive-pack`,
117
+ `--repo`, submodules pushed on demand) is refused before anything is sent.
118
+ - `git merge` and a `git tag` that makes or deletes a tag pass when the repository
119
+ they act on lies inside the system temporary directory: its top level and the
120
+ folder that holds its history, links resolved. Everywhere else they are refused.
121
+ A worktree under the temporary directory of a repository kept elsewhere is that
122
+ repository, and is refused.
123
+
124
+ The temporary directory is the one ELU Code itself was started with, and `/tmp`;
125
+ a session cannot name another by changing `TMPDIR`.
126
+
127
+ Stand-in commands are not a sandbox. A full path to the real command
128
+ (`/usr/bin/git push`), a changed PATH, a Git alias or another program that does the
129
+ same work gets past them, and a session in Full access can rewrite the folder; it
130
+ is written again when the next session starts. In Ask for approval and Approve for
131
+ me the vendor's sandbox, where it runs one, still stands behind them; Antigravity in
132
+ Ask for approval runs none, so every command asks and one you approve runs outside
133
+ any sandbox. In Full access nothing does.
134
+
135
+ ## Git in the sandbox
136
+
137
+ In Read only, Ask for approval and Approve for me a command in the sandbox reads the
138
+ repository's Git folder and writes nothing there. `git status`, `git diff`, `git log`
139
+ and `git show` work without asking. `git add`, `git commit`, `git stash` and the like
140
+ are refused in the sandbox.
141
+
142
+ What a sandboxed command can read that way:
143
+
144
+ - The repository's history through git: every committed version of every file. That
145
+ includes a file committed once and later removed, and committed files the sandbox
146
+ hides by name in the worktree (`.env*`, `*.pem`, `.npmrc`): `git show HEAD:.env`
147
+ prints one.
148
+ - The Git settings file: remote URLs, and a token if one is written into a URL.
149
+ - The stash and the reflog.
150
+
151
+ The sandbox has no network, so a sandboxed command cannot send any of it anywhere.
152
+ What it prints goes to the model, as all output does, and into the session's record.
153
+ ELU Code's own records in the folder (`.git/elu-code`) stay closed to commands and
154
+ to the file tools. A teammate's question reads none of the Git folder.
155
+
156
+ ## Approvals
157
+
158
+ Claude Code is started with ELU Code's approval relay as its permission prompt
159
+ tool: a small MCP server over stdio, started from the same private config file
160
+ as your own servers, with one tool. When Claude would prompt, the call becomes a
161
+ request under ELU Code's personal directory and the terminal shows it under
162
+ Needs you. Allow lets the action run; deny tells the model not to retry it
163
+ another way; no answer within 20 minutes counts as a denial. The role's time
164
+ limit waits while a request is open, and the conductor does not treat the wait
165
+ as a stall. Every decision is kept with the task, and blocked actions are kept
166
+ on the run.
167
+
168
+ The keys are `y` allow and `n` deny. Where the session can be given Full access
169
+ there is a third, `f`: it asks what `/permissions full` asks, then allows the
170
+ action and switches the session to Full access, so that session stops asking.
171
+ It is not offered for a bot's scheduled turn or a turn that runs read only.
172
+
173
+ ## Plan first
174
+
175
+ A message that asks for a change to code or files, or for work that needs planning, comes
176
+ back as a Task before anything is edited: a title, a description, the goal, the objectives
177
+ that say when it is done, a priority and a severity. You approve it with `y`, reject it
178
+ with `n`, or change it with a message. This holds in every mode, Full access included, for
179
+ the session's model and for a bot that works, such as `@lead`.
180
+
181
+ ELU Code enforces this much, on every vendor:
182
+
183
+ - The editing turn starts only for a Task you approved. That is the turn that runs in the
184
+ session's workspace and is followed by the checks and the review. A reply that asks for
185
+ an edit with no Task is asked for its Task once more. If none comes, the reply is kept
186
+ as a plain answer with a line that says no Task was read, and nothing starts.
187
+ - Only you approve, in your own session: with the key, or by typing `yes`. A teammate's
188
+ message, a schedule and a bot's own turn cannot approve a Task.
189
+ - A Task is read as untrusted text. The title is one line of up to 80 characters; the
190
+ description up to 2000; the goal up to 500; one to eight objectives of up to 300 each;
191
+ the priority is urgent, high, medium or low; the severity is critical, high, medium or
192
+ low. Control characters are refused, and markup is shown as typed.
193
+ - After approval the model reports its subtasks and the objectives it says are met. A
194
+ report cannot change the goal or an objective: those change only through a revised Task
195
+ that you approve again. Done on the card is the model's report. The checks and the
196
+ review are what verify the edit.
197
+
198
+ The rest is an instruction to the model, not a boundary. The model decides whether a
199
+ message needs a Task and whether a follow-up is inside a Task you already approved, and
200
+ it is told to change nothing in the turn that proposes one. That
201
+ first turn runs with the session's mode, in the project folder, so what stops an edit
202
+ there is the mode:
203
+
204
+ | First turn | Claude Code | Codex | Grok Build | Antigravity |
205
+ | --- | --- | --- | --- | --- |
206
+ | Read only | no edit tools | read-only sandbox | edits denied | edits denied |
207
+ | Ask for approval | an edit in the project folder asks you | read-only sandbox | an edit asks you, or is denied and reported | not fenced: it can edit in the project folder |
208
+ | Approve for me | an edit in the project folder asks you | read-only sandbox; Codex's reviewer model decides a request to leave it | Grok's reviewer model decides | not fenced |
209
+ | Full access | not fenced | not fenced | not fenced | not fenced |
210
+
211
+ Some turns wait for nobody:
212
+
213
+ - A session a schedule starts, and a bot's own scheduled turn, have nobody to approve.
214
+ The Task is recorded as started by schedule and the work goes on.
215
+ - A teammate's handoff you chose to work on with `w` records its Task as started by a
216
+ handoff and goes on.
217
+ - A teammate's question and any read-only turn change nothing and make no Task.
218
+ - `elu run "<goal>"` on the command line makes no Task.
219
+
220
+ ## Lifecycle
221
+
222
+ - New sessions start with your personal default, `ask` until you change it
223
+ with `/permissions default <mode>`; the place is a worktree.
224
+ - A follow-up inherits the session's mode, place and folders. Switching the
225
+ mode applies from the next turn and is recorded on the session; the pinned
226
+ policy does not change.
227
+ - A switch made while a turn runs is saved at once, and so are the spending
228
+ limit, the folders and the bots. The turn in progress keeps the mode it
229
+ started with, with one exception: after a switch to Full access, ELU Code
230
+ allows that turn's approvals for you, the one that waits and the ones it asks
231
+ next. Three kinds stay yours to answer: a removal with `rm` or `rmdir`, an
232
+ MCP tool, and a command too long to show whole. The never list is not asked,
233
+ so it stays refused. A bot's scheduled turn is not answered this way: it runs
234
+ with Approve for me at most.
235
+ - A turn that starts in Full access is never answered for you. What it still
236
+ asks, its vendor asks in every mode (Claude Code asks before `rm` or `rmdir`
237
+ on your home, the working folder or a top-level folder), and you answer it.
238
+ - The automatic edit task of a conversation inherits the conversation's choices.
239
+ - A schedule stores `ask` or `auto`; it never runs with Full access. An
240
+ unanswered approval times out and the task asks you.
241
+ - A request sent to the local A2A endpoint, which is off by default, runs your
242
+ personal default, capped at `auto`.
243
+ - A teammate's message you answer runs read only and never gets a mode. One you
244
+ choose to work on runs with the mode you pick then.
245
+ - `elu run` runs read only unless you pass `--permissions auto|full`,
246
+ with `--place here|branch:<name>` and `--folder <path>` (repeatable). `ask`
247
+ needs the terminal app.
248
+ - Publishing pushes the session's branch. A `here` session on a default branch
249
+ cannot publish. The publish preview says when the model ran with Full access.
250
+
251
+ ## Linux
252
+
253
+ Claude Code's shell sandbox on Linux is bubblewrap. It needs `bwrap` and `socat`
254
+ on PATH, and `bwrap` must be allowed to make user namespaces. Where the sandbox
255
+ does not start, the system fences nothing, so Ask for approval asks you for every
256
+ command and Approve for me sends every command to the reviewer.
257
+
258
+ The Permissions picker says which case this machine is: "No OS sandbox here" when
259
+ a tool is not installed, "The OS sandbox cannot start here" when both are installed
260
+ and `bwrap` cannot start. ELU Code finds out by starting `bwrap` once, around a
261
+ command that does nothing, when the picker first opens; start ELU Code again after
262
+ a fix.
263
+
264
+ Ubuntu 24.04 stops a program from making user namespaces unless an AppArmor
265
+ profile lets it, and has no profile for `bwrap`. This one does, saved as
266
+ `/etc/apparmor.d/bwrap`:
267
+
268
+ abi <abi/4.0>,
269
+ include <tunables/global>
270
+
271
+ profile bwrap /usr/bin/bwrap flags=(unconfined) {
272
+ userns,
273
+ include if exists <local/bwrap>
274
+ }
275
+
276
+ and loaded once with `sudo apparmor_parser -r /etc/apparmor.d/bwrap`. Nothing is
277
+ installed or changed for you.
278
+
279
+ The check sandbox (`codex sandbox`), which runs every project's checks whatever the
280
+ vendor, is bubblewrap on Linux too. The Linux tests run with `bubblewrap` installed
281
+ and an AppArmor profile like this one loaded.
282
+
283
+ ## Claude Code
284
+
285
+ See [vendors/claude.md](vendors/claude.md) for the exact launch per profile.