@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,329 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { atomic, noSymlinks, readJson, refuse, isObject } from '../util.mjs';
5
+ import { waitLock } from '../state.mjs';
6
+ import { enqueue } from '../conductor.mjs';
7
+ import { repository } from '../git.mjs';
8
+ import { registeredProjects, taskDetails } from '../projects.mjs';
9
+ import { BOT_NAME } from '../bots.mjs';
10
+ import { peerFile, peerRepo } from './paths.mjs';
11
+ import { peerPolicy } from './settings.mjs';
12
+ import { plainName, sanitize } from './peerdata.mjs';
13
+ import { teams } from './roster.mjs';
14
+ import { prepareOutgoing, scanSecrets } from './secrets.mjs';
15
+ import { PEER_EXT, REVIEW_MAX, TEXT_MAX, TaskState, isTerminal, message, textPart, validateTask } from './a2a.mjs';
16
+ import { dropOutgoing, pushPictures } from './pictures.mjs';
17
+ import { audit } from './taskstore.mjs';
18
+
19
+ // Asks this machine sent (docs/peer-network.md):
20
+ // asks/<id>.json = {id, to, name, messageId, threadId, taskId|null, kind, project, text,
21
+ // state, attempts, nextAt, createdAt, expiresAt, answer|null, root, chatTaskId, reason, group?, bot?, pictures?}.
22
+ // pictures: {set, list: [{id, n, name, size, type, sha256}], sentAt?, unsent?, reason?, kept?} for a
23
+ // message the owner typed that named pictures (pictures.mjs); nothing else carries any.
24
+ // A team ask is one ask per teammate sharing a group id and a thread. bot is the teammate's
25
+ // shared bot a question is for; only a question to one teammate names one.
26
+ // state: pending (not delivered), submitted, working, held, answered, rejected, failed,
27
+ // canceled, expired. ctx: {me: identity, session(key), dial(key), event(text), kick()}.
28
+ const DAY = 24 * 3600 * 1000;
29
+ const ID = /^[a-f0-9-]{36}$/;
30
+ const OPEN = ['pending', 'submitted', 'working', 'held'];
31
+ const dir = () => peerFile('asks');
32
+ const file = id => { if (!ID.test(id ?? '')) refuse('Choose an ask.'); return path.join(dir(), `${id}.json`); };
33
+ const locked = fn => { const release = waitLock(peerRepo(), 'asks'); try { return fn(); } finally { release(); } };
34
+ export function getAsk(id) {
35
+ try { const target = file(id); noSymlinks(target); if (!fs.existsSync(target)) return null; const value = readJson(target); return isObject(value) && value.id === id ? value : null; } catch { return null; }
36
+ }
37
+ export function asks() {
38
+ noSymlinks(dir()); if (!fs.existsSync(dir())) return [];
39
+ return fs.readdirSync(dir()).filter(name => ID.test(name.slice(0, -5)) && name.endsWith('.json')).flatMap(name => { const ask = getAsk(name.slice(0, -5)); return ask ? [ask] : []; });
40
+ }
41
+ const saveAsk = ask => { atomic(file(ask.id), ask); return ask; };
42
+ const updateAsk = (id, fn) => locked(() => { const ask = getAsk(id); if (!ask) return null; const next = fn(ask); return next ? saveAsk(next) : ask; });
43
+
44
+ // Active teammates by name or key, across every team this machine is in.
45
+ export function teammate(me, who) {
46
+ const seen = new Map();
47
+ for (const team of teams()) {
48
+ if (!team.members.some(m => m.key === me.key && !m.removed)) continue;
49
+ for (const m of team.members) if (!m.removed && m.key !== me.key) seen.set(m.key, m);
50
+ }
51
+ // The TUI shows names without spaces or @, so that form and the 8-character fingerprint match too.
52
+ const value = String(who ?? '').replace(/^@/, ''), plain = text => String(text).replace(/[\s@]+/g, '').toLowerCase();
53
+ const exact = [...seen.values()].filter(m => m.key === value || m.name === value);
54
+ const found = exact.length ? exact : [...seen.values()].filter(m => plain(m.name) === plain(value) && plain(value) || /^[0-9a-f]{8,63}$/.test(value) && m.key.startsWith(value));
55
+ if (!found.length) refuse(`No teammate is named @${sanitize(value, 40)}.`);
56
+ if (found.length > 1) refuse(`More than one teammate is named @${sanitize(value, 40)}. Use their key.`);
57
+ return found[0];
58
+ }
59
+ const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
60
+ export function createAsk(ctx, { to, kind = 'ask', project = null, text, root = null, chatTaskId = null, threadId = null, group = null, room = null, bot = null, byOwner = false, pictures = null }) {
61
+ if (!['ask', 'review', 'handoff'].includes(kind)) refuse('Choose ask, review or handoff.');
62
+ // Only the daemon's own commands for a message the owner typed pass pictures (stagedPictures).
63
+ if (pictures && (kind === 'review' || !byOwner)) refuse('Only a message you typed can carry pictures.');
64
+ if (project != null && !PROJECT.test(project)) refuse('A project is a share handle.');
65
+ if (bot != null && (typeof bot !== 'string' || !BOT_NAME.test(bot))) refuse('A bot name is 1 to 30 lowercase letters, digits or -, starting with a letter.');
66
+ if (bot != null && (kind !== 'ask' || group)) refuse('Only a question to one teammate can name a bot.');
67
+ const max = kind === 'review' ? REVIEW_MAX : TEXT_MAX;
68
+ if (typeof text !== 'string' || !text.trim() || text.length > max) refuse(`Write 1 to ${max} characters.`);
69
+ if (threadId != null && !/^[A-Za-z0-9_-]{1,128}$/.test(threadId)) refuse('Invalid thread.');
70
+ const member = teammate(ctx.me, to), now = Date.now();
71
+ // Agent drafts and local A2A callers write this text, so it is scanned like a reply.
72
+ // What the owner typed, or a draft the owner sent by hand (byOwner), goes as written: the
73
+ // owner decides what to tell a teammate, and a path or a long name is easily taken for a key.
74
+ // A trusted teammate gets it with its paths; the scan still refuses a secret.
75
+ const prepared = prepareOutgoing(text, { root, trusted: peerPolicy(member.key).trusted });
76
+ if (!prepared.ok && !byOwner) refuse(`This ${kind} contains a possible secret. Remove it before sending.`);
77
+ // A diff keeps its exact lines (--- /dev/null and all); other text loses paths outside the repository.
78
+ if (kind !== 'review') text = prepared.text;
79
+ const ask = saveAsk({ id: crypto.randomUUID(), to: member.key, name: sanitize(member.name, 64), messageId: crypto.randomUUID(), threadId: threadId ?? crypto.randomUUID(), taskId: null, kind, project, text,
80
+ state: 'pending', attempts: 0, nextAt: now, createdAt: new Date(now).toISOString(), expiresAt: new Date(now + 7 * DAY).toISOString(), answer: null, root, chatTaskId: chatTaskId == null ? null : String(chatTaskId), reason: null, ...(group ? { group } : {}), ...(room ? { room } : {}), ...(bot ? { bot } : {}), ...(kind !== 'review' && prepared.pathsCut ? { pathsCut: true } : {}), ...(pictures ? { pictures } : {}) });
81
+ audit(ask.threadId, { dir: 'out', peer: ask.to, kind, text });
82
+ if (!group) ctx.event(`${kind} to @${ask.name}`);
83
+ ctx.dial(ask.to); deliver(ctx, ask.id);
84
+ return ask;
85
+ }
86
+ // A question with no @name goes to every teammate; with a team, to everyone on it
87
+ // (#general); with a channel too, to that channel's members. The first whose agent takes
88
+ // it (its run starts) answers; the others' copies are then withdrawn (withdrawOthers).
89
+ export const TEAM_MAX = 20;
90
+ export function teamAsk(ctx, { project = null, text, root = null, chatTaskId = null, team = null, channel = null, byOwner = false, pictures = null }) {
91
+ const members = new Map(), mine = teams().filter(state => state.members.some(m => m.key === ctx.me.key && !m.removed));
92
+ let room = null, where = 'the team';
93
+ if (channel && !team) refuse('Name the team that channel is on.');
94
+ if (team) {
95
+ const state = mine.find(row => row.id === team); if (!state) refuse('You are not on that team.');
96
+ const found = channel ? state.channels.find(row => row.id === channel) : null;
97
+ if (channel && !found?.members.includes(ctx.me.key)) refuse('You are not in that channel.');
98
+ for (const m of state.members) if (!m.removed && m.key !== ctx.me.key && (!found || found.members.includes(m.key))) members.set(m.key, m);
99
+ if (found && !members.size) refuse(`No one else is in #${found.name}.`);
100
+ room = { team: state.id, channel: found?.id ?? null }; where = `#${found?.name ?? 'general'}`;
101
+ } else for (const state of mine) for (const m of state.members) if (!m.removed && m.key !== ctx.me.key) members.set(m.key, m);
102
+ if (!members.size) refuse('No teammates yet. Use /invite to add one.');
103
+ if (members.size > TEAM_MAX) refuse(`Your teams have more than ${TEAM_MAX} people. Ask one of them with @name.`);
104
+ // Scanned once up front, so a secret refuses the whole ask before any copy is saved.
105
+ if (!byOwner && typeof text === 'string' && !prepareOutgoing(text, { root }).ok) refuse('This ask contains a possible secret. Remove it before sending.');
106
+ const group = crypto.randomUUID(), threadId = crypto.randomUUID();
107
+ const sent = [...members.keys()].map(key => createAsk(ctx, { to: key, project, text, root, chatTaskId, threadId, group, room, byOwner, pictures }));
108
+ ctx.event(`ask to ${where} (${sent.length})`);
109
+ return sent;
110
+ }
111
+ const TAKEN = ['working', 'held', 'answered'];
112
+ function withdrawOthers(ctx, taken) {
113
+ let withdrawn = 0;
114
+ for (const other of asks().filter(row => row.group === taken.group && row.id !== taken.id && ['pending', 'submitted'].includes(row.state))) {
115
+ const done = updateAsk(other.id, current => ['pending', 'submitted'].includes(current.state) ? { ...current, state: 'canceled', cancel: true, dismissed: true, reason: `@${taken.name} took it.` } : null);
116
+ if (done?.state === 'canceled' && done.cancel) { withdrawn++; void cancel(ctx, done.id); }
117
+ }
118
+ if (withdrawn) ctx.event(`@${taken.name} took the team ask`);
119
+ }
120
+ // Dismissing a team ask dismisses every copy; open ones are cancelled with their teammates.
121
+ export function dismissTeam(ctx, group) {
122
+ const rows = asks().filter(row => row.group === group && !row.dismissed);
123
+ if (!rows.length) refuse('Choose an ask.');
124
+ return rows.map(row => dismissAsk(ctx, row.id));
125
+ }
126
+ // The diff of a local editing task, refused before sending when it is too large or
127
+ // looks like it carries a secret.
128
+ export function reviewText(root, taskId) {
129
+ const repo = registeredProjects().find(row => row.root === root) ?? repository(root);
130
+ const details = taskDetails(repo, String(taskId));
131
+ if (details.task.kind === 'conversation') refuse('Choose an editing task to review.');
132
+ const diff = details.diff.trim();
133
+ if (!diff) refuse('This task has no changes to review.');
134
+ if (diff.length > REVIEW_MAX) refuse(`This diff is over ${REVIEW_MAX} characters. Send a smaller change.`);
135
+ if (scanSecrets(diff).length) refuse('This diff contains a possible secret. Remove it before sending.');
136
+ return diff;
137
+ }
138
+ const backoff = attempts => Math.min(5000 * 2 ** attempts, 10 * 60000);
139
+ const sending = new Set(), pushing = new Set();
140
+ // The teammate answers at most 20 requests per 10 s and closes the connection past 40, so
141
+ // this side sends asks, polls and cancels to one teammate at half that rate. Whatever is
142
+ // left waits for a later tick.
143
+ const PACE = 10, PACE_MS = 10000, sent = new Map();
144
+ function allow(key, now = Date.now()) {
145
+ const recent = (sent.get(key) ?? []).filter(at => now - at < PACE_MS);
146
+ if (recent.length >= PACE) { sent.set(key, recent); return false; }
147
+ sent.set(key, [...recent, now]); return true;
148
+ }
149
+ // Sends a pending ask if its teammate is connected; otherwise it waits for a reconnect.
150
+ const paced = async key => { while (!allow(key)) await new Promise(resolve => setTimeout(resolve, 250)); };
151
+ // After the message went: what the teammate kept, and the copies for sending are removed
152
+ // once no other ask still waits to send them.
153
+ function settlePictures(id, sent, task) {
154
+ const done = updateAsk(id, current => {
155
+ if (!current.pictures) return null;
156
+ const kept = task.metadata?.[PEER_EXT]?.pictures, pictures = { ...current.pictures };
157
+ if (sent && Number.isInteger(kept) && kept < sent) Object.assign(pictures, { unsent: 'partly', kept });
158
+ if (sent && !pictures.unsent) pictures.sentAt = new Date().toISOString();
159
+ return { ...current, pictures };
160
+ });
161
+ if (done?.pictures && !asks().some(row => row.id !== id && row.state === 'pending' && row.pictures?.set === done.pictures.set)) dropOutgoing(done.pictures.set);
162
+ }
163
+ export async function deliver(ctx, id) {
164
+ let ask = getAsk(id); if (!ask || ask.state !== 'pending' || sending.has(id)) return;
165
+ // Pictures go first, in pieces, to one message per teammate at a time; the message then
166
+ // names them. A teammate who cannot take them gets the text alone.
167
+ const carries = Boolean(ask.pictures && !ask.pictures.unsent);
168
+ if (carries && pushing.has(ask.to)) return;
169
+ const session = ctx.session(ask.to); if (!session || !allow(ask.to)) return;
170
+ sending.add(id); if (carries) pushing.add(ask.to);
171
+ try {
172
+ let files = [];
173
+ if (carries) {
174
+ const pushed = await pushPictures(session, ask.pictures, () => paced(ask.to));
175
+ if (pushed.parts) files = pushed.parts;
176
+ else { ask = updateAsk(id, current => ({ ...current, pictures: { ...current.pictures, unsent: pushed.unsent, ...(pushed.reason ? { reason: pushed.reason } : {}) } })) ?? ask; ctx.event(`pictures to @${ask.name} not sent (${pushed.unsent})`); }
177
+ if (getAsk(id)?.state !== 'pending') return;
178
+ }
179
+ const params = { message: message({ text: ask.text, ...(files.length ? { parts: [textPart(ask.text), ...files] } : {}), contextId: ask.threadId, messageId: ask.messageId, ext: { kind: ask.kind, project: ask.project ?? null, hop: 1, from: { key: ctx.me.key, name: ctx.me.name.slice(0, 64) }, ...(ask.group ? { team: true } : {}), ...(ask.room ? { room: ask.room } : {}), ...(ask.bot ? { bot: ask.bot } : {}) } }) };
180
+ const result = await session.request('SendMessage', params);
181
+ if (!isObject(result?.task)) throw new Error('The teammate sent no task.');
182
+ if (ask.pictures) settlePictures(id, files.length, result.task);
183
+ applyTask(ctx, ask.to, result.task, id);
184
+ } catch (error) {
185
+ // Rate limits and internal errors (a disk or lock hiccup over there) are retried; refusals are final.
186
+ const final = Number.isInteger(error.details?.rpc?.code) && ![-32000, -32603].includes(error.details.rpc.code);
187
+ updateAsk(id, current => current.state !== 'pending' ? null : final
188
+ ? { ...current, state: 'failed', reason: sanitize(error.message, 300) }
189
+ : { ...current, attempts: current.attempts + 1, nextAt: Date.now() + backoff(current.attempts) });
190
+ if (final) ctx.event(`ask to @${ask.name} failed`);
191
+ } finally { sending.delete(id); if (carries) pushing.delete(ask.to); }
192
+ }
193
+ const answerText = task => (task.artifacts ?? []).flatMap(item => item.parts).filter(part => typeof part.text === 'string').map(part => part.text).join('\n\n');
194
+ const statusText = task => task.status.message?.parts?.filter(part => typeof part.text === 'string').map(part => part.text).join(' ') ?? null;
195
+ // Applies a Task from GetTask, SendMessage or elu.taskUpdate. Only the teammate the ask
196
+ // went to can change it, and only for the task it returned.
197
+ export function applyTask(ctx, from, value, askId = null) {
198
+ let current; try { current = validateTask(value); } catch { return null; }
199
+ const target = askId ? getAsk(askId) : asks().find(ask => ask.taskId === current.id && ask.to === from);
200
+ if (!target || target.to !== from || (target.taskId && target.taskId !== current.id)) return null;
201
+ // Dismissed while SendMessage was in flight: keep the task id so the cancel can go out.
202
+ if (!OPEN.includes(target.state)) { if (askId && target.cancel && !target.taskId) updateAsk(target.id, ask => ({ ...ask, taskId: current.id })); return null; }
203
+ const state = current.status.state, held = current.metadata?.['urn:elu-code:a2a-ext:peer:v1']?.substate === 'awaiting-owner';
204
+ const next = { [TaskState.SUBMITTED]: 'submitted', [TaskState.WORKING]: held ? 'held' : 'working', [TaskState.COMPLETED]: 'answered', [TaskState.REJECTED]: 'rejected', [TaskState.FAILED]: 'failed', [TaskState.CANCELED]: 'canceled' }[state] ?? 'working';
205
+ const ask = updateAsk(target.id, ask => {
206
+ if (!OPEN.includes(ask.state)) return null;
207
+ const update = { ...ask, taskId: current.id, state: next };
208
+ if (next === 'answered') update.answer = { text: sanitize(answerText(current), REVIEW_MAX), at: new Date().toISOString() };
209
+ if (isTerminal(state) && next !== 'answered') update.reason = sanitize(statusText(current) ?? '', 300) || null;
210
+ return update;
211
+ });
212
+ if (!ask || ask.state === target.state) return ask;
213
+ if (ask.group && TAKEN.includes(ask.state)) withdrawOthers(ctx, ask);
214
+ if (ask.state === 'answered') {
215
+ audit(ask.threadId, { dir: 'in', peer: ask.to, kind: 'answer', text: ask.answer.text });
216
+ ctx.event(`answer from @${ask.name}`);
217
+ if (ask.root && ask.chatTaskId && peerPolicy(ask.to).autoContinue) { try { continueAnswer(ctx, ask.id); } catch (error) { ctx.event(`continue failed: ${error.message}`); } }
218
+ } else if (isTerminal(state)) ctx.event(`${ask.kind} to @${ask.name} ${ask.state}`);
219
+ return ask;
220
+ }
221
+ // Queues a follow-up in the chat the ask came from, with the answer as peer data.
222
+ export function continueAnswer(ctx, id) {
223
+ const ask = getAsk(id);
224
+ if (!ask || ask.state !== 'answered' || !ask.answer) refuse('That ask has no answer yet.');
225
+ if (!ask.root || !ask.chatTaskId) refuse('That ask did not come from a chat.');
226
+ if (ask.continued) refuse('That answer was already continued.');
227
+ const name = ask.name.replace(/["<>\t\n]/g, '').trim() || ask.to.slice(0, 8);
228
+ const row = enqueue(ask.root, `Continue with the answer from @${plainName(ask.name, ask.to)} shown in the peer data block.`, {
229
+ continuationTaskId: ask.chatTaskId, priority: 0, readOnly: true,
230
+ origin: { kind: 'answer', peer: ask.to, name, a2aTaskId: ask.taskId, threadId: ask.threadId, hop: 0 }, context: ask.answer.text.slice(0, TEXT_MAX),
231
+ });
232
+ updateAsk(id, current => ({ ...current, continued: true, continuedAt: new Date().toISOString() }));
233
+ ctx.kick(); return row;
234
+ }
235
+ // Dismissing an open ask cancels it with the teammate, so it never runs on their budget.
236
+ export function dismissAsk(ctx, id) {
237
+ const ask = updateAsk(id, current => ({ ...current, dismissed: true, ...(OPEN.includes(current.state) ? { state: 'canceled', cancel: true } : {}) }));
238
+ if (!ask) refuse('Choose an ask.');
239
+ if (ask.cancel) void cancel(ctx, ask.id);
240
+ return ask;
241
+ }
242
+ // Sends CancelTask for an ask marked cancel (dismissed or expired). Done when the teammate
243
+ // cancels it, says it is finished or unknown, or never received it.
244
+ export async function cancel(ctx, id) {
245
+ const ask = getAsk(id); if (!ask?.cancel || sending.has(id)) return;
246
+ if (!ask.taskId) return void updateAsk(id, current => current.taskId ? null : { ...current, cancel: false });
247
+ const session = ctx.session(ask.to); if (!session || !allow(ask.to)) return;
248
+ sending.add(id);
249
+ try { await session.request('CancelTask', { id: ask.taskId }); updateAsk(id, current => ({ ...current, cancel: false })); }
250
+ catch (error) { if ([-32001, -32002].includes(error.details?.rpc?.code)) updateAsk(id, current => ({ ...current, cancel: false })); }
251
+ finally { sending.delete(id); }
252
+ }
253
+ // Polls one open ask with GetTask. A rate limit or a timeout tries again later.
254
+ async function poll(ctx, ask) {
255
+ const session = ctx.session(ask.to); if (!session || sending.has(ask.id) || !allow(ask.to)) return;
256
+ sending.add(ask.id);
257
+ try { applyTask(ctx, ask.to, await session.request('GetTask', { id: ask.taskId }), ask.id); updateAsk(ask.id, current => ({ ...current, pollAt: null, polls: 0 })); }
258
+ catch (error) {
259
+ if (error.details?.rpc?.code === -32001) updateAsk(ask.id, current => ({ ...current, state: 'failed', reason: 'The teammate no longer has this task.', pollAt: null }));
260
+ else updateAsk(ask.id, current => ({ ...current, polls: (current.polls ?? 0) + 1, pollAt: Date.now() + backoff(current.polls ?? 0) }));
261
+ } finally { sending.delete(ask.id); }
262
+ }
263
+ // On connect: what waits for this teammate goes out, and what they still hold is polled,
264
+ // at the paced rate; tickAsks sends the rest.
265
+ export async function reconnect(ctx, key) {
266
+ for (const ask of asks().filter(ask => ask.to === key)) {
267
+ if (ask.state === 'pending') updateAsk(ask.id, current => ({ ...current, nextAt: 0 }));
268
+ else if (OPEN.includes(ask.state) && ask.taskId) updateAsk(ask.id, current => ({ ...current, pollAt: 0, polls: 0 }));
269
+ }
270
+ await pump(ctx, key);
271
+ }
272
+ async function pump(ctx, key, now = Date.now()) {
273
+ for (const ask of asks().filter(ask => ask.to === key)) {
274
+ if (!ctx.session(key) || (sent.get(key) ?? []).filter(at => now - at < PACE_MS).length >= PACE) return;
275
+ if (ask.cancel) await cancel(ctx, ask.id);
276
+ else if (ask.state === 'pending' && ask.nextAt <= now) await deliver(ctx, ask.id);
277
+ else if (OPEN.includes(ask.state) && ask.taskId && ask.pollAt != null && ask.pollAt <= now) await poll(ctx, ask);
278
+ }
279
+ }
280
+ export function tickAsks(ctx, now = Date.now()) {
281
+ const keys = new Set();
282
+ for (const ask of asks()) {
283
+ if (ask.cancel) { if (ask.taskId) keys.add(ask.to); else cancel(ctx, ask.id); continue; }
284
+ if (!OPEN.includes(ask.state)) continue;
285
+ if (Date.parse(ask.expiresAt) <= now) { updateAsk(ask.id, current => OPEN.includes(current.state) ? { ...current, state: 'expired', cancel: true } : null); ctx.event(`${ask.kind} to @${ask.name} expired`); keys.add(ask.to); continue; }
286
+ if (ask.state === 'pending' && ask.nextAt <= now) {
287
+ // Offline: dial on the ask's own backoff; Hyperswarm retries in between.
288
+ if (!ctx.session(ask.to)) { updateAsk(ask.id, current => ({ ...current, attempts: current.attempts + 1, nextAt: now + backoff(current.attempts) })); ctx.dial(ask.to); continue; }
289
+ keys.add(ask.to);
290
+ }
291
+ if (ask.pollAt != null && ask.pollAt <= now) keys.add(ask.to);
292
+ }
293
+ for (const key of keys) if (ctx.session(key)) pump(ctx, key, now).catch(() => {});
294
+ }
295
+
296
+ // Agent drafts (drafts/<id>.json from the runner): approved ones, and drafts to
297
+ // teammates set to autoAsk, become asks.
298
+ const draftFile = id => { if (!ID.test(id ?? '')) refuse('Choose a draft.'); return peerFile('drafts', `${id}.json`); };
299
+ export function drafts() {
300
+ const folder = peerFile('drafts'); noSymlinks(folder); if (!fs.existsSync(folder)) return [];
301
+ return fs.readdirSync(folder).filter(name => name.endsWith('.json') && ID.test(name.slice(0, -5))).flatMap(name => {
302
+ try { const value = readJson(path.join(folder, name)); return isObject(value) && value.id === name.slice(0, -5) ? [value] : []; } catch { return []; }
303
+ });
304
+ }
305
+ export function sendDraft(ctx, id, { byOwner = false } = {}) {
306
+ const target = draftFile(id); noSymlinks(target);
307
+ const release = waitLock(peerRepo(), 'drafts');
308
+ let draft;
309
+ try {
310
+ draft = fs.existsSync(target) ? readJson(target) : null;
311
+ if (!draft || draft.state !== 'draft') refuse('No draft is waiting.');
312
+ atomic(target, { ...draft, state: 'sending' });
313
+ } finally { release(); }
314
+ try {
315
+ const ask = createAsk(ctx, { to: draft.to, project: draft.project ?? null, text: String(draft.question ?? ''), root: draft.root ?? null, chatTaskId: draft.taskId ?? null, threadId: draft.threadId ?? null, byOwner });
316
+ atomic(target, { ...draft, state: 'sent', askId: ask.id }); return ask;
317
+ } catch (error) { atomic(target, { ...draft, state: 'draft', error: error.message }); throw error; }
318
+ }
319
+ export function discardDraft(id) {
320
+ const target = draftFile(id); noSymlinks(target);
321
+ if (!fs.existsSync(target)) refuse('No draft is waiting.');
322
+ const draft = readJson(target); atomic(target, { ...draft, state: 'discarded' }); return draft;
323
+ }
324
+ export function autoDrafts(ctx) {
325
+ for (const draft of drafts().filter(row => row.state === 'draft' && !row.error)) {
326
+ let member; try { member = teammate(ctx.me, draft.to); } catch { continue; }
327
+ if (peerPolicy(member.key).autoAsk) { try { sendDraft(ctx, draft.id); } catch { /* Kept as a draft with its error. */ } }
328
+ }
329
+ }
@@ -0,0 +1,72 @@
1
+ import crypto from 'node:crypto';
2
+ import { isObject, refuse } from '../util.mjs';
3
+
4
+ // Canonical JSON: object keys sorted (by UTF-16 code units) at every level, no whitespace.
5
+ // Only JSON values are accepted, so a signature never covers something JSON would drop
6
+ // or rewrite (undefined, functions, NaN, Dates, Buffers, sparse arrays).
7
+ const MAX_DEPTH = 32;
8
+ function encode(value, depth) {
9
+ if (depth > MAX_DEPTH) refuse('Signed data is nested too deeply.');
10
+ if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value);
11
+ if (typeof value === 'number') { if (!Number.isFinite(value)) refuse('Signed data has a number JSON cannot hold.'); return JSON.stringify(value); }
12
+ if (Array.isArray(value)) {
13
+ const out = [];
14
+ for (let i = 0; i < value.length; i++) { if (!(i in value)) refuse('Signed data has an empty array slot.'); out.push(encode(value[i], depth + 1)); }
15
+ return `[${out.join(',')}]`;
16
+ }
17
+ const proto = isObject(value) ? Object.getPrototypeOf(value) : undefined;
18
+ if (proto !== Object.prototype && proto !== null) refuse('Signed data must be plain JSON.');
19
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${encode(value[key], depth + 1)}`).join(',')}}`;
20
+ }
21
+ export const canonical = value => encode(value, 0);
22
+
23
+ // Keys are raw 32-byte ed25519 values. Node takes them only as DER, so the fixed
24
+ // PKCS#8 and SPKI prefixes for ed25519 are prepended. hyperdht's DHT.keyPair(seed)
25
+ // derives the same public key from the same seed, so one seed is also the transport key.
26
+ const PKCS8 = Buffer.from('302e020100300506032b657004220420', 'hex');
27
+ const SPKI = Buffer.from('302a300506032b6570032100', 'hex');
28
+ const HEX64 = /^[0-9a-f]{64}$/;
29
+ function seedBytes(seed) {
30
+ if (!Buffer.isBuffer(seed) || seed.length !== 32) refuse('A signing seed must be 32 bytes.');
31
+ return seed;
32
+ }
33
+ export const privateKey = seed => crypto.createPrivateKey({ key: Buffer.concat([PKCS8, seedBytes(seed)]), format: 'der', type: 'pkcs8' });
34
+ export function publicKey(key) {
35
+ if (typeof key !== 'string' || !HEX64.test(key)) refuse('A public key must be 64 hex characters.');
36
+ return crypto.createPublicKey({ key: Buffer.concat([SPKI, Buffer.from(key, 'hex')]), format: 'der', type: 'spki' });
37
+ }
38
+ export function publicKeyHex(seed) {
39
+ return crypto.createPublicKey(privateKey(seed)).export({ format: 'der', type: 'spki' }).subarray(SPKI.length).toString('hex');
40
+ }
41
+ export const sign = (seed, value) => crypto.sign(null, Buffer.from(canonical(value)), privateKey(seed)).toString('hex');
42
+ // Never throws: a malformed key, signature or value is simply not verified.
43
+ export function verify(key, value, sig) {
44
+ try {
45
+ if (typeof sig !== 'string' || !/^[0-9a-f]{128}$/.test(sig)) return false;
46
+ return crypto.verify(null, Buffer.from(canonical(value)), publicKey(key), Buffer.from(sig, 'hex'));
47
+ } catch { return false; }
48
+ }
49
+
50
+ // RFC 4648 base32, lowercase and unpadded, for codes people paste.
51
+ const ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
52
+ export function base32(bytes) {
53
+ let bits = 0, value = 0, out = '';
54
+ for (const byte of bytes) {
55
+ value = (value << 8) | byte; bits += 8;
56
+ while (bits >= 5) { out += ALPHABET[(value >>> (bits - 5)) & 31]; bits -= 5; }
57
+ value &= (1 << bits) - 1;
58
+ }
59
+ return bits ? out + ALPHABET[(value << (5 - bits)) & 31] : out;
60
+ }
61
+ // Strict: only the lowercase alphabet, no padding, and no stray trailing bits, so each
62
+ // byte string has exactly one accepted spelling.
63
+ export function unbase32(text) {
64
+ if (typeof text !== 'string' || !/^[a-z2-7]*$/.test(text) || [1, 3, 6].includes(text.length % 8)) return null;
65
+ let bits = 0, value = 0; const out = [];
66
+ for (const char of text) {
67
+ value = (value << 5) | ALPHABET.indexOf(char); bits += 5;
68
+ if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; }
69
+ value &= (1 << bits) - 1;
70
+ }
71
+ return value === 0 ? Buffer.from(out) : null;
72
+ }
@@ -0,0 +1,154 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { refuse } from '../util.mjs';
4
+ import * as peer from './index.mjs';
5
+ import { loadIdentity, validName } from './identity.mjs';
6
+ import { appendOp, makeOp, teams } from './roster.mjs';
7
+ import { peerFile } from './paths.mjs';
8
+ import { teammate } from './asks.mjs';
9
+ import { sanitize } from './peerdata.mjs';
10
+
11
+ // The peer commands of bin/elu-code.mjs (README, "Teammates"). Each returns a
12
+ // result with a message and display lines; --json prints the whole result instead.
13
+ const live = () => peer.peerStatus().running;
14
+ async function daemonCall(command, timeoutMs = 30000) {
15
+ if (!live()) refuse('The peer daemon is not running. Run elu peer on.');
16
+ return peer.awaitCommand(peer.sendPeerCommand(command).id, timeoutMs);
17
+ }
18
+ const myTeams = me => teams().filter(state => state.members.some(m => m.key === me.key && !m.removed));
19
+ // --team takes an id, an id prefix of 8 or more characters, or a name; without it there must be one team.
20
+ function chooseTeam(me, value, { admin = false } = {}) {
21
+ const mine = myTeams(me).filter(state => !admin || state.members.some(m => m.key === me.key && m.role === 'admin'));
22
+ if (!mine.length) refuse(admin ? 'You are not an admin of any team.' : 'Create a team first with elu team create NAME.');
23
+ if (value === undefined) { if (mine.length > 1) refuse('Choose a team with --team.'); return mine[0]; }
24
+ const found = mine.filter(state => state.id === value || (value.length >= 8 && state.id.startsWith(value)) || state.name === value);
25
+ if (found.length !== 1) refuse(found.length ? 'More than one team matches. Use its id.' : 'No team of yours matches --team.');
26
+ return found[0];
27
+ }
28
+ const shown = me => ({ name: me.name, key: me.key, fingerprint: me.fingerprint, createdAt: me.createdAt });
29
+ // When a teammate is added, the project they can ask about is chosen too: --project PATH, else
30
+ // this folder when it can be shared. Only a --project that fails is an error.
31
+ function teamShare(teamId, flags) {
32
+ const where = flags.project === undefined ? process.cwd() : path.resolve(String(flags.project));
33
+ try { return { row: peer.share(where, { teams: [teamId] }) }; }
34
+ catch (error) { return { error: flags.project === undefined ? null : error.message }; }
35
+ }
36
+ const askAbout = ({ row, error }) => row ? ` Your teammates can ask about ${row.handle}, the folder ${row.root}.` : error ? ` Not shared: ${error}` : ' Run elu share in a project so your teammates can ask about it.';
37
+ function memberLines(status) {
38
+ const lines = [];
39
+ for (const state of status.teams) {
40
+ lines.push(`${state.name} (${state.id.slice(0, 8)}, ${state.role})`);
41
+ for (const m of state.members) lines.push(` ${m.key === status.key ? '●' : m.online ? '●' : '○'} ${m.name} ${m.role} ${m.key === status.key ? 'you' : m.online ? m.path ?? 'direct' : 'offline'}${m.key !== status.key && peer.peerPolicy(m.key).trusted ? ' trusted' : ''}`);
42
+ }
43
+ return lines;
44
+ }
45
+ // elu peer trust|untrust @NAME: writes peers.json directly; the daemon
46
+ // reads trust per send, so no restart is needed.
47
+ function trustCommand(action, words) {
48
+ if (!words[2] || words.length > 3) refuse(`Use elu peer ${action} @NAME.`);
49
+ const member = teammate(peer.identity(), words[2]), name = sanitize(member.name, 40).replace(/[\s@]+/g, '') || member.key.slice(0, 8);
50
+ const { trusted } = peer.savePeerPolicy(member.key, { trusted: action === 'trust' });
51
+ return { peer: { key: member.key, name: member.name, trusted }, message: trusted ? `Trusted @${name}. Paths go out as they are. Their text shows as sent.` : `@${name} is no longer trusted. Paths go out as [path].` };
52
+ }
53
+
54
+ function idCommand(flags) {
55
+ if (flags.import) return { identity: shown(peer.importIdentity(flags.import)), message: 'Identity imported.' };
56
+ let me = loadIdentity(), message = 'This is your peer identity.';
57
+ if (!me) {
58
+ const name = flags.name ?? os.userInfo().username.replace(/[^\p{L}\p{N}._-]/gu, '').slice(0, 40);
59
+ if (!validName(name)) refuse('Choose a name with --name.');
60
+ me = peer.identity({ create: true, name }); message = 'Peer identity created.';
61
+ } else if (flags.name !== undefined) { me = peer.identity({ name: flags.name }); message = 'Name changed.'; }
62
+ const result = { identity: shown(me), message, lines: [`${me.name} ${me.fingerprint}`, `Key: ${me.key}`] };
63
+ if (flags.export) { result.code = peer.exportIdentity(); result.lines.push('Export code. Anyone with it can act as you; keep it secret:', result.code); }
64
+ return result;
65
+ }
66
+ async function peerMode(words) {
67
+ const action = words[1] ?? 'status';
68
+ if (action === 'on') {
69
+ peer.startPeer();
70
+ // Wait for the first heartbeat, so a start failure shows here and not only in the log.
71
+ for (let until = Date.now() + 20000; Date.now() < until && !live();) await new Promise(resolve => setTimeout(resolve, 200));
72
+ if (!live()) refuse(`The peer daemon did not start. See ${peerFile('daemon.log')}.`);
73
+ return { peer: peer.peerStatus(), message: 'Peer mode is on.' };
74
+ }
75
+ if (action === 'off') { peer.stopPeer(); return { message: 'Peer mode is off.' }; }
76
+ if (action === 'status') {
77
+ const status = peer.peerStatus(), settings = peer.peerSettings();
78
+ return { peer: status, settings, message: `Peer mode is ${settings.enabled ? 'on' : 'off'}; the daemon is ${status.running ? 'running' : 'stopped'}.`, lines: [...status.error ? [`Error: ${status.error}`] : [], ...memberLines(status)] };
79
+ }
80
+ if (action === 'install') return { service: peer.installService(), message: 'The peer daemon now starts at login.' };
81
+ if (action === 'uninstall') return { service: peer.uninstallService(), message: 'The peer daemon no longer starts at login.' };
82
+ if (action === 'trust' || action === 'untrust') return trustCommand(action, words);
83
+ refuse(`Unknown command: peer ${action}. Use elu peer on|off|status|install|uninstall|trust @NAME|untrust @NAME.`);
84
+ }
85
+
86
+ export async function peerCli(words, flags) {
87
+ const [command] = words;
88
+ if (command === 'id') return idCommand(flags);
89
+ if (command === 'peer') return peerMode(words);
90
+ if (command === 'team') {
91
+ if (words[1] === 'create') {
92
+ const name = words.slice(2).join(' ');
93
+ const made = peer.createTeam(name);
94
+ return { team: { id: made.id, name: made.name }, message: `Team ${made.name} created. Add a teammate with elu invite.` };
95
+ }
96
+ if (words[1]) refuse(`Unknown command: team ${words[1]}. Use elu team create NAME.`);
97
+ const status = peer.peerStatus();
98
+ return { teams: status.teams, message: status.teams.length ? `${status.teams.length} team${status.teams.length === 1 ? '' : 's'}.` : 'No team yet. Create one with elu team create NAME.', lines: memberLines(status) };
99
+ }
100
+ if (command === 'invite') {
101
+ const me = peer.identity(), state = chooseTeam(me, flags.team, { admin: true }), hours = flags.hours === undefined ? 24 : Number(flags.hours);
102
+ const shared = teamShare(state.id, flags);
103
+ if (shared.error) refuse(shared.error);
104
+ const code = peer.createInvite(state.id, { hours });
105
+ return { invite: { code, team: state.id, hours, project: shared.row?.handle ?? null }, message: `Invite to ${state.name}. It works once, within ${hours} h.${askAbout(shared)}${live() ? '' : ' Turn on peer mode (elu peer on) so they can join.'}`, lines: [code, 'Your teammate runs it in the project you can ask about: elu join <code>'] };
106
+ }
107
+ if (command === 'join') {
108
+ if (!words[1] || words.length > 2) refuse('Use elu join CODE.');
109
+ const joined = await peer.joinTeam(words[1]), shared = teamShare(joined.id, flags);
110
+ return { team: { ...joined, project: shared.row?.handle ?? null }, message: `Joined ${joined.name}.${askAbout(shared)}${live() ? '' : ' Run elu peer on to talk to your teammates.'}` };
111
+ }
112
+ if (command === 'share') {
113
+ if (words.length > 2) refuse('Use elu share [PATH] [--as HANDLE] [--no-status].');
114
+ const row = peer.share(path.resolve(words[1] ?? process.cwd()), { handle: flags.as, status: !flags['no-status'] });
115
+ return { share: row, message: `Shared ${row.root} with your teams as ${row.handle}. Questions that name no project go to it.` };
116
+ }
117
+ if (command === 'unshare') {
118
+ if (words.length !== 2) refuse('Use elu unshare HANDLE.');
119
+ const row = peer.unshare(words[1]);
120
+ return { share: row, message: `Stopped sharing ${row.handle}.` };
121
+ }
122
+ if (command === 'peers') {
123
+ const status = peer.peerStatus(), items = peer.peerItems(), agents = peer.teamAgents();
124
+ const lines = [...memberLines(status), ...items.map(item => `${item.kind} @${item.name} ${item.text ?? ''}`.slice(0, 160))];
125
+ return { peer: status, items, agents, message: `The daemon is ${status.running ? 'running' : 'stopped'}. ${items.length} peer item${items.length === 1 ? '' : 's'}.`, lines };
126
+ }
127
+ if (command === 'ask') {
128
+ const to = words[1] ?? '', text = words.slice(2).join(' ');
129
+ // No @NAME: every teammate gets it, and the first to take it answers.
130
+ if (to && !to.startsWith('@')) {
131
+ const { askIds } = await daemonCall({ type: 'teamAsk', project: flags.project ?? null, text: words.slice(1).join(' ') });
132
+ return { askIds, message: `Asked your team (${askIds.length}). The first teammate to take it answers; see elu peers.` };
133
+ }
134
+ if (!/^@[^\s@/\\]{1,64}$/.test(to) || !text.trim()) refuse('Use elu ask [@NAME] [--project H] TEXT.');
135
+ const { askId } = await daemonCall({ type: 'ask', to: to.slice(1), project: flags.project ?? null, text });
136
+ return { askId, message: `Asked ${to}. The answer shows in elu peers.` };
137
+ }
138
+ refuse(`Unknown command: ${words.join(' ')}. Use elu --help.`);
139
+ }
140
+
141
+ // elu relay: serves the chosen team (or every team), and as an admin adds its key to that
142
+ // team's roster so teammates' daemons use it after their next start.
143
+ export async function startRelay(flags) {
144
+ const me = peer.identity(), chosen = flags.team === undefined ? null : chooseTeam(me, flags.team);
145
+ const { runRelay } = await import('./relay.mjs');
146
+ const relay = await runRelay({ identity: me, bootstrap: peer.peerSettings().bootstrap, teams: chosen ? [chosen.id] : null });
147
+ const added = [];
148
+ for (const state of chosen ? [chosen] : myTeams(me)) {
149
+ if (!state.members.some(m => m.key === me.key && m.role === 'admin') || state.relays.includes(relay.key) || state.relays.length >= 16) continue;
150
+ appendOp(state.id, makeOp(me, state.id, 'relays', { keys: [...state.relays, relay.key] })); added.push(state.name);
151
+ }
152
+ const result = { relay: { key: relay.key, teams: chosen ? [chosen.id] : myTeams(me).map(state => state.id) }, message: `Relay running. Key: ${relay.key}`, lines: [added.length ? `Added to the roster of ${added.join(', ')}.` : 'A team admin adds this key to the roster.', 'Press Ctrl-C to stop.'] };
153
+ return { relay, result };
154
+ }