@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.
- package/LICENSE +21 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/README.md +327 -2
- package/SECURITY.md +333 -0
- package/THIRD_PARTY_NOTICES.md +62 -0
- package/bin/elu-code.mjs +209 -0
- package/bin/elu.cjs +10 -0
- package/bin/start.cjs +6 -0
- package/docs/peer-network.md +257 -0
- package/docs/permissions.md +285 -0
- package/docs/vendors/antigravity.md +131 -0
- package/docs/vendors/claude.md +124 -0
- package/docs/vendors/codex.md +64 -0
- package/docs/vendors/grok.md +151 -0
- package/lib/acp-client.mjs +100 -0
- package/lib/adapters/agy.mjs +283 -0
- package/lib/adapters/claude.mjs +311 -0
- package/lib/adapters/codex.mjs +137 -0
- package/lib/adapters/grok.mjs +384 -0
- package/lib/adapters/index.mjs +13 -0
- package/lib/agents.mjs +77 -0
- package/lib/agy-hook.mjs +53 -0
- package/lib/approval-server.mjs +85 -0
- package/lib/approvals.mjs +128 -0
- package/lib/awake.mjs +45 -0
- package/lib/board.mjs +261 -0
- package/lib/bot-models.mjs +71 -0
- package/lib/bot-schedules.mjs +291 -0
- package/lib/bots.mjs +361 -0
- package/lib/changes.mjs +168 -0
- package/lib/checks.mjs +102 -0
- package/lib/conductor.mjs +337 -0
- package/lib/config.mjs +147 -0
- package/lib/conformance.mjs +109 -0
- package/lib/default-bots.mjs +150 -0
- package/lib/detect.mjs +108 -0
- package/lib/diff.mjs +217 -0
- package/lib/editor.mjs +165 -0
- package/lib/format.mjs +51 -0
- package/lib/git.mjs +173 -0
- package/lib/init.mjs +129 -0
- package/lib/install.mjs +130 -0
- package/lib/kanban.mjs +180 -0
- package/lib/mcp.mjs +79 -0
- package/lib/memory.mjs +35 -0
- package/lib/models.mjs +144 -0
- package/lib/modes.mjs +95 -0
- package/lib/never-list.mjs +110 -0
- package/lib/never-shims.mjs +311 -0
- package/lib/paste.mjs +56 -0
- package/lib/peer/a2a-http.mjs +166 -0
- package/lib/peer/a2a.mjs +357 -0
- package/lib/peer/asks.mjs +329 -0
- package/lib/peer/canonical.mjs +72 -0
- package/lib/peer/cli.mjs +154 -0
- package/lib/peer/daemon.mjs +525 -0
- package/lib/peer/identity.mjs +81 -0
- package/lib/peer/index.mjs +291 -0
- package/lib/peer/intake.mjs +207 -0
- package/lib/peer/invite.mjs +76 -0
- package/lib/peer/paths.mjs +7 -0
- package/lib/peer/peerdata.mjs +21 -0
- package/lib/peer/pictures.mjs +295 -0
- package/lib/peer/plans.mjs +182 -0
- package/lib/peer/relay.mjs +31 -0
- package/lib/peer/replies.mjs +128 -0
- package/lib/peer/roster.mjs +256 -0
- package/lib/peer/rpc.mjs +88 -0
- package/lib/peer/secrets.mjs +88 -0
- package/lib/peer/settings.mjs +45 -0
- package/lib/peer/shares.mjs +120 -0
- package/lib/peer/status.mjs +84 -0
- package/lib/peer/taskstore.mjs +108 -0
- package/lib/peer/transport.mjs +120 -0
- package/lib/pictures.mjs +104 -0
- package/lib/plan.mjs +238 -0
- package/lib/policy.mjs +98 -0
- package/lib/process.mjs +92 -0
- package/lib/project-settings.mjs +55 -0
- package/lib/project-trust.mjs +230 -0
- package/lib/projects.mjs +668 -0
- package/lib/publish.mjs +105 -0
- package/lib/runner.mjs +874 -0
- package/lib/sandbox.mjs +102 -0
- package/lib/schedules.mjs +270 -0
- package/lib/selection.mjs +202 -0
- package/lib/shell.mjs +101 -0
- package/lib/state.mjs +247 -0
- package/lib/terminal-bots.mjs +239 -0
- package/lib/terminal-commands.mjs +165 -0
- package/lib/terminal-plan.mjs +37 -0
- package/lib/terminal-visuals.mjs +268 -0
- package/lib/terminal.mjs +3159 -0
- package/lib/unit-file.mjs +17 -0
- package/lib/usage.mjs +249 -0
- package/lib/util.mjs +86 -0
- package/lib/vendor-check.mjs +96 -0
- package/lib/vendors.mjs +191 -0
- package/lib/workspace.mjs +168 -0
- package/lib/yaml.mjs +624 -0
- package/npm-shrinkwrap.json +827 -0
- package/package.json +70 -4
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { atomic, hash, isObject, mkdir, noSymlinks, readJson, refuse } from '../util.mjs';
|
|
4
|
+
import { lock } from '../state.mjs';
|
|
5
|
+
import { peerFile, peerRepo, peerDir } from './paths.mjs';
|
|
6
|
+
import { peerPolicy, peerSettings, savePeerPolicy } from './settings.mjs';
|
|
7
|
+
import { sanitize } from './peerdata.mjs';
|
|
8
|
+
import { identity as loadIdentity, validName } from './identity.mjs';
|
|
9
|
+
import { head, merge, opsMissing, readLog, setChannel, team, teamPath, teams } from './roster.mjs';
|
|
10
|
+
import { parseInvite, redeemInvite } from './invite.mjs';
|
|
11
|
+
import { shares, sharedWith } from './shares.mjs';
|
|
12
|
+
import { createNode } from './transport.mjs';
|
|
13
|
+
import { Session } from './rpc.mjs';
|
|
14
|
+
import { intakeMethods, sharedTeams } from './intake.mjs';
|
|
15
|
+
import { acceptIncoming, declineIncoming, discardReply, processOutbox, sendReply, syncRecords } from './replies.mjs';
|
|
16
|
+
import { applyTask, asks as sentAsks, autoDrafts, continueAnswer, createAsk, discardDraft, dismissAsk, dismissTeam, reconnect, reviewText, sendDraft, teamAsk, tickAsks } from './asks.mjs';
|
|
17
|
+
import { cleanBots, cleanBox, saveRemoteStatus, statusRows, thisMachine } from './status.mjs';
|
|
18
|
+
import { fitPlans, heardPlans, planRows, prunePlans, saveRemotePlans } from './plans.mjs';
|
|
19
|
+
import { ErrorCode, TaskState, a2aError, artifact, peerExt, task as a2aTask } from './a2a.mjs';
|
|
20
|
+
import { buildAgentCard, startLocalA2A } from './a2a-http.mjs';
|
|
21
|
+
import { enqueue, startConductor } from '../conductor.mjs';
|
|
22
|
+
import { capMode, defaultMode } from '../modes.mjs';
|
|
23
|
+
import { pendingRequests } from '../workspace.mjs';
|
|
24
|
+
import { readTask } from '../state.mjs';
|
|
25
|
+
import { registeredProjects } from '../projects.mjs';
|
|
26
|
+
import { getAsk } from './asks.mjs';
|
|
27
|
+
import { getRecord, prune } from './taskstore.mjs';
|
|
28
|
+
import { pictureMethods, stagedPictures, sweepPictures } from './pictures.mjs';
|
|
29
|
+
|
|
30
|
+
// The peer daemon (docs/peer-network.md, "Layers"): one per user, holding peer.lock. It
|
|
31
|
+
// talks to the TUI and CLI only through files: commands/ in, commands/*.result.json and
|
|
32
|
+
// state.json out. It dials every active teammate, serves their A2A calls, and moves
|
|
33
|
+
// conductor outbox entries back to them.
|
|
34
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
35
|
+
const COMMAND = /^[A-Za-z0-9-]{1,64}$/;
|
|
36
|
+
const WAKE_MS = 30000;
|
|
37
|
+
const LOG_MAX = 1024 * 1024;
|
|
38
|
+
const active = (state, key) => state.members.some(m => m.key === key && !m.removed);
|
|
39
|
+
|
|
40
|
+
// local-tasks/<requestId>.json: {id, root, contextId, taskId, createdAt} for each /a2a
|
|
41
|
+
// request, so GetTask answers after the conductor drops the row. Kept 7 days.
|
|
42
|
+
const LOCAL = /^request-[a-f0-9-]{1,64}$/;
|
|
43
|
+
const localFile = id => peerFile('local-tasks', `${id}.json`);
|
|
44
|
+
const saveLocal = row => atomic(localFile(row.id), row);
|
|
45
|
+
function getLocal(id) {
|
|
46
|
+
if (!LOCAL.test(id ?? '')) return null;
|
|
47
|
+
try { const file = localFile(id); noSymlinks(file); if (!fs.existsSync(file)) return null; const value = readJson(file); return isObject(value) && value.id === id ? value : null; } catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
function locals() {
|
|
50
|
+
const folder = peerFile('local-tasks'); noSymlinks(folder); if (!fs.existsSync(folder)) return [];
|
|
51
|
+
return fs.readdirSync(folder).filter(name => name.endsWith('.json')).flatMap(name => { const row = getLocal(name.slice(0, -5)); return row ? [row] : []; });
|
|
52
|
+
}
|
|
53
|
+
function pruneLocal(now) { for (const row of locals()) if (now - Date.parse(row.createdAt) > 7 * 24 * 3600000) fs.rmSync(localFile(row.id), { force: true }); }
|
|
54
|
+
|
|
55
|
+
export class PeerDaemon {
|
|
56
|
+
constructor({ tickMs = 2000, statusMs = 10000, conductor = startConductor, testing = {}, requireEnabled = true, onExit } = {}) {
|
|
57
|
+
Object.assign(this, { tickMs, statusMs, conductor, testing, requireEnabled, onExit });
|
|
58
|
+
this.sessions = new Map(); this.handles = new Map(); this.joins = new Map(); this.waiters = new Map();
|
|
59
|
+
this.running = new Set(); this.rows = []; this.rowsDigest = null; this.statusAt = 0; this.error = null;
|
|
60
|
+
// machines: what each teammate last said their machine is called and shares; mine: what this one tells them.
|
|
61
|
+
this.machines = new Map(); this.mine = null;
|
|
62
|
+
}
|
|
63
|
+
async start() {
|
|
64
|
+
if (this.requireEnabled && !peerSettings().enabled) refuse('Peer mode is off. Turn it on with elu peer on.');
|
|
65
|
+
mkdir(peerDir());
|
|
66
|
+
this.release = lock(peerRepo(), 'peer');
|
|
67
|
+
try {
|
|
68
|
+
this.me = loadIdentity(); this.recall(); this.mine = thisMachine();
|
|
69
|
+
const settings = peerSettings();
|
|
70
|
+
this.ctx = {
|
|
71
|
+
me: this.me, event: text => this.event(text), kick: () => this.kick(), dial: key => this.node.dial(key),
|
|
72
|
+
session: key => { const s = this.sessions.get(key); return s?.admitted ? s : null; },
|
|
73
|
+
push: (key, task) => this.ctx.session(key)?.notify('elu.taskUpdate', { task }),
|
|
74
|
+
};
|
|
75
|
+
const relays = [...new Set([...teams().flatMap(state => active(state, this.me.key) ? state.relays : []), ...(Array.isArray(settings.relays) ? settings.relays : [])])];
|
|
76
|
+
this.node = await createNode({ identity: this.me, bootstrap: settings.bootstrap, relays, testing: this.testing });
|
|
77
|
+
this.node.on('connection', (socket, key) => this.attach(socket, key));
|
|
78
|
+
this.methods = pictureMethods(this.ctx, { ...intakeMethods(this.ctx), ...this.extensions() });
|
|
79
|
+
await this.node.listen();
|
|
80
|
+
} catch (error) { await this.node?.destroy().catch(() => {}); this.release(); throw error; }
|
|
81
|
+
this.startedAt = new Date().toISOString(); this.lastTick = Date.now();
|
|
82
|
+
this.dialAll();
|
|
83
|
+
try { fs.mkdirSync(peerFile('outbox'), { recursive: true, mode: 0o700 }); this.watcher = fs.watch(peerFile('outbox'), () => this.soon()); } catch { /* Polling still runs. */ }
|
|
84
|
+
this.timer = setInterval(() => this.tick(), this.tickMs); this.tick();
|
|
85
|
+
// A transient node (joinTeam with the daemon off) only joins.
|
|
86
|
+
if (this.requireEnabled && peerSettings().localA2A) { try { this.local = await this.startLocal(); } catch (error) { this.event(`local A2A did not start: ${error.message}`); } }
|
|
87
|
+
this.log(`started ${this.me.fingerprint}`);
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
async stop() {
|
|
91
|
+
if (this.closed) return; this.closed = true;
|
|
92
|
+
clearInterval(this.timer); clearTimeout(this.soonTimer); this.watcher?.close();
|
|
93
|
+
for (const session of this.sessions.values()) session.close();
|
|
94
|
+
await this.local?.close().catch(() => {});
|
|
95
|
+
await this.node?.destroy().catch(() => {});
|
|
96
|
+
try { this.heartbeat(true); } catch { /* Best effort. */ }
|
|
97
|
+
this.log('stopped');
|
|
98
|
+
try { this.release?.(); } catch { /* Already gone. */ }
|
|
99
|
+
}
|
|
100
|
+
soon() { clearTimeout(this.soonTimer); this.soonTimer = setTimeout(() => this.tick(), 50); }
|
|
101
|
+
kick() { try { this.conductor(); } catch (error) { this.log(`conductor did not start: ${error.message}`); } }
|
|
102
|
+
log(text) {
|
|
103
|
+
try {
|
|
104
|
+
const file = peerFile('daemon.log'); noSymlinks(file);
|
|
105
|
+
if (fs.existsSync(file) && fs.statSync(file).size > LOG_MAX) fs.truncateSync(file, 0);
|
|
106
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${sanitize(text, 500)}\n`, { mode: 0o600 });
|
|
107
|
+
} catch { /* Logging never stops the daemon. */ }
|
|
108
|
+
}
|
|
109
|
+
event(text) { this.log(text); }
|
|
110
|
+
name(key) { return sanitize(teams().flatMap(state => state.members).find(m => m.key === key)?.name ?? key.slice(0, 8), 64); }
|
|
111
|
+
myTeams() { return teams().filter(state => active(state, this.me.key)); }
|
|
112
|
+
teammateKeys() { return [...new Set(this.myTeams().flatMap(state => state.members.filter(m => !m.removed && m.key !== this.me.key).map(m => m.key)))]; }
|
|
113
|
+
dialAll() {
|
|
114
|
+
const keys = new Set(this.teammateKeys());
|
|
115
|
+
for (const key of keys) this.node.dial(key);
|
|
116
|
+
for (const key of this.dialled ?? []) if (!keys.has(key) && !this.joins.has(key)) this.node.leave(key);
|
|
117
|
+
this.dialled = keys;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
tick() {
|
|
121
|
+
if (this.closed || this.busy) return; this.busy = true;
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
try {
|
|
124
|
+
// A long gap between ticks means the machine slept: announce again and redial.
|
|
125
|
+
if (now - this.lastTick > WAKE_MS) { this.event('woke up; reconnecting'); this.node.refresh().catch(() => {}); this.dialAll(); }
|
|
126
|
+
this.lastTick = now;
|
|
127
|
+
this.commands();
|
|
128
|
+
processOutbox(this.ctx); syncRecords(this.ctx, now); tickAsks(this.ctx, now); autoDrafts(this.ctx);
|
|
129
|
+
this.forwardLocal(); this.syncLocal();
|
|
130
|
+
if (now - (this.pruneAt ??= 0) > 3600000) { this.pruneAt = now; prune(now); pruneLocal(now); sweepPictures(set => sentAsks().some(row => row.state === 'pending' && row.pictures?.set === set), now); this.forgetPlans(now); }
|
|
131
|
+
this.retell();
|
|
132
|
+
if (now - this.statusAt >= this.statusMs) { this.statusAt = now; this.status(); }
|
|
133
|
+
if (now - (this.plansAt ??= 0) >= (this.plansMs ?? 5000)) { this.plansAt = now; this.tellPlans(); }
|
|
134
|
+
if (now - (this.routingAt ??= now) > 5 * 60000) { this.routingAt = now; this.node.saveRouting(); }
|
|
135
|
+
this.error = null;
|
|
136
|
+
} catch (error) { this.error = error.message; this.log(`tick: ${error.message}`); }
|
|
137
|
+
finally { this.busy = false; }
|
|
138
|
+
try { this.heartbeat(); } catch (error) { this.log(`heartbeat: ${error.message}`); }
|
|
139
|
+
}
|
|
140
|
+
// state.json in the facade's peerStatus shape, plus this machine's status rows.
|
|
141
|
+
heartbeat(stopped = false) {
|
|
142
|
+
const connections = this.node?.connections ?? new Map();
|
|
143
|
+
const member = m => {
|
|
144
|
+
const conn = connections.get(m.key), online = m.key === this.me.key || Boolean(this.sessions.get(m.key)?.admitted);
|
|
145
|
+
const machine = m.key === this.me.key ? this.mine : this.machines.get(m.key);
|
|
146
|
+
return { key: m.key, fingerprint: m.key.slice(0, 8), name: sanitize(m.name, 64), role: m.role, online: online && !stopped, path: m.key === this.me.key ? null : conn?.path ?? null, lastSeen: conn ? new Date(conn.lastSeen).toISOString() : this.lastSeen?.[m.key] ?? null, ...(this.handles.get(m.key) ? { handles: this.handles.get(m.key) } : {}), ...(machine ? { box: machine.box, bots: machine.bots } : {}) };
|
|
147
|
+
};
|
|
148
|
+
for (const [key, conn] of connections) (this.lastSeen ??= {})[key] = new Date(conn.lastSeen).toISOString();
|
|
149
|
+
atomic(peerFile('state.json'), {
|
|
150
|
+
running: !stopped, stopped, pid: process.pid, at: new Date().toISOString(), startedAt: this.startedAt, key: this.me.key, fingerprint: this.me.fingerprint, name: this.me.name, error: this.error,
|
|
151
|
+
teams: this.myTeams().map(state => ({ id: state.id, name: state.name, role: state.members.find(m => m.key === this.me.key)?.role ?? 'member', members: state.members.filter(m => !m.removed).map(member),
|
|
152
|
+
channels: state.channels.filter(row => row.members.includes(this.me.key)).map(row => ({ id: row.id, name: row.name, members: row.members })) })),
|
|
153
|
+
counters: { ...(this.node?.counters ?? { direct: 0, relayed: 0, failed: 0 }) },
|
|
154
|
+
rows: this.rows,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
status(force = false) {
|
|
158
|
+
let rows; try { rows = statusRows(); } catch (error) { this.log(`status: ${error.message}`); return; }
|
|
159
|
+
const digest = hash(JSON.stringify(rows));
|
|
160
|
+
if (!force && digest === this.rowsDigest) return;
|
|
161
|
+
this.rows = rows; this.rowsDigest = digest;
|
|
162
|
+
this.announce();
|
|
163
|
+
}
|
|
164
|
+
// elu.status carries the rows, and with them this machine's box name and shared bots. A
|
|
165
|
+
// build from before bots reads only the rows. It goes to a teammate on connect and to
|
|
166
|
+
// everyone connected when any of the three changes.
|
|
167
|
+
announce(only = null) {
|
|
168
|
+
const mine = this.mine ??= thisMachine();
|
|
169
|
+
for (const session of only ? [only] : this.sessions.values()) if (session.admitted) { const open = this.openTo(session.key); session.notify('elu.status', { rows: this.rows.filter(row => open.has(row.handle)), ...mine }); }
|
|
170
|
+
}
|
|
171
|
+
// A project's name and its live rows go only to a teammate on a team it is shared with. A
|
|
172
|
+
// shares file that cannot be read shares none.
|
|
173
|
+
openTo(key) {
|
|
174
|
+
try { const ids = sharedTeams(this.me.key, key).map(state => state.id); return new Set(shares().filter(row => sharedWith(row, ids)).map(row => row.handle)); }
|
|
175
|
+
catch { return new Set(); }
|
|
176
|
+
}
|
|
177
|
+
// elu.plans carries the Tasks this machine's sessions work on in the projects open to
|
|
178
|
+
// that teammate (plans.mjs): the whole present list each time, so a Task that ended and a
|
|
179
|
+
// share that ended are simply absent from the next one. It goes on connect, and when
|
|
180
|
+
// that list changes. Only a teammate whose hello said it reads Tasks is sent any.
|
|
181
|
+
tellPlans(only = null) {
|
|
182
|
+
let rows; try { rows = planRows(); } catch (error) { this.log(`plans: ${error.message}`); return; }
|
|
183
|
+
const told = this.plansTold ??= new Map();
|
|
184
|
+
for (const session of only ? [only] : this.sessions.values()) if (session.admitted && session.remoteHello?.plans === true) {
|
|
185
|
+
const open = this.openTo(session.key), tasks = fitPlans(rows.filter(row => open.has(row.handle))), digest = hash(JSON.stringify(tasks));
|
|
186
|
+
if (!only && told.get(session.key) === digest) continue;
|
|
187
|
+
told.set(session.key, digest); session.notify('elu.plans', { tasks });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// What teammates told this machine is dropped when they are no longer teammates, and
|
|
191
|
+
// 48 hours after they were last connected.
|
|
192
|
+
forgetPlans(now = Date.now()) {
|
|
193
|
+
try { prunePlans({ teammates: this.teammateKeys(), online: [...this.sessions.values()].filter(session => session.admitted).map(session => session.key), now }); }
|
|
194
|
+
catch (error) { this.log(`plans: ${error.message}`); }
|
|
195
|
+
}
|
|
196
|
+
// The box name and the bots file are read again on every tick, so a renamed machine or
|
|
197
|
+
// a bot shared, changed or taken back reaches teammates within a few seconds.
|
|
198
|
+
retell() {
|
|
199
|
+
const mine = thisMachine(), changed = JSON.stringify(mine) !== JSON.stringify(this.mine);
|
|
200
|
+
this.mine = mine; if (changed) this.announce();
|
|
201
|
+
}
|
|
202
|
+
// What teammates said about their machines stays in state.json when the daemon stops,
|
|
203
|
+
// so the next start still knows it for those who are offline.
|
|
204
|
+
recall() {
|
|
205
|
+
let saved = null; try { const file = peerFile('state.json'); noSymlinks(file); if (fs.existsSync(file)) saved = readJson(file); } catch { /* Starts with none. */ }
|
|
206
|
+
if (!isObject(saved) || saved.key !== this.me.key || !Array.isArray(saved.teams)) return;
|
|
207
|
+
for (const state of saved.teams.slice(0, 32)) for (const m of Array.isArray(state?.members) ? state.members.slice(0, 256) : []) {
|
|
208
|
+
if (isObject(m) && HEX64.test(m.key ?? '') && m.key !== this.me.key && (Object.hasOwn(m, 'box') || Object.hasOwn(m, 'bots'))) this.machines.set(m.key, { box: cleanBox(m.box), bots: cleanBots(m.bots) });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Connections. Admission happens here, after the hello (see rpc.mjs).
|
|
213
|
+
// key: the key this hello goes to, which Noise proved before the first frame. The hello
|
|
214
|
+
// lists only my teams that key is an active member of here, each with the ops that admit
|
|
215
|
+
// me, and carries my name only when it lists a team. A key on none of my teams (a stranger,
|
|
216
|
+
// someone removed, a joiner) learns no team, member or name; a joiner gets the roster in
|
|
217
|
+
// the reply to elu.join. One admitted by this same exchange is not on my roster yet, so
|
|
218
|
+
// my name and project names reach them on the next connect.
|
|
219
|
+
hello(key) {
|
|
220
|
+
const proof = id => {
|
|
221
|
+
const { ops } = readLog(id), out = [], founder = ops.find(op => op.type === 'create');
|
|
222
|
+
if (!founder) return [];
|
|
223
|
+
let key = this.me.key;
|
|
224
|
+
while (key !== founder.by && out.length < 7) {
|
|
225
|
+
const admit = ops.filter(op => op.type === 'admit' && op.body.key === key).sort((a, b) => a.at - b.at)[0]; if (!admit) break;
|
|
226
|
+
out.push(admit, ...ops.filter(op => op.type === 'role' && op.body.key === admit.by).slice(0, 1)); key = admit.by;
|
|
227
|
+
}
|
|
228
|
+
return [founder, ...out].slice(0, 8);
|
|
229
|
+
};
|
|
230
|
+
const known = sharedTeams(this.me.key, key).slice(0, 32);
|
|
231
|
+
return { name: known.length ? this.me.name.slice(0, 64) : '', teams: known.map(state => ({ id: state.id, ...head(state.id), ops: proof(state.id) })), handles: [...this.openTo(key)].slice(0, 20), plans: true };
|
|
232
|
+
}
|
|
233
|
+
admit(hello, session) {
|
|
234
|
+
const key = session.key;
|
|
235
|
+
if (this.joins.has(key)) return 'member';
|
|
236
|
+
// A teammate admitted elsewhere proves it with the ops that admit it.
|
|
237
|
+
for (const entry of hello.teams) {
|
|
238
|
+
const state = team(entry.id);
|
|
239
|
+
// A key that is gone from the team gets no say in its log, here or through sync.
|
|
240
|
+
if (!state || !active(state, this.me.key) || state.members.some(m => m.key === key) || !Array.isArray(entry.ops)) continue;
|
|
241
|
+
try { const { added } = merge(entry.id, entry.ops.slice(0, 8)); if (added.length) this.forward(entry.id, added, key); } catch (error) { this.log(`proof: ${error.message}`); }
|
|
242
|
+
}
|
|
243
|
+
if (sharedTeams(this.me.key, key).length) return 'member';
|
|
244
|
+
return this.invitesOpen() ? 'join' : null;
|
|
245
|
+
}
|
|
246
|
+
invitesOpen() {
|
|
247
|
+
return this.myTeams().some(state => {
|
|
248
|
+
try { const file = teamPath(state.id, 'invites.json'); noSymlinks(file); return fs.existsSync(file) && readJson(file).some(row => row?.usedBy === null && row.expiresAt > Date.now()); } catch { return false; }
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
attach(socket, key) {
|
|
252
|
+
if (this.closed || !HEX64.test(key)) return void socket.destroy();
|
|
253
|
+
const session = new Session(socket, { key, hello: () => this.hello(key), admit: (hello, s) => this.admit(hello, s), methods: this.methods, log: text => this.log(text), onError: error => this.log(`handler: ${error.message}`) });
|
|
254
|
+
// A refusal to a trusted teammate keeps its paths; read per refusal.
|
|
255
|
+
session.trusted = () => peerPolicy(key).trusted;
|
|
256
|
+
session.on('admitted', () => this.admitted(session));
|
|
257
|
+
session.on('close', () => {
|
|
258
|
+
if (this.sessions.get(key) !== session) return;
|
|
259
|
+
this.sessions.delete(key); this.event(`@${this.name(key)} offline`);
|
|
260
|
+
this.plansTold?.delete(key); try { heardPlans(key); } catch { /* Their Tasks keep the time they were last told. */ }
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
admitted(session) {
|
|
264
|
+
const key = session.key, previous = this.sessions.get(key);
|
|
265
|
+
this.sessions.set(key, session);
|
|
266
|
+
if (previous && previous !== session) previous.close('replaced by a newer connection');
|
|
267
|
+
const handles = Array.isArray(session.remoteHello?.handles) ? session.remoteHello.handles.filter(h => typeof h === 'string' && /^[a-z0-9][a-z0-9-]{0,39}$/.test(h)).slice(0, 20) : [];
|
|
268
|
+
this.handles.set(key, handles);
|
|
269
|
+
for (const waiter of this.waiters.get(key) ?? []) waiter(session);
|
|
270
|
+
this.waiters.delete(key);
|
|
271
|
+
if (this.joins.has(key)) return;
|
|
272
|
+
this.event(`@${this.name(key)} online`);
|
|
273
|
+
this.sync(session).catch(error => this.log(`sync: ${error.message}`))
|
|
274
|
+
.then(() => reconnect(this.ctx, key)).catch(error => this.log(`reconnect: ${error.message}`));
|
|
275
|
+
// Sent even when empty, so a teammate drops rows of tasks that ended while apart.
|
|
276
|
+
this.announce(session);
|
|
277
|
+
this.tellPlans(session);
|
|
278
|
+
}
|
|
279
|
+
// Roster sync: where heads differ, each side lists its op ids and pushes what the other lacks.
|
|
280
|
+
// A hello lists only the teams its sender knows the receiver is on, so a team we share that
|
|
281
|
+
// their hello left out is one where they did not know me yet: it is synced too, so they get
|
|
282
|
+
// the ops they lack. A joiner already has the whole log from the reply to elu.join.
|
|
283
|
+
async sync(session) {
|
|
284
|
+
const listed = new Map((session.remoteHello?.teams ?? []).map(entry => [entry.id, entry.head]));
|
|
285
|
+
const ids = [...listed.keys()].filter(id => { const state = team(id); return state && active(state, this.me.key) && state.members.some(m => m.key === session.key); });
|
|
286
|
+
if (!session.joinAccepted) for (const state of sharedTeams(this.me.key, session.key)) if (!listed.has(state.id)) ids.push(state.id);
|
|
287
|
+
for (const teamId of ids) {
|
|
288
|
+
if (session.state === 'closed') return;
|
|
289
|
+
if (head(teamId).head === listed.get(teamId)) continue;
|
|
290
|
+
try {
|
|
291
|
+
const { ids: theirs } = await session.request('elu.roster.get', { teamId });
|
|
292
|
+
const missing = opsMissing(teamId, Array.isArray(theirs) ? theirs : []);
|
|
293
|
+
for (let i = 0; i < missing.length; i += 500) await session.request('elu.roster.push', { teamId, ops: missing.slice(i, i + 500) });
|
|
294
|
+
} catch (error) { this.log(`sync ${teamId.slice(0, 8)}: ${error.message}`); }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
forward(teamId, ops, except) {
|
|
298
|
+
const state = team(teamId); if (!state) return;
|
|
299
|
+
this.noteOps(teamId, ops);
|
|
300
|
+
for (const session of this.sessions.values()) {
|
|
301
|
+
if (session.key !== except && session.admitted && active(state, session.key)) session.request('elu.roster.push', { teamId, ops }).catch(() => {});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Ops written by another process (the CLI's relay op, an invite redeemed by a transient
|
|
305
|
+
// node) are pushed to connected teammates on the next tick. Ops this daemon merged or
|
|
306
|
+
// forwarded itself are noted first, so they are not pushed twice.
|
|
307
|
+
noteOps(teamId, ops) { const known = this.knownOps?.get(teamId); for (const op of ops) known?.add(op.id); }
|
|
308
|
+
forwardLocal() {
|
|
309
|
+
this.knownOps ??= new Map();
|
|
310
|
+
for (const state of this.myTeams()) {
|
|
311
|
+
const ids = readLog(state.id).ops, known = this.knownOps.get(state.id);
|
|
312
|
+
this.knownOps.set(state.id, new Set(ids.map(op => op.id)));
|
|
313
|
+
const fresh = known ? ids.filter(op => !known.has(op.id)) : [];
|
|
314
|
+
if (fresh.length) { this.forward(state.id, fresh); this.recheck(); }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// After a roster change: close teammates who are gone and dial new ones.
|
|
318
|
+
recheck() {
|
|
319
|
+
for (const session of [...this.sessions.values()]) if (!this.joins.has(session.key) && !sharedTeams(this.me.key, session.key).length) session.close('no longer a teammate');
|
|
320
|
+
this.dialAll(); this.forgetPlans();
|
|
321
|
+
}
|
|
322
|
+
extensions() {
|
|
323
|
+
const teamOf = (params, session) => {
|
|
324
|
+
if (!isObject(params) || !HEX64.test(params.teamId ?? '')) refuse('Choose a team.');
|
|
325
|
+
const state = team(params.teamId);
|
|
326
|
+
if (!state || !active(state, this.me.key) || !active(state, session.key)) refuse('That team is not shared with you.');
|
|
327
|
+
return state;
|
|
328
|
+
};
|
|
329
|
+
return {
|
|
330
|
+
'elu.join': (params, session) => {
|
|
331
|
+
if (!isObject(params) || !HEX64.test(params.teamId ?? '') || !HEX64.test(params.secret ?? '') || !validName(params.name)) refuse('This invite is not valid.');
|
|
332
|
+
const op = redeemInvite(params.teamId, params.secret, session.key, params.name);
|
|
333
|
+
this.event(`@${sanitize(params.name, 40)} joined`);
|
|
334
|
+
this.forward(params.teamId, [op], session.key);
|
|
335
|
+
session.joinAccepted = true; setImmediate(() => this.dialAll());
|
|
336
|
+
return { ops: readLog(params.teamId).ops };
|
|
337
|
+
},
|
|
338
|
+
'elu.roster.get': (params, session) => {
|
|
339
|
+
const state = teamOf(params, session), after = Number.isSafeInteger(params.after) ? params.after : 0;
|
|
340
|
+
return { teamId: state.id, ids: readLog(state.id).ops.filter(op => op.at > after).map(op => op.id) };
|
|
341
|
+
},
|
|
342
|
+
'elu.roster.push': (params, session) => {
|
|
343
|
+
const state = teamOf(params, session);
|
|
344
|
+
if (!Array.isArray(params.ops) || params.ops.length > 5000) refuse('Roster ops must be a list.');
|
|
345
|
+
const { added, rejected } = merge(state.id, params.ops);
|
|
346
|
+
if (added.length) { this.forward(state.id, added, session.key); setImmediate(() => this.recheck()); }
|
|
347
|
+
if (rejected.length) this.log(`roster push from ${session.key.slice(0, 8)}: ${rejected.length} refused`);
|
|
348
|
+
return { added: added.length, rejected: rejected.length };
|
|
349
|
+
},
|
|
350
|
+
// A teammate on a build from before bots sends only rows: no box name and no bots.
|
|
351
|
+
'elu.status': (params, session) => {
|
|
352
|
+
if (!session.admitted || !isObject(params)) return null;
|
|
353
|
+
saveRemoteStatus(session.key, params.rows); this.machines.set(session.key, { box: cleanBox(params.box), bots: cleanBots(params.bots) });
|
|
354
|
+
return null;
|
|
355
|
+
},
|
|
356
|
+
// A teammate's Tasks: the list replaces what they said before, each field cleaned on the way in.
|
|
357
|
+
'elu.plans': (params, session) => { if (session.admitted && isObject(params)) saveRemotePlans(session.key, params.tasks); return null; },
|
|
358
|
+
'elu.taskUpdate': (params, session) => { if (session.admitted && isObject(params)) applyTask(this.ctx, session.key, params.task); return null; },
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
waitSession(key, timeoutMs) {
|
|
362
|
+
const now = this.sessions.get(key); if (now?.admitted) return Promise.resolve(now);
|
|
363
|
+
return new Promise((resolve, reject) => {
|
|
364
|
+
const timer = setTimeout(() => { this.waiters.set(key, (this.waiters.get(key) ?? []).filter(w => w !== done)); reject(new Error('The inviter is not reachable. They must be online for you to join.')); }, timeoutMs);
|
|
365
|
+
const done = session => { clearTimeout(timer); resolve(session); };
|
|
366
|
+
this.waiters.set(key, [...this.waiters.get(key) ?? [], done]);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
// Invite join over the network: dial the inviter, call elu.join, store the log.
|
|
370
|
+
async join(code, { timeoutMs = 60000 } = {}) {
|
|
371
|
+
const invite = parseInvite(code);
|
|
372
|
+
if (invite.expiresAt <= Date.now()) refuse('This invite has expired.');
|
|
373
|
+
if (invite.inviter === this.me.key) refuse('This is your own invite.');
|
|
374
|
+
const known = team(invite.teamId);
|
|
375
|
+
if (known && active(known, this.me.key)) refuse('You are already on this team.');
|
|
376
|
+
this.joins.set(invite.inviter, invite);
|
|
377
|
+
try {
|
|
378
|
+
this.node.dial(invite.inviter);
|
|
379
|
+
const session = await this.waitSession(invite.inviter, timeoutMs);
|
|
380
|
+
const result = await session.request('elu.join', { teamId: invite.teamId, secret: invite.secret, name: this.me.name });
|
|
381
|
+
if (!Array.isArray(result?.ops)) refuse('The inviter sent no roster.');
|
|
382
|
+
merge(invite.teamId, result.ops);
|
|
383
|
+
const state = team(invite.teamId);
|
|
384
|
+
if (!state || !active(state, this.me.key)) refuse('The inviter did not admit you.');
|
|
385
|
+
this.event(`joined ${state.name}`);
|
|
386
|
+
return { id: state.id, name: state.name };
|
|
387
|
+
} finally {
|
|
388
|
+
this.joins.delete(invite.inviter);
|
|
389
|
+
const session = this.sessions.get(invite.inviter);
|
|
390
|
+
if (session && sharedTeams(this.me.key, invite.inviter).length) { this.admitted(session); } else session?.close('join ended');
|
|
391
|
+
this.dialAll();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// The local A2A endpoint (docs/peer-network.md, "Local A2A endpoint"): /a2a/peer/<name>
|
|
396
|
+
// becomes an ask to that teammate; /a2a queues an owner request in metadata.project.
|
|
397
|
+
startLocal() {
|
|
398
|
+
// A name that is not URL-safe is routed by its fingerprint, which teammate() also accepts.
|
|
399
|
+
const teammates = () => this.teammateKeys().map(key => { const name = this.name(key); return { name: /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name) ? name : key.slice(0, 8) }; });
|
|
400
|
+
return startLocalA2A({ writeState: true, card: ({ url }) => buildAgentCard({ name: this.me.name, shares: shares(), teammates: teammates(), url }), dispatch: (method, params, ctx) => this.localCall(method, params, ctx) });
|
|
401
|
+
}
|
|
402
|
+
// SendMessage never blocks here: it returns the Task SUBMITTED and GetTask follows it.
|
|
403
|
+
localCall(method, params, { peerName }) {
|
|
404
|
+
if (method === 'CancelTask') throw a2aError(ErrorCode.UNSUPPORTED_OPERATION, 'Cancel the request in ELU Code.');
|
|
405
|
+
const text = params.message?.parts?.filter(part => typeof part.text === 'string').map(part => part.text).join('\n\n') ?? '';
|
|
406
|
+
const project = params.metadata?.project ?? params.message?.metadata?.project ?? null;
|
|
407
|
+
if (method === 'SendMessage') {
|
|
408
|
+
// A client context is kept, never replaced; follow-ups to an existing task are not supported.
|
|
409
|
+
const contextId = params.message?.contextId ?? null;
|
|
410
|
+
if (params.message?.taskId) throw a2aError(ErrorCode.UNSUPPORTED_OPERATION, 'Follow-ups to a task are not supported. Send a new message in the same context.', { taskId: params.message.taskId });
|
|
411
|
+
if (contextId !== null && !/^[A-Za-z0-9_-]{1,128}$/.test(contextId)) throw a2aError(ErrorCode.INVALID_PARAMS, 'The context id may use only letters, digits, dashes and underscores.');
|
|
412
|
+
if (peerName) {
|
|
413
|
+
const kind = ['ask', 'review', 'handoff'].includes(peerExt(params.message)?.kind) ? peerExt(params.message).kind : 'ask';
|
|
414
|
+
const ask = createAsk(this.ctx, { to: peerName, kind, project: typeof project === 'string' ? project : null, text, threadId: contextId });
|
|
415
|
+
return a2aTask({ id: ask.id, contextId: ask.threadId });
|
|
416
|
+
}
|
|
417
|
+
const share = shares().find(row => row.handle === project) ?? registeredProjects().map(repo => ({ root: repo.root, handle: path.basename(repo.root) })).find(row => row.handle === project);
|
|
418
|
+
if (!share) refuse('Name a project in metadata.project.');
|
|
419
|
+
// An automatic request runs the personal default, capped at Approve for me.
|
|
420
|
+
const row = enqueue(share.root, text, { permissions: capMode(defaultMode(), 'auto') }); this.kick();
|
|
421
|
+
saveLocal({ id: row.id, root: share.root, contextId: contextId ?? row.id, taskId: null, createdAt: new Date().toISOString() });
|
|
422
|
+
return a2aTask({ id: row.id, contextId: contextId ?? row.id });
|
|
423
|
+
}
|
|
424
|
+
const ask = getAsk(params.id);
|
|
425
|
+
if (ask) {
|
|
426
|
+
const state = { pending: 'SUBMITTED', submitted: 'SUBMITTED', working: 'WORKING', held: 'WORKING', answered: 'COMPLETED', rejected: 'REJECTED', failed: 'FAILED', canceled: 'CANCELED', expired: 'FAILED' }[ask.state] ?? 'WORKING';
|
|
427
|
+
return a2aTask({ id: ask.id, contextId: ask.threadId, state: TaskState[state], statusMessage: ask.reason ?? undefined, artifacts: ask.answer ? [artifact({ name: 'Answer', text: ask.answer.text })] : undefined });
|
|
428
|
+
}
|
|
429
|
+
// An /a2a request: its row while the conductor keeps it, then the task it ran.
|
|
430
|
+
const local = getLocal(params.id), repos = registeredProjects();
|
|
431
|
+
for (const repo of local ? repos.filter(repo => repo.root === local.root) : repos) {
|
|
432
|
+
let row; try { row = pendingRequests(repo.root).find(r => r.id === params.id && !r.origin); } catch { continue; }
|
|
433
|
+
const taskId = row?.taskId ?? local?.taskId ?? null;
|
|
434
|
+
if (!row && !local) continue;
|
|
435
|
+
let reply = null; try { if (taskId) { const done = readTask(repo, taskId); reply = (done.reply ?? done.result)?.summary ?? null; } } catch { /* Not started. */ }
|
|
436
|
+
const state = !row ? reply ? 'COMPLETED' : 'FAILED' : row.state === 'done' ? (reply ? 'COMPLETED' : 'FAILED') : ['starting', 'running'].includes(row.state) ? 'WORKING' : 'SUBMITTED';
|
|
437
|
+
return a2aTask({ id: params.id, contextId: local?.contextId ?? params.id, state: TaskState[state], artifacts: state === 'COMPLETED' ? [artifact({ name: 'Answer', text: reply })] : undefined });
|
|
438
|
+
}
|
|
439
|
+
throw a2aError(ErrorCode.TASK_NOT_FOUND, 'No such task.', { taskId: params.id });
|
|
440
|
+
}
|
|
441
|
+
// Notes the task id of each /a2a request while its row still exists.
|
|
442
|
+
syncLocal() {
|
|
443
|
+
for (const local of locals().filter(row => !row.taskId)) {
|
|
444
|
+
let row; try { row = pendingRequests(local.root).find(r => r.id === local.id); } catch { continue; }
|
|
445
|
+
if (row?.taskId) saveLocal({ ...local, taskId: row.taskId });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// commands/<id>.json from the TUI and CLI; each result goes to commands/<id>.result.json.
|
|
450
|
+
commands() {
|
|
451
|
+
const folder = peerFile('commands'); noSymlinks(folder); if (!fs.existsSync(folder)) return;
|
|
452
|
+
const names = fs.readdirSync(folder);
|
|
453
|
+
// The TUI never waits for results, so results older than an hour are dropped.
|
|
454
|
+
for (const name of names.filter(name => name.endsWith('.result.json'))) { try { const file = path.join(folder, name); if (Date.now() - fs.lstatSync(file).mtimeMs > 3600000) fs.rmSync(file, { force: true }); } catch { /* Gone already. */ } }
|
|
455
|
+
for (const name of names.filter(name => name.endsWith('.json') && !name.endsWith('.result.json')).sort()) {
|
|
456
|
+
const id = name.slice(0, -5), file = path.join(folder, name);
|
|
457
|
+
if (!COMMAND.test(id) || this.running.has(id)) continue;
|
|
458
|
+
let command = null; try { noSymlinks(file); command = readJson(file); } catch { /* Answered as invalid below. */ }
|
|
459
|
+
fs.rmSync(file, { force: true });
|
|
460
|
+
this.running.add(id);
|
|
461
|
+
const finish = (ok, value) => { this.running.delete(id); try { atomic(path.join(folder, `${id}.result.json`), { id, ok, ...(ok ? { result: value ?? null } : { error: value }), at: new Date().toISOString() }); } catch (error) { this.log(`result: ${error.message}`); } };
|
|
462
|
+
Promise.resolve().then(() => this.command(command)).then(value => finish(true, value), error => finish(false, error.message ?? 'The command failed.'));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
async command(command) {
|
|
466
|
+
if (!isObject(command)) refuse('The command is not valid.');
|
|
467
|
+
const { type } = command, item = String(command.id ?? command.item ?? ''), [kind, ref] = item.includes(':') ? [item.slice(0, item.indexOf(':')), item.slice(item.indexOf(':') + 1)] : [null, item];
|
|
468
|
+
// Pictures ride only on a message the owner typed: the app that took it staged the copies.
|
|
469
|
+
const pictures = ['ask', 'handoff', 'teamAsk'].includes(type) ? stagedPictures(command.pictures) : null;
|
|
470
|
+
// bot: the teammate's shared bot the question is for; createAsk refuses it on a handoff.
|
|
471
|
+
const ask = kindOf => createAsk(this.ctx, { to: command.to, kind: kindOf, project: command.project ?? null, text: command.text, root: command.root ?? null, chatTaskId: command.chatTaskId ?? (kindOf === 'ask' ? command.taskId : null) ?? null, threadId: command.threadId ?? null, bot: command.bot ?? null, byOwner: true, pictures });
|
|
472
|
+
// pathsCut tells the TUI a path left as [path], so it can say why.
|
|
473
|
+
const sent = row => ({ askId: row.id, pathsCut: row.pathsCut === true, ...(row.pictures ? { pictures: row.pictures.list.length } : {}) });
|
|
474
|
+
const incoming = () => kind === 'incoming' || !kind && getRecord(ref);
|
|
475
|
+
switch (type) {
|
|
476
|
+
case 'ask': case 'handoff': return sent(ask(type));
|
|
477
|
+
case 'teamAsk': return { askIds: teamAsk(this.ctx, { project: command.project ?? null, text: command.text, root: command.root ?? null, chatTaskId: command.chatTaskId ?? command.taskId ?? null, team: command.team ?? null, channel: command.channel ?? null, byOwner: true, pictures }).map(row => row.id), ...(pictures ? { pictures: pictures.list.length } : {}) };
|
|
478
|
+
// A channel is a signed roster op, so the people in it get it from any teammate.
|
|
479
|
+
case 'channelSet': {
|
|
480
|
+
if (!this.myTeams().some(state => state.id === command.team)) refuse('Choose one of your teams.');
|
|
481
|
+
const { op, id } = setChannel(this.me, command.team, { id: command.id ?? null, name: command.name ?? null, members: command.members });
|
|
482
|
+
this.forward(command.team, [op], null); return { id };
|
|
483
|
+
}
|
|
484
|
+
case 'review': return { askId: createAsk(this.ctx, { to: command.to, kind: 'review', project: command.project ?? null, text: reviewText(command.root, command.taskId), root: command.root ?? null }).id };
|
|
485
|
+
case 'approve': case 'accept':
|
|
486
|
+
if (kind === 'held') { const task = sendReply(this.ctx, ref, { text: command.text }); return { task, pathsCut: getRecord(ref)?.reply?.pathsCut === true }; }
|
|
487
|
+
// y: answer it read-only, whatever the wire kind.
|
|
488
|
+
if (incoming()) return { task: acceptIncoming(this.ctx, ref, { how: 'answer' }) };
|
|
489
|
+
if (kind === 'draft') return sent(sendDraft(this.ctx, ref, { byOwner: true }));
|
|
490
|
+
refuse('Nothing to approve there.');
|
|
491
|
+
case 'work':
|
|
492
|
+
// w: the owner's own session in the mode, place and folders they picked.
|
|
493
|
+
if (!incoming()) refuse('Nothing to work on there.');
|
|
494
|
+
return { task: acceptIncoming(this.ctx, ref, { how: 'work', permissions: command.permissions, place: command.place, folders: command.folders }) };
|
|
495
|
+
case 'discard':
|
|
496
|
+
if (kind === 'held') return { task: discardReply(this.ctx, ref) };
|
|
497
|
+
if (incoming()) return { task: this.decline(ref) };
|
|
498
|
+
if (kind === 'draft') return { draft: discardDraft(ref).id };
|
|
499
|
+
if (['answer', 'asked', 'expired', 'rejected'].includes(kind)) return { askId: dismissAsk(this.ctx, ref).id };
|
|
500
|
+
if (kind === 'team') return { askIds: dismissTeam(this.ctx, ref).map(row => row.id) };
|
|
501
|
+
refuse('Nothing to discard there.');
|
|
502
|
+
case 'continue': return { requestId: continueAnswer(this.ctx, kind ? ref : item).id };
|
|
503
|
+
case 'mute': case 'unmute':
|
|
504
|
+
if (!HEX64.test(command.key ?? '') || !teams().some(state => state.members.some(m => m.key === command.key))) refuse('Choose a teammate to mute.');
|
|
505
|
+
return { key: command.key, muted: savePeerPolicy(command.key, { muted: type === 'mute' }).muted };
|
|
506
|
+
case 'trust': case 'untrust':
|
|
507
|
+
// Trust is mine about a teammate on my roster; it is never sent to them.
|
|
508
|
+
if (!HEX64.test(command.key ?? '') || !this.teammateKeys().includes(command.key)) refuse('Choose a teammate to trust.');
|
|
509
|
+
return { key: command.key, trusted: savePeerPolicy(command.key, { trusted: type === 'trust' }).trusted };
|
|
510
|
+
case 'join': return this.join(command.code);
|
|
511
|
+
case 'shutdown':
|
|
512
|
+
// peer off then peer on within one tick leaves enabled true, and a shutdown left over
|
|
513
|
+
// from a daemon that died before reading it predates this one: both are ignored.
|
|
514
|
+
if (this.requireEnabled && peerSettings().enabled || Date.parse(command.at) < Date.parse(this.startedAt)) return { stopping: false };
|
|
515
|
+
setImmediate(() => this.onExit ? this.onExit() : this.stop()); return { stopping: true };
|
|
516
|
+
default: refuse('Unknown peer command.');
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// The board may remove the request before the decline arrives; the record is then already rejected.
|
|
520
|
+
decline(id) {
|
|
521
|
+
const record = getRecord(id);
|
|
522
|
+
if (record && !record.waiting && record.task.status.state === TaskState.REJECTED) return record.task;
|
|
523
|
+
return declineIncoming(this.ctx, id);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import { atomic, isObject, noSymlinks, readJson, refuse } from '../util.mjs';
|
|
4
|
+
import { waitLock } from '../state.mjs';
|
|
5
|
+
import { peerFile, peerRepo } from './paths.mjs';
|
|
6
|
+
import { base32, canonical, publicKeyHex, unbase32 } from './canonical.mjs';
|
|
7
|
+
|
|
8
|
+
// One ed25519 identity per install. The seed is the team signing key and, through
|
|
9
|
+
// hyperdht, the transport key.
|
|
10
|
+
// The fingerprint is the first 8 hex characters of the public key, for people to compare.
|
|
11
|
+
const file = () => peerFile('identity.json');
|
|
12
|
+
const EXPORT_PREFIX = 'elu-id:v1:';
|
|
13
|
+
const INVISIBLE = /[---]/;
|
|
14
|
+
// A display name: 1 to max printable characters, no controls, bidi overrides or zero-width.
|
|
15
|
+
export function validName(value, max = 40) {
|
|
16
|
+
return typeof value === 'string' && value === value.trim() && [...value].length >= 1 && [...value].length <= max
|
|
17
|
+
&& !/[\x00-\x1f\x7f-\x9f]/.test(value) && !INVISIBLE.test(value);
|
|
18
|
+
}
|
|
19
|
+
function checkName(name) { if (!validName(name)) refuse('Choose a name of 1 to 40 printable characters.'); return name; }
|
|
20
|
+
function seedOf(value) {
|
|
21
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]{43}=$/.test(value)) return null;
|
|
22
|
+
const seed = Buffer.from(value, 'base64'); return seed.length === 32 && seed.toString('base64') === value ? seed : null;
|
|
23
|
+
}
|
|
24
|
+
function save(record) { atomic(file(), record); fs.chmodSync(file(), 0o600); }
|
|
25
|
+
// The seed is non-enumerable, so JSON output, spreads and console logging of an
|
|
26
|
+
// identity never carry it by accident.
|
|
27
|
+
function derive(record) {
|
|
28
|
+
const seed = seedOf(record.seed), key = publicKeyHex(seed), out = { name: record.name, key, fingerprint: key.slice(0, 8), createdAt: record.createdAt };
|
|
29
|
+
Object.defineProperty(out, 'seed', { value: seed });
|
|
30
|
+
return Object.freeze(out);
|
|
31
|
+
}
|
|
32
|
+
// A file or code written by an earlier build may carry a field this build does not use.
|
|
33
|
+
// It is accepted, and dropped the next time the identity is saved.
|
|
34
|
+
const FIELDS = ['version', 'name', 'seed', 'createdAt'], UNUSED = ['nostrSecret'];
|
|
35
|
+
const kept = record => Object.fromEntries(Object.entries(record).filter(([key]) => FIELDS.includes(key)));
|
|
36
|
+
function validRecord(value) {
|
|
37
|
+
return isObject(value) && Object.keys(value).every(key => FIELDS.includes(key) || UNUSED.includes(key))
|
|
38
|
+
&& value.version === 1 && validName(value.name) && seedOf(value.seed)
|
|
39
|
+
&& typeof value.createdAt === 'string' && !Number.isNaN(Date.parse(value.createdAt));
|
|
40
|
+
}
|
|
41
|
+
export function loadIdentity() {
|
|
42
|
+
noSymlinks(file());
|
|
43
|
+
if (!fs.existsSync(file())) return null;
|
|
44
|
+
let value; try { value = readJson(file()); } catch { refuse('The peer identity file is unreadable.'); }
|
|
45
|
+
if (!validRecord(value)) refuse('The peer identity file is invalid.');
|
|
46
|
+
return derive(value);
|
|
47
|
+
}
|
|
48
|
+
export function identity() { return loadIdentity() ?? refuse('Create your peer identity first with elu id --name NAME.'); }
|
|
49
|
+
function locked(fn) { const release = waitLock(peerRepo(), 'identity'); try { return fn(); } finally { release(); } }
|
|
50
|
+
export function createIdentity({ name } = {}) {
|
|
51
|
+
checkName(name);
|
|
52
|
+
return locked(() => {
|
|
53
|
+
if (loadIdentity()) refuse('A peer identity already exists.');
|
|
54
|
+
save({ version: 1, name, seed: crypto.randomBytes(32).toString('base64'), createdAt: new Date().toISOString() });
|
|
55
|
+
return identity();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export function renameIdentity(name) {
|
|
59
|
+
checkName(name);
|
|
60
|
+
return locked(() => { identity(); save({ ...kept(readJson(file())), name }); return identity(); });
|
|
61
|
+
}
|
|
62
|
+
export function exportIdentity() {
|
|
63
|
+
const id = identity();
|
|
64
|
+
return EXPORT_PREFIX + base32(Buffer.from(canonical({ seed: id.seed.toString('base64'), name: id.name })));
|
|
65
|
+
}
|
|
66
|
+
export function parseIdentityCode(code) {
|
|
67
|
+
const bad = () => refuse('This identity code is not valid.');
|
|
68
|
+
if (typeof code !== 'string' || code.length > 800 || !code.startsWith(EXPORT_PREFIX)) bad();
|
|
69
|
+
const bytes = unbase32(code.slice(EXPORT_PREFIX.length)); if (!bytes) bad();
|
|
70
|
+
let value; try { value = JSON.parse(bytes.toString('utf8')); } catch { bad(); }
|
|
71
|
+
if (!isObject(value) || !['name,seed', 'name,nostrSecret,seed'].includes(Object.keys(value).sort().join()) || !seedOf(value.seed) || !validName(value.name) || canonical(value) !== bytes.toString('utf8')) bad();
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
export function importIdentity(code, { replace = false } = {}) {
|
|
75
|
+
const value = parseIdentityCode(code);
|
|
76
|
+
return locked(() => {
|
|
77
|
+
if (!replace && loadIdentity()) refuse('A peer identity already exists. Replace it explicitly to import another.');
|
|
78
|
+
save({ version: 1, name: value.name, seed: value.seed, createdAt: new Date().toISOString() });
|
|
79
|
+
return identity();
|
|
80
|
+
});
|
|
81
|
+
}
|