@elu-labs/elu 0.0.0-stage → 0.1.0

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 +327 -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 +268 -0
  93. package/lib/terminal.mjs +3159 -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,256 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { atomic, hash, isObject, mkdir, noSymlinks, refuse } from '../util.mjs';
5
+ import { waitLock } from '../state.mjs';
6
+ import { peerFile, peerRepo } from './paths.mjs';
7
+ import { canonical, sign, verify } from './canonical.mjs';
8
+ import { validName } from './identity.mjs';
9
+
10
+ // A team roster is a grow-only set of signed ops (docs/peer-network.md, "Roster").
11
+ //
12
+ // op = {teamId, id, type, by, at, body, sig}
13
+ // id = sha256 hex of canonical({teamId, type, by, at, body}); for create, teamId is
14
+ // the empty string inside this hashed object, and the team id is the resulting id
15
+ // (so the stored create op has teamId === id).
16
+ // sig = ed25519 by `by` over canonical({id, teamId, type, by, at, body}), i.e. the
17
+ // stored op without sig.
18
+ // at = integer milliseconds since the epoch. makeOp never goes below the newest op in
19
+ // the local log, so an op always sorts after the ops its author had seen.
20
+ //
21
+ // Bodies: create {name, founderName} (the founder's display name, which the spec's {name}
22
+ // lacks), admit {key, name, role, invite: sha256(secret)}, remove {key}, role {key, role},
23
+ // relays {keys}, leave {}, channel {id, name, members}.
24
+ //
25
+ // A channel is a named group inside the team. Its op sets the whole channel: the name and
26
+ // the member keys; no members deletes it. Any active member may create one they are in; a
27
+ // channel's members and the team's admins may change it. Every team also has #general,
28
+ // which is everyone and needs no op.
29
+ //
30
+ // fold() -> {id, name, founder, relays, members: [{key, name, role, removed, left, joinedAt}],
31
+ // channels: [{id, name, members: [key], createdBy, createdAt}], rejected: [{id, reason}]}. State is the fold of all valid ops sorted by (at, id). Each
32
+ // op applies only if its signer holds the needed role at that point: the founder for
33
+ // create, an active admin for admit/remove/role/relays, and any active non-founder member
34
+ // for their own leave. The founder's role is admin.
35
+ // Removal wins: a removed or departed key's later ops are ignored and the key is never
36
+ // admitted again. The founder cannot be removed, leave, or change role.
37
+ // Ordering trusts `at`, so merge() refuses every new op from a key this machine already
38
+ // saw removed or departed (a backdated op cannot bring it back), ops more than SKEW_MS in
39
+ // the future, and ops the fold would not apply. Logs on two machines can then differ by
40
+ // ops one of them refused; sync keeps offering them and the other keeps refusing.
41
+ export const TYPES = Object.freeze(['create', 'admit', 'remove', 'role', 'relays', 'leave', 'channel']);
42
+ export const CHANNEL_NAME = /^[a-z0-9][a-z0-9_-]{0,29}$/;
43
+ // A member can fill the log only this far: CHANNEL_CHANGES channel ops each, CHANNELS live channels a team.
44
+ const CHANNEL_ID = /^[0-9a-f]{16}$/, CHANNELS = 50, CHANNEL_CHANGES = 200, CHANNEL_MEMBERS = 20;
45
+ const HEX64 = /^[0-9a-f]{64}$/;
46
+ const MAX_OP = 4096, MAX_LINE = 8192, MAX_OPS = 10000, MAX_MERGE = 5000, MAX_LOG_BYTES = 16 * 1024 * 1024, SKEW_MS = 10 * 60000;
47
+ // Signature checks dominate reading a log, and teams() reads every log several times a
48
+ // tick. id binds every signed field but sig, so (id, sig) is checked once per process.
49
+ const verified = new Map();
50
+ function signed(op) {
51
+ const key = `${op.id}:${op.sig}`;
52
+ if (!verified.has(key)) { if (verified.size >= 50000) verified.clear(); verified.set(key, verify(op.by, unsigned(op), op.sig)); }
53
+ return verified.get(key);
54
+ }
55
+ const isKey = value => typeof value === 'string' && HEX64.test(value);
56
+ export function teamPath(teamId, ...parts) {
57
+ if (!isKey(teamId)) refuse('A team id must be 64 hex characters.');
58
+ return peerFile('teams', teamId, ...parts);
59
+ }
60
+ const keysOf = (value, names) => isObject(value) && Object.keys(value).sort().join() === [...names].sort().join();
61
+ const BODY = {
62
+ create: body => keysOf(body, ['name', 'founderName']) && validName(body.name, 60) && validName(body.founderName),
63
+ admit: body => keysOf(body, ['key', 'name', 'role', 'invite']) && isKey(body.key) && validName(body.name) && ['member', 'admin'].includes(body.role) && isKey(body.invite),
64
+ remove: body => keysOf(body, ['key']) && isKey(body.key),
65
+ role: body => keysOf(body, ['key', 'role']) && isKey(body.key) && ['member', 'admin'].includes(body.role),
66
+ relays: body => keysOf(body, ['keys']) && Array.isArray(body.keys) && body.keys.length <= 16 && body.keys.every(isKey) && new Set(body.keys).size === body.keys.length,
67
+ leave: body => keysOf(body, []),
68
+ channel: body => keysOf(body, ['id', 'name', 'members']) && typeof body.id === 'string' && CHANNEL_ID.test(body.id) && typeof body.name === 'string' && CHANNEL_NAME.test(body.name) && body.name !== 'general'
69
+ && Array.isArray(body.members) && body.members.length <= CHANNEL_MEMBERS && body.members.every(isKey) && new Set(body.members).size === body.members.length,
70
+ };
71
+ const unsigned = op => ({ id: op.id, teamId: op.teamId, type: op.type, by: op.by, at: op.at, body: op.body });
72
+ const opId = ({ teamId, type, by, at, body }) => hash(canonical({ teamId: type === 'create' ? '' : teamId, type, by, at, body }));
73
+ // Returns why an op is malformed, or null. Never throws, whatever it is given.
74
+ export function opProblem(op) {
75
+ try {
76
+ if (!keysOf(op, ['teamId', 'id', 'type', 'by', 'at', 'body', 'sig'])) return 'The op has missing or unknown fields.';
77
+ if (!isKey(op.teamId) || !isKey(op.id) || !isKey(op.by) || typeof op.sig !== 'string') return 'The op has a malformed id or key.';
78
+ if (!TYPES.includes(op.type)) return 'The op type is unknown.';
79
+ if (!Number.isSafeInteger(op.at) || op.at <= 0) return 'The op time is invalid.';
80
+ if (!BODY[op.type](op.body)) return `The ${op.type} op body is invalid.`;
81
+ if (canonical(op).length > MAX_OP) return 'The op is too large.';
82
+ if (opId(op) !== op.id || (op.type === 'create' && op.teamId !== op.id)) return 'The op id does not match its contents.';
83
+ if (!signed(op)) return 'The op signature is invalid.';
84
+ return null;
85
+ } catch { return 'The op is not valid JSON data.'; }
86
+ }
87
+ export function validateOp(op) { const reason = opProblem(op); if (reason) refuse(reason); return op; }
88
+ export function makeOp(identity, teamId, type, body) {
89
+ if (!TYPES.includes(type)) refuse('Unknown roster op.');
90
+ if (type !== 'create') teamPath(teamId);
91
+ // An op stamped far in the future (kept from before merge refused them) must not freeze the log.
92
+ const newest = type === 'create' ? 0 : readLog(teamId).ops.reduce((max, op) => op.at <= Date.now() + SKEW_MS ? Math.max(max, op.at) : max, 0);
93
+ const draft = { teamId: type === 'create' ? '' : teamId, type, by: identity.key, at: Math.max(Date.now(), newest + 1), body };
94
+ const id = opId(draft), op = { ...draft, id, teamId: type === 'create' ? id : teamId };
95
+ return validateOp({ ...op, sig: sign(identity.seed, unsigned(op)) });
96
+ }
97
+
98
+ const order = (a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
99
+ // Folds any list of ops into team state. Bad and unauthorised ops land in `rejected`;
100
+ // duplicates are ignored. With teamId, only that team's create op can start the fold.
101
+ export function fold(ops, teamId = null) {
102
+ const rejected = [], seen = new Set(), valid = [];
103
+ for (const op of Array.isArray(ops) ? ops : []) {
104
+ const reason = opProblem(op);
105
+ if (reason) rejected.push({ id: isKey(op?.id) ? op.id : null, reason });
106
+ else if (!seen.has(op.id)) { seen.add(op.id); valid.push(op); }
107
+ }
108
+ const state = { id: null, name: null, founder: null, relays: [] }, members = new Map(), gone = new Set(), channels = new Map(), changes = new Map();
109
+ const active = key => members.has(key) && !members.get(key).removed;
110
+ const admin = key => active(key) && members.get(key).role === 'admin';
111
+ // Someone who leaves the team leaves its channels then; an emptied channel frees its name and its place among CHANNELS.
112
+ const quit = key => { for (const row of [...channels.values()]) { row.members = row.members.filter(k => k !== key); if (!row.members.length) channels.delete(row.id); } };
113
+ const apply = op => {
114
+ if (op.type === 'create') {
115
+ if (teamId && op.id !== teamId) return 'The op belongs to another team.';
116
+ if (state.id) return 'The team already exists.';
117
+ Object.assign(state, { id: op.id, name: op.body.name, founder: op.by });
118
+ members.set(op.by, { key: op.by, name: op.body.founderName, role: 'admin', removed: false, left: false, joinedAt: op.at }); return null;
119
+ }
120
+ if (!state.id) return 'The team does not exist yet.';
121
+ if (op.teamId !== state.id) return 'The op belongs to another team.';
122
+ if (gone.has(op.by)) return 'The signer was removed from the team.';
123
+ if (!active(op.by)) return 'The signer is not a member of the team.';
124
+ if (op.type === 'leave') {
125
+ if (op.by === state.founder) return 'The founder cannot leave the team.';
126
+ Object.assign(members.get(op.by), { removed: true, left: true }); gone.add(op.by); quit(op.by); return null;
127
+ }
128
+ if (op.type === 'channel') {
129
+ const { id, name, members: wanted } = op.body, current = channels.get(id), count = (changes.get(op.by) ?? 0) + 1;
130
+ if (count > CHANNEL_CHANGES) return 'The signer changed channels too many times.';
131
+ if (wanted.some(key => !active(key))) return 'A channel member is not on the team.';
132
+ if (!current) {
133
+ if (!wanted.includes(op.by)) return 'A new channel needs its creator as a member.';
134
+ if (channels.size >= CHANNELS) return 'The team has too many channels.';
135
+ } else if (!current.members.includes(op.by) && !admin(op.by)) return 'Only a channel member can change it.';
136
+ if (wanted.length && [...channels.values()].some(row => row.id !== id && row.name === name)) return 'A channel with that name exists.';
137
+ changes.set(op.by, count);
138
+ if (wanted.length) channels.set(id, { id, name, members: [...wanted], createdBy: current?.createdBy ?? op.by, createdAt: current?.createdAt ?? op.at });
139
+ else channels.delete(id);
140
+ return null;
141
+ }
142
+ if (!admin(op.by)) return 'The signer is not an admin.';
143
+ const { key } = op.body;
144
+ if (op.type === 'admit') {
145
+ if (gone.has(key)) return 'A removed key cannot be admitted again.';
146
+ if (active(key)) return 'The key is already a member.';
147
+ members.set(key, { key, name: op.body.name, role: op.body.role, removed: false, left: false, joinedAt: op.at }); return null;
148
+ }
149
+ if (op.type === 'relays') { state.relays = [...op.body.keys]; return null; }
150
+ if (key === state.founder) return `The founder cannot be ${op.type === 'remove' ? 'removed' : 'given another role'}.`;
151
+ if (!active(key)) return 'The key is not a member of the team.';
152
+ if (op.type === 'remove') { Object.assign(members.get(key), { removed: true }); gone.add(key); quit(key); return null; }
153
+ members.get(key).role = op.body.role; return null;
154
+ };
155
+ for (const op of valid.sort(order)) { const reason = apply(op); if (reason) rejected.push({ id: op.id, reason }); }
156
+ const list = [...members.values()].sort((a, b) => a.joinedAt - b.joinedAt || (a.key < b.key ? -1 : 1)).map(m => ({ ...m }));
157
+ // Someone removed from the team is out of its channels; a channel nobody is left in is gone.
158
+ const rooms = [...channels.values()].map(row => ({ ...row, members: row.members.filter(active) })).filter(row => row.members.length).sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1));
159
+ return { ...state, members: list, channels: rooms, rejected };
160
+ }
161
+
162
+ const logFile = teamId => teamPath(teamId, 'log.jsonl');
163
+ // Reads a log, skipping (and reporting) lines that are not valid ops for this team.
164
+ export function readLog(teamId) {
165
+ const file = logFile(teamId); noSymlinks(file);
166
+ if (!fs.existsSync(file)) return { ops: [], bad: [] };
167
+ if (fs.statSync(file).size > MAX_LOG_BYTES) return { ops: [], bad: [{ line: 0, reason: 'The roster log is too large.' }] };
168
+ const ops = [], bad = [], seen = new Set();
169
+ fs.readFileSync(file, 'utf8').split('\n').forEach((text, index) => {
170
+ if (!text.trim()) return;
171
+ const line = index + 1;
172
+ if (text.length > MAX_LINE) return bad.push({ line, reason: 'The line is too long.' });
173
+ let op; try { op = JSON.parse(text); } catch { return bad.push({ line, reason: 'The line is not JSON.' }); }
174
+ const reason = opProblem(op) ?? (op.teamId !== teamId ? 'The op belongs to another team.' : null);
175
+ if (reason) return bad.push({ line, reason });
176
+ if (ops.length >= MAX_OPS) return bad.push({ line, reason: 'The roster log is full.' });
177
+ if (!seen.has(op.id)) { seen.add(op.id); ops.push(op); }
178
+ });
179
+ return { ops, bad };
180
+ }
181
+ export function team(teamId) {
182
+ const { ops, bad } = readLog(teamId);
183
+ if (!ops.length) return null;
184
+ const state = fold(ops, teamId); return state.id ? { ...state, bad } : null;
185
+ }
186
+ export function teams() {
187
+ const dir = peerFile('teams'); noSymlinks(dir);
188
+ if (!fs.existsSync(dir)) return [];
189
+ return fs.readdirSync(dir).filter(name => HEX64.test(name)).sort().flatMap(id => { try { const found = team(id); return found ? [found] : []; } catch { return []; } });
190
+ }
191
+ export function head(teamId) {
192
+ const ids = readLog(teamId).ops.map(op => op.id).sort();
193
+ return { seq: ids.length, head: hash(ids.join('\n')) };
194
+ }
195
+ function locked(teamId, fn) { teamPath(teamId); const release = waitLock(peerRepo(), `team-${teamId}`); try { return fn(); } finally { release(); } }
196
+ function write(teamId, ops) {
197
+ if (ops.length > MAX_OPS) refuse('The roster log is full.');
198
+ mkdir(path.dirname(logFile(teamId)));
199
+ atomic(logFile(teamId), ops.map(op => `${JSON.stringify(op)}\n`).join(''));
200
+ }
201
+ // Adds one local op. It must apply on top of the current log, or nothing is written.
202
+ export function appendOp(teamId, op) {
203
+ return locked(teamId, () => {
204
+ validateOp(op);
205
+ if (op.teamId !== teamId) refuse('The op belongs to another team.');
206
+ const { ops } = readLog(teamId);
207
+ if (ops.some(x => x.id === op.id)) return { added: false, op };
208
+ const problem = fold([...ops, op], teamId).rejected.find(x => x.id === op.id);
209
+ if (problem) refuse(problem.reason);
210
+ write(teamId, [...ops, op]); return { added: true, op };
211
+ });
212
+ }
213
+ // Merges ops from a teammate. Only ops the fold applies are written: malformed ops, other
214
+ // teams' ops, ops by keys that were never members, ops their signer had no authority for,
215
+ // ops stamped over SKEW_MS ahead, and any op by a key this log already shows removed or
216
+ // departed are dropped. So a plain member can add nothing but their own leave and channel ops.
217
+ export function merge(teamId, incoming, now = Date.now()) {
218
+ if (!Array.isArray(incoming)) refuse('Roster ops must be a list.');
219
+ return locked(teamId, () => {
220
+ const { ops } = readLog(teamId), have = new Set(ops.map(op => op.id)), rejected = [], fresh = [];
221
+ const gone = new Set(ops.length ? fold(ops, teamId).members.filter(m => m.removed).map(m => m.key) : []);
222
+ incoming.forEach((op, index) => {
223
+ const reason = index >= MAX_MERGE ? 'Too many ops in one push.' : opProblem(op) ?? (op.teamId !== teamId ? 'The op belongs to another team.' : op.at > now + SKEW_MS ? 'The op time is in the future.' : gone.has(op.by) ? 'The signer was removed from the team.' : null);
224
+ if (reason) rejected.push({ id: isKey(op?.id) ? op.id : null, reason });
225
+ else if (!have.has(op.id)) { have.add(op.id); fresh.push(op); }
226
+ });
227
+ const refused = new Map(fold([...ops, ...fresh], teamId).rejected.map(x => [x.id, x.reason])), added = [];
228
+ for (const op of fresh) {
229
+ if (refused.has(op.id)) rejected.push({ id: op.id, reason: refused.get(op.id) });
230
+ else added.push(op);
231
+ }
232
+ if (added.length) write(teamId, [...ops, ...added]);
233
+ return { added, rejected };
234
+ });
235
+ }
236
+ export function opsMissing(teamId, theirIds) {
237
+ if (!Array.isArray(theirIds) || theirIds.length > MAX_OPS * 2) refuse('Roster ids must be a list.');
238
+ const theirs = new Set(theirIds.filter(isKey));
239
+ return readLog(teamId).ops.filter(op => !theirs.has(op.id));
240
+ }
241
+ // Creates a channel (no id), or sets the name and members of one. No members deletes it.
242
+ export function setChannel(identity, teamId, { id = null, name = null, members }) {
243
+ const state = team(teamId); if (!state) refuse('Unknown team.');
244
+ const current = id ? state.channels.find(row => row.id === id) : null;
245
+ if (id && !current) refuse('That channel no longer exists.');
246
+ const label = String(name ?? current?.name ?? '').replace(/^#/, '');
247
+ if (!CHANNEL_NAME.test(label) || label === 'general') refuse('Name a channel with 1 to 30 lowercase letters, digits, - or _. general is taken.');
248
+ if (!Array.isArray(members) || members.length > CHANNEL_MEMBERS || !members.every(isKey)) refuse(`A channel holds up to ${CHANNEL_MEMBERS} teammates.`);
249
+ const op = makeOp(identity, teamId, 'channel', { id: id ?? crypto.randomBytes(8).toString('hex'), name: label, members: [...new Set(members)] });
250
+ appendOp(teamId, op); return { op, id: op.body.id };
251
+ }
252
+ export function createTeam(identity, name) {
253
+ if (!validName(name, 60)) refuse('Choose a team name of 1 to 60 printable characters.');
254
+ const op = makeOp(identity, '', 'create', { name, founderName: identity.name });
255
+ appendOp(op.id, op); return team(op.id);
256
+ }
@@ -0,0 +1,88 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { EluCodeError, isObject } from '../util.mjs';
3
+ import { A2A_VERSION, BINDING, createRpcHandler, rpcError } from './a2a.mjs';
4
+ import { frames, writeFrame } from './transport.mjs';
5
+
6
+ // One JSON-RPC 2.0 session per connection, both directions (docs/peer-network.md,
7
+ // "Binding"). The first frame each way is the elu.hello notification; without a valid
8
+ // one within helloMs the connection closes. admit(hello, session) then returns
9
+ // 'member', 'join' (only elu.join may follow) or a falsy value (closed).
10
+ export const HELLO_MS = 5000;
11
+ const HEX64 = /^[0-9a-f]{64}$/;
12
+ // Any 1.x A2A version is accepted; minor versions only add fields, which are ignored.
13
+ const validHello = value => isObject(value) && value.binding === BINDING && typeof value.a2aVersion === 'string' && /^1\.\d{1,4}$/.test(value.a2aVersion) && typeof value.name === 'string' && value.name.length <= 64
14
+ && Array.isArray(value.teams) && value.teams.length <= 32 && value.teams.every(team => isObject(team) && HEX64.test(team.id ?? '') && Number.isSafeInteger(team.seq) && HEX64.test(team.head ?? ''));
15
+
16
+ export class Session extends EventEmitter {
17
+ constructor(socket, { key, hello, admit, methods = {}, onError, helloMs = HELLO_MS, limit = 20, windowMs = 10000, log = () => {} }) {
18
+ super();
19
+ Object.assign(this, { socket, key, admit, log, limit, windowMs, state: 'hello', pending: new Map(), nextId: 1, hits: [], remoteHello: null });
20
+ this.handle = createRpcHandler(methods, { onError });
21
+ this.timer = setTimeout(() => this.close('no hello'), helloMs);
22
+ frames(socket, frame => this.frame(frame), reason => this.close(reason));
23
+ socket.on('close', () => this.closed());
24
+ socket.on('error', () => {});
25
+ this.send({ jsonrpc: '2.0', method: 'elu.hello', params: { binding: BINDING, a2aVersion: A2A_VERSION, ...hello() } });
26
+ }
27
+ get admitted() { return this.state === 'member'; }
28
+ send(value) { try { writeFrame(this.socket, value); } catch (error) { this.log(`send failed: ${error.message}`); } }
29
+ close(reason) {
30
+ if (this.state === 'closed') return;
31
+ if (reason) this.log(`closed ${this.key.slice(0, 8)}: ${reason}`);
32
+ this.closed(); this.socket.destroy();
33
+ }
34
+ closed() {
35
+ if (this.state === 'closed') return;
36
+ this.state = 'closed'; clearTimeout(this.timer);
37
+ for (const { reject, timer } of this.pending.values()) { clearTimeout(timer); reject(new EluCodeError('The teammate disconnected.')); }
38
+ this.pending.clear(); this.emit('close');
39
+ }
40
+ request(method, params, { timeoutMs = 30000 } = {}) {
41
+ if (this.state === 'closed') return Promise.reject(new EluCodeError('The teammate is not connected.'));
42
+ const id = this.nextId++;
43
+ return new Promise((resolve, reject) => {
44
+ const timer = setTimeout(() => { this.pending.delete(id); reject(new EluCodeError('The teammate did not answer in time.', { timeout: true })); }, timeoutMs);
45
+ this.pending.set(id, { resolve, reject, timer });
46
+ this.send({ jsonrpc: '2.0', id, method, params });
47
+ });
48
+ }
49
+ notify(method, params) { if (this.state !== 'closed') this.send({ jsonrpc: '2.0', method, params }); }
50
+ // Marks a session admitted after a join or a roster change.
51
+ promote() { if (this.state !== 'closed' && this.state !== 'member') { clearTimeout(this.timer); this.state = 'member'; this.emit('admitted'); } }
52
+ frame(frame) {
53
+ if (this.state === 'closed') return;
54
+ if (!isObject(frame) || frame.jsonrpc !== '2.0') return this.close('not JSON-RPC 2.0');
55
+ if (this.state === 'hello') {
56
+ if (frame.method !== 'elu.hello' || Object.hasOwn(frame, 'id') || !validHello(frame.params)) return this.close(typeof frame.params?.a2aVersion === 'string' && !/^1\./.test(frame.params.a2aVersion) ? `unsupported A2A version ${JSON.stringify(frame.params.a2aVersion.slice(0, 16))}` : 'no valid hello');
57
+ clearTimeout(this.timer); this.remoteHello = frame.params;
58
+ let decision; try { decision = this.admit(frame.params, this); } catch (error) { this.log(`admission failed: ${error.message}`); }
59
+ this.log(`admission ${this.key.slice(0, 8)}: ${decision || 'refused'}`);
60
+ if (decision === 'member') { this.state = 'member'; this.emit('admitted'); return; }
61
+ if (decision === 'join') { this.state = 'join'; this.timer = setTimeout(() => this.close('no join'), 30000); return; }
62
+ return this.close('not a teammate');
63
+ }
64
+ if (Object.hasOwn(frame, 'method')) {
65
+ if (frame.method === 'elu.hello') return this.close('repeated hello');
66
+ // A machine that only came to join may send elu.join once, and nothing else.
67
+ if (this.state === 'join' && (frame.method !== 'elu.join' || this.joinTried)) return this.close('not a teammate');
68
+ if (this.state === 'join') this.joinTried = true;
69
+ const now = Date.now(); this.hits = this.hits.filter(at => now - at < this.windowMs); this.hits.push(now);
70
+ if (this.hits.length > this.limit * 2) return this.close('too many requests');
71
+ if (this.hits.length > this.limit) {
72
+ if (Object.hasOwn(frame, 'id')) this.send(rpcError(frame.id ?? null, -32000, 'Too many requests. Try again shortly.'));
73
+ return;
74
+ }
75
+ this.handle(frame, this).then(response => {
76
+ if (response) this.send(response);
77
+ // The join handler sets joinAccepted; the joiner is admitted once its reply is out.
78
+ if (this.state === 'join') { if (this.joinAccepted) this.promote(); else this.close('join finished without admission'); }
79
+ }, error => this.log(`handler failed: ${error.message}`));
80
+ return;
81
+ }
82
+ if (!Object.hasOwn(frame, 'id')) return this.close('frame without method or id');
83
+ const waiting = this.pending.get(frame.id); if (!waiting) return;
84
+ this.pending.delete(frame.id); clearTimeout(waiting.timer);
85
+ if (isObject(frame.error)) waiting.reject(new EluCodeError(typeof frame.error.message === 'string' ? frame.error.message.slice(0, 300) : 'The teammate refused.', { rpc: { code: frame.error.code, data: frame.error.data } }));
86
+ else waiting.resolve(frame.result);
87
+ }
88
+ }
@@ -0,0 +1,88 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { within } from '../util.mjs';
6
+
7
+ // Patterns for credentials that must never leave this machine in a reply. Order matters:
8
+ // when two matches overlap, the earlier, more specific kind is reported.
9
+ const VALUE = `(?!(?:process\\.env|os\\.environ|ENV\\b|getenv|null|undefined|true|false|none|changeme|example|your[-_]|xxx|\\*{3}|<|\\$|\\{))[^\\s"'\`;,)]{6,}`;
10
+ const PATTERNS = [
11
+ ['private-key', /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/g],
12
+ ['aws-access-key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g],
13
+ ['github-token', /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})\b/g],
14
+ ['anthropic-key', /\bsk-ant-[A-Za-z0-9_-]{20,}/g],
15
+ ['openai-key', /\bsk-(?:proj-)?(?=[A-Za-z0-9_-]*[0-9])[A-Za-z0-9_-]{20,}/g],
16
+ ['slack-token', /\bxox[abpr]-[A-Za-z0-9-]{10,}/g],
17
+ ['google-api-key', /\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g],
18
+ ['stripe-key', /\b(?:sk|rk)_live_[0-9A-Za-z]{16,}/g],
19
+ ['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
20
+ ['npm-token', /\bnpm_[A-Za-z0-9]{36}(?![A-Za-z0-9])/g],
21
+ ['url-credentials', /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@'"`]+:[^\s/@'"`]+@/gi],
22
+ // KEY=value: an upper-case variable name may take a bare value; any other name needs a quoted one.
23
+ ['assignment', new RegExp(`\\b[A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API_?KEY|PRIVATE_?KEY|ACCESS_?KEY)\\s*[:=]\\s*["']?${VALUE}`, 'g')],
24
+ ['assignment', new RegExp(`\\b[A-Za-z0-9_]*(?:password|passwd|secret|token|api_?key|private_?key|access_?key)\\s*[:=]\\s*["']${VALUE}`, 'gi')],
25
+ ];
26
+ // "=" only as base64 padding, so in KEY=value the value is judged on its own.
27
+ const TOKEN = /[A-Za-z0-9+/_-]{32,}={0,2}/g;
28
+ const HARMLESS = [/^[0-9a-f]{40}$/i, /^[0-9a-f]{64}$/i, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i];
29
+ export function entropy(text) {
30
+ const counts = new Map(); for (const char of text) counts.set(char, (counts.get(char) ?? 0) + 1);
31
+ let bits = 0; for (const count of counts.values()) { const p = count / text.length; bits -= p * Math.log2(p); }
32
+ return bits;
33
+ }
34
+ // A generic token must look random: over 4 bits per character (which hex can never reach)
35
+ // and switching between lowercase, uppercase, digits and symbols on at least 40% of
36
+ // adjacent characters, with lowercase runs under 3.5 characters on average. Random base64
37
+ // switches on about 64% with runs near 1.7; camelCase names, words and paths have long
38
+ // lowercase runs and switch far less, so they are not flagged.
39
+ const charClass = char => /[a-z]/.test(char) ? 0 : /[A-Z]/.test(char) ? 1 : /[0-9]/.test(char) ? 2 : 3;
40
+ function switches(token) {
41
+ let count = 0; for (let i = 1; i < token.length; i++) if (charClass(token[i]) !== charClass(token[i - 1])) count++;
42
+ return count / (token.length - 1);
43
+ }
44
+ function lowerRun(token) { const runs = token.match(/[a-z]+/g) ?? []; return runs.length ? runs.join('').length / runs.length : 0; }
45
+ const randomLooking = token => !HARMLESS.some(re => re.test(token)) && entropy(token) > 4 && switches(token) >= 0.4 && lowerRun(token) < 3.5;
46
+ export function scanSecrets(text) {
47
+ const value = String(text ?? ''), found = [];
48
+ const overlaps = (start, end) => found.some(x => start < x.end && end > x.index);
49
+ for (const [kind, re] of PATTERNS) {
50
+ for (const m of value.matchAll(re)) if (!overlaps(m.index, m.index + m[0].length)) found.push({ kind, index: m.index, end: m.index + m[0].length });
51
+ }
52
+ for (const m of value.matchAll(TOKEN)) {
53
+ if (!overlaps(m.index, m.index + m[0].length) && randomLooking(m[0])) found.push({ kind: 'high-entropy', index: m.index, end: m.index + m[0].length });
54
+ }
55
+ return found.sort((a, b) => a.index - b.index).map(({ kind, index }) => ({ kind, index }));
56
+ }
57
+ // Absolute paths of two or more segments that are not part of a URL or a relative path.
58
+ const ABSOLUTE = /(?<![\w:/.~\u0000-])\/[\w.@+-]+(?:\/[\w.@+-]+)+\/?/g;
59
+ const HOME_PATH = /(?<![\w:/.~\u0000-])~\/[\w.@+-]+(?:\/[\w.@+-]+)*\/?/g;
60
+ function spellings(root) {
61
+ if (!root) return [];
62
+ const resolved = path.resolve(root); let real = resolved;
63
+ try { real = fs.realpathSync(resolved); } catch { /* A missing root is matched as given. */ }
64
+ return [...new Set([resolved, real])].filter(x => x !== path.parse(x).root).sort((a, b) => b.length - a.length);
65
+ }
66
+ export function redactPaths(text, { root } = {}) {
67
+ const home = os.homedir(), roots = spellings(root), mark = `\u0000${crypto.randomBytes(6).toString('hex')}\u0000`;
68
+ let value = String(text ?? '').replaceAll('\u0000', '');
69
+ // The root may contain spaces, which the path pattern stops at, so it is masked first.
70
+ for (const r of roots) value = value.replace(new RegExp(`${r.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w.@+-])`, 'g'), mark);
71
+ value = value.replace(ABSOLUTE, match => {
72
+ const found = match.replace(/\.+$/, ''), tail = match.slice(found.length);
73
+ return (roots.some(r => within(r, found.replace(/\/$/, ''))) ? found : '[path]') + tail;
74
+ });
75
+ // A ~/ path in the text names a place in the owner's home, never the masked repository.
76
+ value = value.replace(HOME_PATH, match => `[path]${match.slice(match.replace(/\.+$/, '').length)}`);
77
+ if (roots.length) value = value.replaceAll(mark, path.resolve(root));
78
+ return home && home !== '/' ? value.replaceAll(home, '~') : value;
79
+ }
80
+ // trusted: the text goes as written, paths and all; the secret scan still
81
+ // decides ok. pathsCut says a path became [path], so the owner can learn why a teammate
82
+ // could not find a file.
83
+ export function prepareOutgoing(text, { root, trusted = false } = {}) {
84
+ const problems = scanSecrets(text), value = String(text ?? '');
85
+ if (trusted) return { ok: problems.length === 0, text: value, problems, pathsCut: false };
86
+ const redacted = redactPaths(value, { root });
87
+ return { ok: problems.length === 0, text: redacted, problems, pathsCut: redacted.split('[path]').length > value.split('[path]').length };
88
+ }
@@ -0,0 +1,45 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import { atomic, readJson, isObject, refuse } from '../util.mjs';
4
+ import { peerFile } from './paths.mjs';
5
+
6
+ export const DEFAULT_PEER_SETTINGS = Object.freeze({
7
+ enabled: false, bootstrap: null, relays: [], peerSlots: 1, localA2A: false, box: null,
8
+ caps: Object.freeze({ threadMessages: 8, hops: 2, threadUsd: 1, peerRunsPerDay: 20, peerUsdPerDay: 5, teamUsdPerDay: 10, runUsd: 0.5 }),
9
+ });
10
+ // holdReplies: I read each reply to this teammate before it goes. Off, a reply goes back into
11
+ // the thread when the run ends, unless the secret scan holds it. trusted: my
12
+ // text to them keeps its paths and their text shows as sent; the secret scan still applies.
13
+ // Trust is mine about them, never sent over the wire and never implied by a team role.
14
+ export const DEFAULT_PEER_POLICY = Object.freeze({ autoAccept: false, holdReplies: false, autoAsk: false, autoContinue: false, muted: false, trusted: false });
15
+ // autoSend (replies held unless it was on) became holdReplies; saved copies are dropped.
16
+ const policyOf = value => { const { autoSend, ...rest } = isObject(value) ? value : {}; return rest; };
17
+ const read = (name, fallback) => { const file = peerFile(name); try { return fs.existsSync(file) ? readJson(file) : fallback; } catch { return fallback; } };
18
+ export function peerSettings() {
19
+ const saved = read('settings.json', {});
20
+ return { ...DEFAULT_PEER_SETTINGS, ...saved, caps: { ...DEFAULT_PEER_SETTINGS.caps, ...saved.caps } };
21
+ }
22
+ export function savePeerSettings(update) {
23
+ if (!isObject(update)) refuse('Peer settings must be an object.');
24
+ const current = peerSettings(), next = { ...current, ...update, caps: { ...current.caps, ...update.caps } };
25
+ if (!Number.isInteger(next.peerSlots) || next.peerSlots < 0 || next.peerSlots > 16) refuse('Peer slots must be 0 to 16.');
26
+ if (update.box != null && !validBox(update.box)) refuse('Name this machine with 1 to 32 letters, digits, dots, - or _.');
27
+ next.box ??= null;
28
+ atomic(peerFile('settings.json'), next); return next;
29
+ }
30
+ // box: what this machine is called, told to teammates beside its owner's name. null is the
31
+ // default: the first label of the host name, without any character a box name cannot have.
32
+ export const BOX = /^[A-Za-z0-9._-]{1,32}$/;
33
+ const validBox = value => typeof value === 'string' && BOX.test(value);
34
+ const hostName = () => { try { return os.hostname(); } catch { return ''; } };
35
+ export const defaultBox = (host = hostName()) => String(host ?? '').split('.')[0].replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32) || 'box';
36
+ // The box name in use. A saved name that is no longer valid counts as none.
37
+ export function peerBox() { const { box } = peerSettings(); return validBox(box) ? box : defaultBox(); }
38
+ export function peerPolicy(key) { return { ...DEFAULT_PEER_POLICY, ...policyOf(read('peers.json', {})[key]) }; }
39
+ export function savePeerPolicy(key, update) {
40
+ if (!/^[0-9a-f]{64}$/.test(key)) refuse('Choose a peer by its key.');
41
+ const all = read('peers.json', {}), next = { ...DEFAULT_PEER_POLICY, ...policyOf(all[key]), ...update };
42
+ for (const name of Object.keys(next)) if (!(name in DEFAULT_PEER_POLICY)) refuse(`Unknown teammate setting: ${name}.`);
43
+ for (const name of Object.keys(DEFAULT_PEER_POLICY)) if (typeof next[name] !== 'boolean') refuse(`${name} must be true or false.`);
44
+ atomic(peerFile('peers.json'), { ...all, [key]: next }); return next;
45
+ }
@@ -0,0 +1,120 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { atomic, hash, isObject, noSymlinks, readJson, refuse, within } from '../util.mjs';
5
+ import { waitLock } from '../state.mjs';
6
+ import { git } from '../git.mjs';
7
+ import { registeredProjects } from '../projects.mjs';
8
+ import { peerFile, peerRepo } from './paths.mjs';
9
+
10
+ // A share lets teammates ask about one registered project by its handle. repoId lets two
11
+ // machines recognise the same repository without sending paths.
12
+ const HANDLE = /^[a-z0-9][a-z0-9-]{0,39}$/;
13
+ const HEX64 = /^[0-9a-f]{64}$/;
14
+ const file = () => peerFile('shares.json');
15
+ const validRow = row => isObject(row) && HANDLE.test(row.handle ?? '') && typeof row.root === 'string' && path.isAbsolute(row.root)
16
+ && (row.repoId === null || HEX64.test(row.repoId ?? '')) && typeof row.status === 'boolean' && typeof row.createdAt === 'string'
17
+ && (row.teams === undefined || Array.isArray(row.teams) && row.teams.every(id => typeof id === 'string' && HEX64.test(id)));
18
+ export function shares() {
19
+ noSymlinks(file());
20
+ if (!fs.existsSync(file())) return [];
21
+ let rows; try { rows = readJson(file()); } catch { return []; }
22
+ return Array.isArray(rows) ? rows.filter(validRow) : [];
23
+ }
24
+ // git@host:owner/repo, ssh://user@host:22/owner/repo.git and https://user:token@Host/owner/repo
25
+ // all become host/owner/repo: no credentials, scheme, port or trailing .git, host lowercased.
26
+ export function normalizeRemote(url) {
27
+ const text = typeof url === 'string' ? url.trim() : '';
28
+ if (!text || text.length > 2048 || /[\x00-\x20\x7f]/.test(text)) return null;
29
+ let host, rest;
30
+ const scp = !text.includes('://') && /^(?:[^@/:]+@)?([^/:]+):(.*)$/.exec(text);
31
+ if (scp) [host, rest] = [scp[1], scp[2]];
32
+ else if (text.startsWith('/')) [host, rest] = ['file', text];
33
+ else {
34
+ let parsed; try { parsed = new URL(text); } catch { return null; }
35
+ host = parsed.protocol === 'file:' ? 'file' : parsed.hostname;
36
+ try { rest = decodeURIComponent(parsed.pathname); } catch { return null; }
37
+ }
38
+ rest = rest.replace(/^\/+/, '').replace(/\/+$/, '').replace(/\.git$/i, '').replace(/\/+$/, '');
39
+ return host && rest ? `${host.toLowerCase()}/${rest}` : null;
40
+ }
41
+ export function repoId(root) {
42
+ const result = git(root, ['config', '--get', 'remote.origin.url'], { allowFailure: true });
43
+ const remote = result.status === 0 ? normalizeRemote(result.stdout) : null;
44
+ return remote ? hash(remote) : null;
45
+ }
46
+ const defaultHandle = root => path.basename(root).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+/, '').slice(0, 40).replace(/-+$/, '');
47
+ // A share is for the teams it was shared with (teams), and a question from any other team
48
+ // cannot reach it, by name or otherwise. A share saved before teams were kept with it stays
49
+ // open to every team of the owner's, as it was, until it is shared again.
50
+ const projectTeams = handle => Object.entries(teamProjects()).filter(([, value]) => value === handle).map(([id]) => id);
51
+ export function sharedWith(row, teamIds) {
52
+ return Array.isArray(row?.teams) ? row.teams.some(id => teamIds.includes(id)) : teamIds.length > 0;
53
+ }
54
+ // A project shared again keeps its handle, status and teams; teams named now are added. One
55
+ // saved before teams were kept is tied then to every team of the owner's (mine) as well, so
56
+ // no team loses it, and a team joined after that is not included.
57
+ export function share(root, { handle, status, teams, mine } = {}) {
58
+ for (const list of [teams, mine]) if (list !== undefined && (!Array.isArray(list) || list.some(id => typeof id !== 'string' || !HEX64.test(id)))) refuse('Choose the teams to share with.');
59
+ if (status !== undefined && typeof status !== 'boolean') refuse('Share status must be true or false.');
60
+ if (handle !== undefined && (typeof handle !== 'string' || !HANDLE.test(handle))) refuse('A share handle is 1 to 40 lowercase letters, digits or dashes.');
61
+ let resolved; try { resolved = fs.realpathSync(path.resolve(String(root ?? ''))); } catch { refuse('That folder does not exist.'); }
62
+ // The home folder holds every other project and the owner's own files. It is never shared
63
+ // from here on; a share of it made before this still answers until it is unshared.
64
+ let home = os.homedir(); try { home = fs.realpathSync(home); } catch { /* Compared as given. */ }
65
+ if (within(resolved, home)) refuse('Your home folder cannot be shared. Open the project\'s own folder and share that.');
66
+ // Only the folder that was named is shared: a folder inside a project never stands for the
67
+ // whole project, so a share cannot reach further than what was asked.
68
+ const projects = registeredProjects(), project = projects.find(p => p.root === resolved);
69
+ if (!project) refuse(projects.some(p => within(p.root, resolved)) ? 'This folder is inside a project but is not one. Share the project from its own folder, or open this folder as a project first.' : 'Open this project in ELU Code before sharing it.');
70
+ const id = repoId(project.root);
71
+ const release = waitLock(peerRepo(), 'shares');
72
+ try {
73
+ const rows = shares(), existing = rows.find(row => row.root === project.root);
74
+ const name = handle ?? existing?.handle ?? defaultHandle(project.root);
75
+ if (!HANDLE.test(name)) refuse('A share handle is 1 to 40 lowercase letters, digits or dashes.');
76
+ if (rows.some(row => row.handle === name && row.root !== project.root)) refuse(`The handle ${name} is already shared.`);
77
+ const known = teams ? [...new Set([...(existing ? existing.teams ?? mine ?? projectTeams(existing.handle) : []), ...teams])] : existing?.teams;
78
+ const row = { handle: name, root: project.root, repoId: id, status: status ?? existing?.status ?? true, createdAt: existing?.createdAt ?? new Date().toISOString(), ...(known ? { teams: known } : {}) };
79
+ atomic(file(), [...rows.filter(x => x.root !== project.root), row]);
80
+ // A renamed share stays the project of the teams that had it.
81
+ if (existing && existing.handle !== name && fs.existsSync(teamFile())) atomic(teamFile(), Object.fromEntries(Object.entries(teamProjects()).map(([id, value]) => [id, value === existing.handle ? name : value])));
82
+ return row;
83
+ } finally { release(); }
84
+ }
85
+ export function unshare(handle) {
86
+ const release = waitLock(peerRepo(), 'shares');
87
+ try {
88
+ const rows = shares(), row = rows.find(x => x.handle === handle);
89
+ if (!row) refuse(`No share is named ${String(handle).slice(0, 40)}.`);
90
+ atomic(file(), rows.filter(x => x !== row));
91
+ const projects = teamProjects(); atomic(teamFile(), Object.fromEntries(Object.entries(projects).filter(([, value]) => value !== row.handle)));
92
+ return row;
93
+ } finally { release(); }
94
+ }
95
+ // Each team's project on this machine: the share a teammate's question goes to when it names
96
+ // none. It is chosen when the teammate is added (/invite and /join in a project), and /share
97
+ // in another project moves it there. team-projects.json = {teamId: handle}.
98
+ const teamFile = () => peerFile('team-projects.json');
99
+ export function teamProjects() {
100
+ noSymlinks(teamFile());
101
+ let value; try { value = fs.existsSync(teamFile()) ? readJson(teamFile()) : {}; } catch { return {}; }
102
+ return isObject(value) ? Object.fromEntries(Object.entries(value).filter(([id, handle]) => HEX64.test(id) && typeof handle === 'string' && HANDLE.test(handle))) : {};
103
+ }
104
+ export function setTeamProject(teamId, handle) {
105
+ if (typeof teamId !== 'string' || !HEX64.test(teamId)) refuse('Choose a team.');
106
+ const release = waitLock(peerRepo(), 'shares');
107
+ try {
108
+ if (!shares().some(row => row.handle === handle)) refuse(`No share is named ${String(handle).slice(0, 40)}.`);
109
+ atomic(teamFile(), { ...teamProjects(), [teamId]: handle });
110
+ } finally { release(); }
111
+ }
112
+ export function teamShare(teamIds) {
113
+ const projects = teamProjects();
114
+ for (const id of teamIds) { const row = projects[id] ? shareFor(projects[id]) : null; if (row) return row; }
115
+ return null;
116
+ }
117
+ export function shareFor(handleOrRepoId) {
118
+ if (typeof handleOrRepoId !== 'string') return null;
119
+ return shares().find(row => row.handle === handleOrRepoId || (HEX64.test(handleOrRepoId) && row.repoId === handleOrRepoId)) ?? null;
120
+ }