@platys/relay 0.0.0-stage → 0.8.0-alpha.11
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/README.md +118 -2
- package/next/CLAUDE_CHANNEL.md +110 -0
- package/next/EASY_SETUP.md +106 -0
- package/next/REMOTE_MCP.md +194 -0
- package/next/TROUBLESHOOTING.md +72 -0
- package/out/core/budget.js +126 -0
- package/out/core/collaboration.js +170 -0
- package/out/core/companion.js +86 -0
- package/out/core/dashboard-page.js +69 -0
- package/out/core/dashboard.js +104 -0
- package/out/core/remote-connect.js +19 -0
- package/out/core/remote-hooks.js +21 -0
- package/out/core/remote-mcp.js +258 -0
- package/out/core/room.js +272 -0
- package/out/core/types.js +52 -0
- package/out/core/version.js +1 -0
- package/out/node/adapters.js +64 -0
- package/out/node/artifacts.js +151 -0
- package/out/node/budget-operator.js +212 -0
- package/out/node/claude-channel.js +234 -0
- package/out/node/cli.js +358 -0
- package/out/node/client.js +80 -0
- package/out/node/connector.js +171 -0
- package/out/node/context.js +59 -0
- package/out/node/diagnostics.js +45 -0
- package/out/node/git.js +70 -0
- package/out/node/hooks.js +113 -0
- package/out/node/hosted-connect.js +41 -0
- package/out/node/hub.js +101 -0
- package/out/node/install.js +64 -0
- package/out/node/integration.js +128 -0
- package/out/node/maintenance.js +82 -0
- package/out/node/network-cli.js +279 -0
- package/out/node/network-install.js +56 -0
- package/out/node/network-runtime.js +90 -0
- package/out/node/network-setup.js +121 -0
- package/out/node/network-state.js +61 -0
- package/out/node/remote-companion-install.js +44 -0
- package/out/node/remote-companion-mcp.js +38 -0
- package/out/node/remote-companion.js +192 -0
- package/out/node/remote-hooks-install.js +25 -0
- package/out/node/remote-setup.js +141 -0
- package/out/node/request-budget.js +50 -0
- package/out/node/setup-page.js +26 -0
- package/out/node/setup-web.js +129 -0
- package/out/node/sqlite.js +33 -0
- package/out/node/state.js +113 -0
- package/out/node/terminal.js +28 -0
- package/out/node/tools.js +204 -0
- package/out/node/workflow.js +60 -0
- package/package.json +65 -4
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { loadConnection } from './state.js';
|
|
4
|
+
import { Remote } from './client.js';
|
|
5
|
+
import { receiveArtifact, sha } from './artifacts.js';
|
|
6
|
+
export async function backupProject(state) {
|
|
7
|
+
const remote = new Remote(loadConnection(state.dir)), snapshot = await remote.call('snapshot');
|
|
8
|
+
const events = [];
|
|
9
|
+
let cursor = 0;
|
|
10
|
+
while (true) {
|
|
11
|
+
const page = await remote.events(cursor);
|
|
12
|
+
events.push(...page.events);
|
|
13
|
+
if (page.events.length)
|
|
14
|
+
cursor = page.events.at(-1).seq;
|
|
15
|
+
if (page.events.length < 200)
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
const artifacts = [];
|
|
19
|
+
for (const artifact of (await remote.call('artifacts')).artifacts) {
|
|
20
|
+
if (!artifact.sealed)
|
|
21
|
+
continue;
|
|
22
|
+
const received = await receiveArtifact(state, artifact.id);
|
|
23
|
+
artifacts.push({ manifest: artifact, payload: readFileSync(received.file, 'utf8') });
|
|
24
|
+
}
|
|
25
|
+
const data = JSON.stringify({ version: 1, at: new Date().toISOString(), projectId: loadConnection(state.dir).projectId, snapshot, events, artifacts, cursor, coverage: 'Point-in-time paginated export; concurrent writes may follow this cursor. No membership tokens or provider credentials.' });
|
|
26
|
+
const dir = join(state.dir, 'backups');
|
|
27
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
28
|
+
const file = join(dir, new Date().toISOString().replace(/[:.]/g, '-') + '.json');
|
|
29
|
+
writeFileSync(file, data, { mode: 0o600 });
|
|
30
|
+
writeFileSync(file + '.sha256', sha(data), { mode: 0o600 });
|
|
31
|
+
return { file, sha256: sha(data), events: events.length, artifacts: artifacts.length, cursor };
|
|
32
|
+
}
|
|
33
|
+
export async function pruneArtifacts(state, sessionId, backupFile, days = 7) {
|
|
34
|
+
if (!Number.isInteger(days) || days < 1)
|
|
35
|
+
throw new Error('Retention must be at least one day');
|
|
36
|
+
const data = readFileSync(backupFile, 'utf8');
|
|
37
|
+
if (!existsSync(backupFile + '.sha256') || sha(data) !== readFileSync(backupFile + '.sha256', 'utf8'))
|
|
38
|
+
throw new Error('Backup integrity check failed');
|
|
39
|
+
const saved = JSON.parse(data), config = loadConnection(state.dir);
|
|
40
|
+
if (saved.projectId !== config.projectId)
|
|
41
|
+
throw new Error('Backup belongs to another project');
|
|
42
|
+
const remote = new Remote(config);
|
|
43
|
+
const current = await remote.call('snapshot');
|
|
44
|
+
const referenced = new Set();
|
|
45
|
+
let cursor = 0;
|
|
46
|
+
while (true) {
|
|
47
|
+
const page = await remote.events(cursor);
|
|
48
|
+
for (const e of page.events) {
|
|
49
|
+
if (e.artifactId && e.kind !== 'artifact')
|
|
50
|
+
referenced.add(e.artifactId);
|
|
51
|
+
}
|
|
52
|
+
if (page.events.length)
|
|
53
|
+
cursor = page.events.at(-1).seq;
|
|
54
|
+
if (page.events.length < 200)
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
if (current.acceptedRevision?.artifactId)
|
|
58
|
+
referenced.add(current.acceptedRevision.artifactId);
|
|
59
|
+
const removed = [];
|
|
60
|
+
for (const item of saved.artifacts) {
|
|
61
|
+
if (referenced.has(item.manifest.id) || Date.parse(item.manifest.createdAt) > Date.now() - days * 86400000)
|
|
62
|
+
continue;
|
|
63
|
+
if (sha(item.payload) !== item.manifest.sha256)
|
|
64
|
+
throw new Error('Archived artifact is corrupt');
|
|
65
|
+
await remote.call('artifacts', { operation: 'delete', sessionId, id: item.manifest.id, archivedHash: item.manifest.sha256 });
|
|
66
|
+
removed.push(item.manifest.id);
|
|
67
|
+
}
|
|
68
|
+
const archivedTasks = [];
|
|
69
|
+
for (const task of saved.snapshot.tasks ?? []) {
|
|
70
|
+
if (!['complete', 'released'].includes(task.status) || Date.parse(task.updatedAt) > Date.now() - days * 86400000)
|
|
71
|
+
continue;
|
|
72
|
+
try {
|
|
73
|
+
await remote.call('taskarchive', { id: task.id, archivedTask: task });
|
|
74
|
+
archivedTasks.push(task.id);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error.status !== 409)
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { removed, archivedTasks, preserved: 'Referenced artifacts, current accepted integration, and recent artifacts. Event history is retained.' };
|
|
82
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, openSync, closeSync, unlinkSync, realpathSync, linkSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { loadConnection, saveConnection, State } from './state.js';
|
|
6
|
+
import { api, Remote, serverURL } from './client.js';
|
|
7
|
+
import { Connector } from './connector.js';
|
|
8
|
+
import { nativeDelivery } from './adapters.js';
|
|
9
|
+
import { handleHook } from './hooks.js';
|
|
10
|
+
import { callRelayTool, serveMcp } from './tools.js';
|
|
11
|
+
import { applyNetworkInstall, planNetworkInstall } from './network-install.js';
|
|
12
|
+
import { networkDir, sharedWorkspace } from './network-state.js';
|
|
13
|
+
import { VERSION } from '../core/version.js';
|
|
14
|
+
import { runtimeNode } from './network-runtime.js';
|
|
15
|
+
const alive = (pid) => { try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
} };
|
|
22
|
+
function connectorRunning(dir) {
|
|
23
|
+
try {
|
|
24
|
+
const lock = JSON.parse(readFileSync(join(dir, 'connector.lock'), 'utf8'));
|
|
25
|
+
return typeof lock.pid === 'number' && alive(lock.pid);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function startNetwork(dir, cli, explicit = false) {
|
|
32
|
+
const stop = join(dir, 'stop');
|
|
33
|
+
if (existsSync(stop)) {
|
|
34
|
+
if (!explicit)
|
|
35
|
+
return;
|
|
36
|
+
unlinkSync(stop);
|
|
37
|
+
}
|
|
38
|
+
if (connectorRunning(dir)) {
|
|
39
|
+
const current = JSON.parse(readFileSync(join(dir, 'connector.lock'), 'utf8'));
|
|
40
|
+
if (current.version === VERSION && current.cli === cli)
|
|
41
|
+
return;
|
|
42
|
+
writeFileSync(stop, 'upgrade');
|
|
43
|
+
for (let i = 0; i < 50 && connectorRunning(dir); i++)
|
|
44
|
+
await new Promise(r => setTimeout(r, 100));
|
|
45
|
+
if (connectorRunning(dir))
|
|
46
|
+
throw new Error('Network connector has not stopped for upgrade; retry after it exits');
|
|
47
|
+
if (existsSync(stop))
|
|
48
|
+
unlinkSync(stop);
|
|
49
|
+
}
|
|
50
|
+
const fd = openSync(join(dir, 'connector.log'), 'a', 0o600);
|
|
51
|
+
try {
|
|
52
|
+
const child = spawn(runtimeNode(cli), [cli, 'run', '--global', '--dir', dir], { cwd: dir, windowsHide: true, detached: true, stdio: ['ignore', fd, fd], env: { ...process.env, RELAY_CREATE_KEY: undefined } });
|
|
53
|
+
child.on('error', error => console.error(`Relay Network could not start: ${error.message}`));
|
|
54
|
+
child.unref();
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
closeSync(fd);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function runNetwork(dir, cli) {
|
|
61
|
+
const config = loadConnection(dir);
|
|
62
|
+
if (config.mode !== 'network')
|
|
63
|
+
throw new Error('This is not a network connection');
|
|
64
|
+
const lock = join(dir, 'connector.lock');
|
|
65
|
+
if (existsSync(lock)) {
|
|
66
|
+
if (connectorRunning(dir))
|
|
67
|
+
throw new Error('Relay Network connector is already running');
|
|
68
|
+
unlinkSync(lock);
|
|
69
|
+
}
|
|
70
|
+
// Publish a complete record atomically, while retaining exclusive acquisition.
|
|
71
|
+
// Opening the public lock with wx first briefly exposed an empty file to other sessions.
|
|
72
|
+
const pending = join(dir, `connector-${crypto.randomUUID()}.pending`);
|
|
73
|
+
writeFileSync(pending, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), version: VERSION, cli }), { flag: 'wx', mode: 0o600 });
|
|
74
|
+
try {
|
|
75
|
+
linkSync(pending, lock);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
unlinkSync(pending);
|
|
79
|
+
}
|
|
80
|
+
const state = new State(dir);
|
|
81
|
+
state.set('mode', 'network');
|
|
82
|
+
const connector = new Connector(state, new Remote(config), nativeDelivery(dir));
|
|
83
|
+
try {
|
|
84
|
+
connector.start();
|
|
85
|
+
await new Promise(done => {
|
|
86
|
+
const stop = () => { clearInterval(timer); process.off('SIGINT', stop); process.off('SIGTERM', stop); done(); };
|
|
87
|
+
const timer = setInterval(() => { if (existsSync(join(dir, 'stop')))
|
|
88
|
+
stop(); }, 500);
|
|
89
|
+
process.once('SIGINT', stop);
|
|
90
|
+
process.once('SIGTERM', stop);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await connector.stop();
|
|
95
|
+
state.close();
|
|
96
|
+
unlinkSync(lock);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function changeSharing(state, workspace, all, share) {
|
|
100
|
+
const previous = state.get('sharing') ?? { all: false, roots: [] };
|
|
101
|
+
let policy;
|
|
102
|
+
if (all)
|
|
103
|
+
policy = { all: share, roots: [], excluded: [] };
|
|
104
|
+
else {
|
|
105
|
+
if (!workspace)
|
|
106
|
+
throw new Error('Choose --repo PATH or --all-workspaces');
|
|
107
|
+
const path = realpathSync(workspace);
|
|
108
|
+
policy = { ...previous, roots: share ? [...new Set([...previous.roots, path])] : previous.roots.filter(r => r !== path), excluded: share ? (previous.excluded ?? []).filter(r => r !== path) : [...new Set([...(previous.excluded ?? []), path])] };
|
|
109
|
+
}
|
|
110
|
+
state.set('sharing', policy);
|
|
111
|
+
for (const binding of state.bindings())
|
|
112
|
+
if (binding.workspace && !sharedWorkspace(state, binding.workspace))
|
|
113
|
+
state.bind({ ...binding, state: 'closed', updatedAt: new Date().toISOString() });
|
|
114
|
+
return policy;
|
|
115
|
+
}
|
|
116
|
+
export async function networkMain(cmd, values, cli) {
|
|
117
|
+
const option = (name) => typeof values[name] === 'string' ? values[name] : undefined;
|
|
118
|
+
const dir = resolve(option('dir') ?? networkDir());
|
|
119
|
+
if (cmd === 'hook') {
|
|
120
|
+
let data = '';
|
|
121
|
+
for await (const chunk of process.stdin) {
|
|
122
|
+
data += chunk;
|
|
123
|
+
if (data.length > 1_000_000)
|
|
124
|
+
throw new Error('Hook input too large');
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const input = JSON.parse(data);
|
|
128
|
+
const provider = option('provider');
|
|
129
|
+
if (provider !== 'codex' && provider !== 'claude')
|
|
130
|
+
throw new Error('Unknown provider');
|
|
131
|
+
const result = handleHook(String(input.cwd ?? process.cwd()), provider, input, process.env, dir);
|
|
132
|
+
if (input.hook_event_name === 'SessionStart')
|
|
133
|
+
try {
|
|
134
|
+
await startNetwork(dir, cli);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
console.error(`Relay Network connector unavailable: ${error.message}`);
|
|
138
|
+
}
|
|
139
|
+
console.log(JSON.stringify(result));
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
console.error(`Relay Network hook unavailable: ${error.message}`);
|
|
143
|
+
console.log('{}');
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (cmd === 'connect') {
|
|
148
|
+
const provider = option('provider') ?? 'both';
|
|
149
|
+
if (!['codex', 'claude', 'both'].includes(provider))
|
|
150
|
+
throw new Error('Provider must be codex, claude or both');
|
|
151
|
+
const plans = values['no-install'] ? [] : (provider === 'both' ? ['codex', 'claude'] : [provider]).map(p => planNetworkInstall(p, cli, dir, undefined, runtimeNode(cli)));
|
|
152
|
+
let config;
|
|
153
|
+
if (existsSync(join(dir, 'connection.json'))) {
|
|
154
|
+
if (option('invite') || option('server'))
|
|
155
|
+
throw new Error('A network is already connected. Re-run without --server/--invite to update the installation.');
|
|
156
|
+
config = loadConnection(dir);
|
|
157
|
+
if (config.mode !== 'network')
|
|
158
|
+
throw new Error('Directory belongs to a repository connection');
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
const name = option('name') ?? hostname();
|
|
162
|
+
let projectId = crypto.randomUUID(), server, identity = 'relay-network';
|
|
163
|
+
let credentials;
|
|
164
|
+
if (option('invite')) {
|
|
165
|
+
const encoded = option('invite');
|
|
166
|
+
if (!encoded.startsWith('relay:network:v1:'))
|
|
167
|
+
throw new Error('Use a Relay Network invitation, not a repository invitation');
|
|
168
|
+
const invitation = JSON.parse(Buffer.from(encoded.slice('relay:network:v1:'.length), 'base64url').toString('utf8'));
|
|
169
|
+
server = serverURL(invitation.server);
|
|
170
|
+
projectId = invitation.projectId;
|
|
171
|
+
identity = invitation.repository;
|
|
172
|
+
const joined = await api(server, projectId, '', 'join', { code: invitation.code, name, repository: identity });
|
|
173
|
+
if (joined.project?.mode !== 'network')
|
|
174
|
+
throw new Error('The service does not support this network. Upgrade Relay first.');
|
|
175
|
+
credentials = { token: joined.token, memberId: joined.memberId };
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const url = option('server') ?? process.env.RELAY_SERVER_URL;
|
|
179
|
+
if (!url)
|
|
180
|
+
throw new Error('Supply --server HTTPS_URL or --invite CODE');
|
|
181
|
+
const key = option('create-key') ?? process.env.RELAY_CREATE_KEY;
|
|
182
|
+
if (!key)
|
|
183
|
+
throw new Error('Network creation requires RELAY_CREATE_KEY; friends join using --invite');
|
|
184
|
+
server = serverURL(url);
|
|
185
|
+
identity = 'network:' + projectId;
|
|
186
|
+
credentials = await api(server, projectId, key, 'create', { name: `${name}'s network`.slice(0, 80), ownerName: name, repository: identity, mode: 'network' });
|
|
187
|
+
const info = await api(server, projectId, credentials.token, 'snapshot');
|
|
188
|
+
if (info.project.mode !== 'network')
|
|
189
|
+
throw new Error('The service does not support networks. Upgrade Relay first.');
|
|
190
|
+
}
|
|
191
|
+
config = { ...credentials, server, projectId, repository: identity, name, repo: dir, mode: 'network' };
|
|
192
|
+
saveConnection(dir, config);
|
|
193
|
+
}
|
|
194
|
+
const state = new State(dir);
|
|
195
|
+
try {
|
|
196
|
+
state.set('mode', 'network');
|
|
197
|
+
if (values['all-workspaces'])
|
|
198
|
+
changeSharing(state, undefined, true, true);
|
|
199
|
+
else if (option('repo'))
|
|
200
|
+
changeSharing(state, option('repo'), false, true);
|
|
201
|
+
else if (!state.get('sharing'))
|
|
202
|
+
changeSharing(state, undefined, true, true);
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
state.close();
|
|
206
|
+
}
|
|
207
|
+
for (const plan of plans)
|
|
208
|
+
applyNetworkInstall(plan, dir);
|
|
209
|
+
if (!values['no-start'])
|
|
210
|
+
await startNetwork(dir, cli, true);
|
|
211
|
+
if (!values.quiet)
|
|
212
|
+
console.log(`Relay Network connected for ${config.name}. Resume your coding clients once and review their MCP/hooks.\nYour agent sessions register automatically. Use relay-next invite --global to add a friend.`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const config = loadConnection(dir);
|
|
216
|
+
if (config.mode !== 'network')
|
|
217
|
+
throw new Error('This is not a network connection');
|
|
218
|
+
if (cmd === 'run') {
|
|
219
|
+
if (existsSync(join(dir, 'stop')))
|
|
220
|
+
unlinkSync(join(dir, 'stop'));
|
|
221
|
+
await runNetwork(dir, cli);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (cmd === 'stop') {
|
|
225
|
+
writeFileSync(join(dir, 'stop'), 'stop');
|
|
226
|
+
console.log('Relay Network stopped until connect --global or run --global resumes it.');
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (cmd === 'budget-refresh') {
|
|
230
|
+
console.log(JSON.stringify(await new Remote(config).refreshBudget(), null, 2));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (cmd === 'invite') {
|
|
234
|
+
const invitation = await new Remote(config).call('invite', {});
|
|
235
|
+
const code = 'relay:network:v1:' + Buffer.from(JSON.stringify({ server: config.server, projectId: config.projectId, repository: config.repository, code: invitation.code })).toString('base64url');
|
|
236
|
+
console.log(`Single-use network invitation; expires ${new Date(invitation.expiresAt).toISOString()}:\n${code}\n\nFriend: relay setup --invite ${code}`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (cmd === 'members') {
|
|
240
|
+
console.log(JSON.stringify((await new Remote(config).call('snapshot')).members, null, 2));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (cmd === 'revoke') {
|
|
244
|
+
if (!option('member'))
|
|
245
|
+
throw new Error('Supply --member ID');
|
|
246
|
+
console.log(JSON.stringify(await new Remote(config).call('revoke', { memberId: option('member') })));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const state = new State(dir);
|
|
250
|
+
state.set('mode', 'network');
|
|
251
|
+
try {
|
|
252
|
+
if (cmd === 'share' || cmd === 'unshare') {
|
|
253
|
+
console.log(JSON.stringify(changeSharing(state, option('repo'), values['all-workspaces'] === true, cmd === 'share'), null, 2));
|
|
254
|
+
await startNetwork(dir, cli);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (cmd === 'mcp') {
|
|
258
|
+
await startNetwork(dir, cli);
|
|
259
|
+
await serveMcp(state);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (cmd === 'doctor' || cmd === 'status') {
|
|
263
|
+
console.log(JSON.stringify({ mode: 'network', name: config.name, connectorRunning: connectorRunning(dir), paused: existsSync(join(dir, 'stop')), sharing: state.get('sharing'), localSessions: state.bindings().map(b => ({ id: b.id, provider: b.provider, state: b.state, task: b.task })), lastSync: state.get('lastSync') ?? null, syncError: state.get('syncError') ?? null, snapshot: state.get('snapshot') ?? null, pendingUploads: state.pending().length }, null, 2));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const sessionId = option('session') ?? option('from');
|
|
267
|
+
if (!sessionId)
|
|
268
|
+
throw new Error('Supply --session ID from relay-next status --global');
|
|
269
|
+
const tool = { discover: 'relay_discover', context: 'relay_context', ask: 'relay_ask', reply: 'relay_reply', presence: 'relay_presence' }[cmd];
|
|
270
|
+
if (!tool)
|
|
271
|
+
throw new Error('Unknown network command; use connect, invite, members, revoke, share, unshare, status, doctor, run, stop, discover, context, ask, reply or presence');
|
|
272
|
+
const args = { ...(option('args') ? JSON.parse(option('args')) : {}), session_id: sessionId, ...(cmd === 'discover' ? { ...(option('name') ? { person: option('name') } : {}), ...(option('query') ? { topic: option('query') } : {}) } : {}), ...(cmd === 'context' && option('query') ? { query: option('query') } : {}), ...(cmd === 'ask' ? { target_session_id: option('to'), question: option('message') } : {}), ...(cmd === 'presence' && option('message') ? { task: option('message') } : {}), ...(cmd === 'reply' ? { question_id: option('id'), answer: option('message') } : {}) };
|
|
273
|
+
console.log(JSON.stringify(await callRelayTool(state, tool, { ...args, session_key: state.binding(sessionId).sessionKey }), null, 2));
|
|
274
|
+
await startNetwork(dir, cli);
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
state.close();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, resolve } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { quote, writeConfig } from './install.js';
|
|
5
|
+
export function planNetworkInstall(provider, cli, dir, home = homedir(), node = process.execPath, codexHome = process.env.CODEX_HOME ?? join(home, '.codex'), claudeHome = process.env.CLAUDE_CONFIG_DIR ?? join(home, '.claude')) {
|
|
6
|
+
const hookPath = provider === 'codex' ? join(codexHome, 'hooks.json') : join(claudeHome, 'settings.json');
|
|
7
|
+
const customClaude = !!process.env.CLAUDE_CONFIG_DIR || resolve(claudeHome) !== resolve(join(home, '.claude'));
|
|
8
|
+
const mcpPath = provider === 'codex' ? join(codexHome, 'config.toml') : join(customClaude ? claudeHome : home, '.claude.json');
|
|
9
|
+
for (const target of [home, codexHome, claudeHome, dir, join(dir, 'config-backups'), hookPath, mcpPath]) {
|
|
10
|
+
let current = target;
|
|
11
|
+
while (true) {
|
|
12
|
+
if (existsSync(current) && lstatSync(current).isSymbolicLink())
|
|
13
|
+
throw new Error(`Relay will not install through a symlink: ${current}`);
|
|
14
|
+
const parent = dirname(current);
|
|
15
|
+
if (parent === current)
|
|
16
|
+
break;
|
|
17
|
+
current = parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const config = existsSync(hookPath) ? JSON.parse(readFileSync(hookPath, 'utf8')) : {};
|
|
21
|
+
config.hooks ??= {};
|
|
22
|
+
const command = `${quote(node)} ${quote(cli)} hook --global --dir ${quote(dir)} --provider ${provider} --relay-network-hook-v1`;
|
|
23
|
+
for (const event of ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PermissionRequest', 'Stop', 'SessionEnd']) {
|
|
24
|
+
config.hooks[event] = (config.hooks[event] ?? []).map((group) => ({ ...group, hooks: (group.hooks ?? []).filter((hook) => !hook.command?.includes('--relay-network-hook-v1')) })).filter((group) => group.hooks.length);
|
|
25
|
+
config.hooks[event].push({ hooks: [{ type: 'command', command, timeout: event === 'SessionEnd' ? 3 : 10 }] });
|
|
26
|
+
}
|
|
27
|
+
let mcpText;
|
|
28
|
+
if (provider === 'codex') {
|
|
29
|
+
const old = existsSync(mcpPath) ? readFileSync(mcpPath, 'utf8') : '';
|
|
30
|
+
const clean = old.replace(/\n?# BEGIN RELAY NETWORK[\s\S]*?# END RELAY NETWORK\n?/g, '');
|
|
31
|
+
if (/^\s*\[mcp_servers\.(?:relay_network|"relay_network"|'relay_network')(?:\.|\])/m.test(clean))
|
|
32
|
+
throw new Error('Existing relay_network MCP entry needs review');
|
|
33
|
+
mcpText = clean.trimEnd() + `\n\n# BEGIN RELAY NETWORK\n[mcp_servers.relay_network]\ncommand = ${JSON.stringify(node)}\nargs = ${JSON.stringify([cli, 'mcp', '--global', '--dir', dir])}\n# END RELAY NETWORK\n`;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
const mcp = existsSync(mcpPath) ? JSON.parse(readFileSync(mcpPath, 'utf8')) : {};
|
|
37
|
+
mcp.mcpServers ??= {};
|
|
38
|
+
const previous = mcp.mcpServers.relay_network;
|
|
39
|
+
if (previous && !(previous.args?.includes('--global') && previous.args?.includes('mcp') && previous.args?.some((arg) => typeof arg === 'string' && arg.replaceAll('\\', '/').endsWith('/out/node/cli.js'))))
|
|
40
|
+
throw new Error('Existing relay_network MCP entry needs review');
|
|
41
|
+
mcp.mcpServers.relay_network = { type: 'stdio', command: node, args: [cli, 'mcp', '--global', '--dir', dir] };
|
|
42
|
+
mcpText = JSON.stringify(mcp, null, 2) + '\n';
|
|
43
|
+
}
|
|
44
|
+
return { provider, updates: [{ path: hookPath, text: JSON.stringify(config, null, 2) + '\n' }, { path: mcpPath, text: mcpText }] };
|
|
45
|
+
}
|
|
46
|
+
export function applyNetworkInstall(plan, dir) {
|
|
47
|
+
for (const update of plan.updates) {
|
|
48
|
+
if (existsSync(update.path) && readFileSync(update.path, 'utf8') === update.text)
|
|
49
|
+
continue;
|
|
50
|
+
writeConfig(update.path, update.text, join(dir, 'config-backups'));
|
|
51
|
+
}
|
|
52
|
+
return { provider: plan.provider, files: plan.updates.map(update => update.path), next: 'Resume the coding client and review its Relay Network MCP/hook trust prompts.' };
|
|
53
|
+
}
|
|
54
|
+
export function installNetwork(...args) {
|
|
55
|
+
return applyNetworkInstall(planNetworkInstall(...args), args[2]);
|
|
56
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
export function assertRealPath(path) {
|
|
6
|
+
let current = resolve(path);
|
|
7
|
+
while (true) {
|
|
8
|
+
if (existsSync(current) && lstatSync(current).isSymbolicLink())
|
|
9
|
+
throw new Error(`Relay will not install through a symlink: ${current}`);
|
|
10
|
+
const parent = dirname(current);
|
|
11
|
+
if (parent === current)
|
|
12
|
+
return;
|
|
13
|
+
current = parent;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function runtimeNode(cli) {
|
|
17
|
+
const bundled = resolve(dirname(cli), '../../engine', process.platform === 'win32' ? 'node.exe' : 'node');
|
|
18
|
+
return existsSync(bundled) ? bundled : process.execPath;
|
|
19
|
+
}
|
|
20
|
+
// Keep the installed MCP independent of npx caches, extracted tarballs, and checkouts.
|
|
21
|
+
// Desktop bundles carry Node too; npm installs reuse the user's installed runtime.
|
|
22
|
+
export function installRuntime(cli, dir) {
|
|
23
|
+
const source = resolve(dirname(cli), '../..');
|
|
24
|
+
const pkg = JSON.parse(readFileSync(join(source, 'package.json'), 'utf8'));
|
|
25
|
+
if (!['@platys/relay', '@relay-workspace/cli'].includes(pkg.name) || !/^\d+\.\d+\.\d+[a-zA-Z0-9.+-]*$/.test(pkg.version))
|
|
26
|
+
throw new Error('Not a Relay package');
|
|
27
|
+
const files = new Map();
|
|
28
|
+
const collect = (folder, target) => {
|
|
29
|
+
for (const item of readdirSync(folder, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
30
|
+
const path = join(folder, item.name), relative = target + '/' + item.name;
|
|
31
|
+
if (item.isSymbolicLink())
|
|
32
|
+
throw new Error('Relay runtime contains a symlink');
|
|
33
|
+
if (item.isDirectory())
|
|
34
|
+
collect(path, relative);
|
|
35
|
+
else if (item.isFile())
|
|
36
|
+
files.set(relative, readFileSync(path));
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
collect(join(source, 'out', 'core'), 'out/core');
|
|
40
|
+
collect(join(source, 'out', 'node'), 'out/node');
|
|
41
|
+
const bundledNode = join(source, 'engine', process.platform === 'win32' ? 'node.exe' : 'node');
|
|
42
|
+
if (existsSync(bundledNode))
|
|
43
|
+
collect(join(source, 'engine'), 'engine');
|
|
44
|
+
// Resolve each runtime dependency from the package being installed, including transitive deps.
|
|
45
|
+
const dependencies = new Map();
|
|
46
|
+
const collectDependencies = (manifest, from) => {
|
|
47
|
+
for (const name of Object.keys(manifest.dependencies ?? {}).sort()) {
|
|
48
|
+
const path = createRequire(join(from, 'package.json')).resolve(name + '/package.json');
|
|
49
|
+
const dependency = JSON.parse(readFileSync(path, 'utf8'));
|
|
50
|
+
if (dependencies.has(name)) {
|
|
51
|
+
if (dependencies.get(name) !== dependency.version)
|
|
52
|
+
throw new Error('Relay runtime needs nested dependency packaging: ' + name);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
dependencies.set(name, dependency.version);
|
|
56
|
+
collect(dirname(path), 'node_modules/' + name);
|
|
57
|
+
collectDependencies(dependency, dirname(path));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
collectDependencies(pkg, source);
|
|
61
|
+
files.set('package.json', Buffer.from(JSON.stringify({ name: pkg.name, version: pkg.version, type: 'module', private: true, dependencies: pkg.dependencies })));
|
|
62
|
+
const hash = createHash('sha256');
|
|
63
|
+
for (const [name, data] of [...files].sort(([a], [b]) => a.localeCompare(b)))
|
|
64
|
+
hash.update(name + '\0' + data.length + '\0').update(data);
|
|
65
|
+
const base = join(resolve(dir), 'runtime'), target = join(base, pkg.version + '-' + hash.digest('hex'));
|
|
66
|
+
assertRealPath(target);
|
|
67
|
+
const verify = () => { for (const [name, data] of files) {
|
|
68
|
+
const path = join(target, name);
|
|
69
|
+
assertRealPath(path);
|
|
70
|
+
if (!existsSync(path) || !readFileSync(path).equals(data))
|
|
71
|
+
throw new Error('Installed Relay runtime is incomplete or changed; remove its version directory and run setup again');
|
|
72
|
+
} };
|
|
73
|
+
if (existsSync(target)) {
|
|
74
|
+
verify();
|
|
75
|
+
return join(target, 'out', 'node', 'cli.js');
|
|
76
|
+
}
|
|
77
|
+
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
78
|
+
const stage = join(base, '.install-' + crypto.randomUUID());
|
|
79
|
+
mkdirSync(stage, { mode: 0o700 });
|
|
80
|
+
for (const [name, data] of files) {
|
|
81
|
+
const path = join(stage, name);
|
|
82
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
83
|
+
writeFileSync(path, data, { mode: 0o600 });
|
|
84
|
+
}
|
|
85
|
+
if (process.platform !== 'win32' && files.has('engine/node'))
|
|
86
|
+
chmodSync(join(stage, 'engine', 'node'), 0o700);
|
|
87
|
+
renameSync(stage, target);
|
|
88
|
+
verify();
|
|
89
|
+
return join(target, 'out', 'node', 'cli.js');
|
|
90
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { homedir, userInfo } from 'node:os';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { networkDir } from './network-state.js';
|
|
8
|
+
import { networkMain } from './network-cli.js';
|
|
9
|
+
import { planNetworkInstall } from './network-install.js';
|
|
10
|
+
import { assertRealPath, installRuntime, runtimeNode } from './network-runtime.js';
|
|
11
|
+
import { loadConnection } from './state.js';
|
|
12
|
+
import { serverURL } from './client.js';
|
|
13
|
+
import { VERSION } from '../core/version.js';
|
|
14
|
+
export function detectClients(home = homedir(), env = process.env, platform = process.platform) {
|
|
15
|
+
const onPath = (name) => (env.PATH ?? env.Path ?? '').split(platform === 'win32' ? ';' : ':').filter(Boolean).some(folder => (platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : ['']).some(ext => {
|
|
16
|
+
try {
|
|
17
|
+
return statSync(join(folder.replace(/^"|"$/g, ''), name + ext)).isFile();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}));
|
|
23
|
+
return ['codex', 'claude'].filter(provider => existsSync(provider === 'codex' ? (env.CODEX_HOME ?? join(home, '.codex')) : (env.CLAUDE_CONFIG_DIR ?? join(home, '.claude'))) ||
|
|
24
|
+
(provider === 'claude' && !env.CLAUDE_CONFIG_DIR && existsSync(join(home, '.claude.json'))) || onPath(provider));
|
|
25
|
+
}
|
|
26
|
+
export function parseNetworkInvitation(value) {
|
|
27
|
+
try {
|
|
28
|
+
if (!/^relay:network:v1:[A-Za-z0-9_-]{1,12000}$/.test(value))
|
|
29
|
+
throw new Error();
|
|
30
|
+
const parsed = JSON.parse(Buffer.from(value.slice('relay:network:v1:'.length), 'base64url').toString('utf8'));
|
|
31
|
+
if (typeof parsed.server !== 'string' || parsed.server.length > 2048 || typeof parsed.projectId !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(parsed.projectId) ||
|
|
32
|
+
typeof parsed.repository !== 'string' || !parsed.repository.length || parsed.repository.length > 500 || typeof parsed.code !== 'string' || !parsed.code.length || parsed.code.length > 512)
|
|
33
|
+
throw new Error();
|
|
34
|
+
return { server: serverURL(parsed.server), projectId: parsed.projectId, repository: parsed.repository, code: parsed.code };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error('That invitation is not valid. Paste the complete Relay Network invitation from your friend.');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function setupNetwork(values, cli, io) {
|
|
41
|
+
let reader;
|
|
42
|
+
const ui = io ?? { interactive: !!process.stdin.isTTY && !!process.stdout.isTTY, log: console.log,
|
|
43
|
+
ask: async (question, fallback) => {
|
|
44
|
+
reader ??= createInterface({ input: process.stdin, output: process.stdout });
|
|
45
|
+
return (await reader.question(`${question}${fallback ? ` [${fallback}]` : ''}: `)).trim() || fallback || '';
|
|
46
|
+
} };
|
|
47
|
+
try {
|
|
48
|
+
const settings = { ...values }, option = (name) => typeof settings[name] === 'string' ? settings[name] : undefined;
|
|
49
|
+
const dir = resolve(option('dir') ?? networkDir());
|
|
50
|
+
assertRealPath(dir);
|
|
51
|
+
const existing = existsSync(join(dir, 'connection.json')) ? loadConnection(dir) : undefined;
|
|
52
|
+
if (existing && existing.mode !== 'network')
|
|
53
|
+
throw new Error('This directory belongs to a repository connection. Choose a different --dir.');
|
|
54
|
+
if (settings['no-install'])
|
|
55
|
+
throw new Error('Setup installs your client integration. For a transport-only connection, use connect --global --no-install.');
|
|
56
|
+
if (option('repo') && settings['all-workspaces'])
|
|
57
|
+
throw new Error('Choose --repo PATH or --all-workspaces, not both.');
|
|
58
|
+
let providers;
|
|
59
|
+
const requested = option('provider') ?? 'auto';
|
|
60
|
+
if (!['auto', 'codex', 'claude', 'both'].includes(requested))
|
|
61
|
+
throw new Error('Choose --provider auto, codex, claude or both.');
|
|
62
|
+
providers = requested === 'auto' ? detectClients() : requested === 'both' ? ['codex', 'claude'] : [requested];
|
|
63
|
+
if (!providers.length && ui.interactive) {
|
|
64
|
+
const choice = (await ui.ask('Which coding client do you use? codex, claude, or both')).toLowerCase();
|
|
65
|
+
if (!['codex', 'claude', 'both'].includes(choice))
|
|
66
|
+
throw new Error('Run setup again with --provider codex, claude or both.');
|
|
67
|
+
providers = choice === 'both' ? ['codex', 'claude'] : [choice];
|
|
68
|
+
}
|
|
69
|
+
if (!providers.length)
|
|
70
|
+
throw new Error('No coding client detected. Install Codex or Claude Code, or specify --provider codex, claude or both.');
|
|
71
|
+
settings.provider = providers.length === 2 ? 'both' : providers[0];
|
|
72
|
+
ui.log(`Relay setup\nClients: ${providers.map(p => p === 'codex' ? 'Codex' : 'Claude Code').join(' + ')}`);
|
|
73
|
+
if (existing) {
|
|
74
|
+
if (option('invite')) {
|
|
75
|
+
const invite = parseNetworkInvitation(option('invite'));
|
|
76
|
+
if (invite.server !== serverURL(existing.server) || invite.projectId !== existing.projectId || invite.repository !== existing.repository)
|
|
77
|
+
throw new Error('Already connected to a different network. Setup will not replace it.');
|
|
78
|
+
delete settings.invite; // Retrying setup never consumes the single-use invitation again.
|
|
79
|
+
}
|
|
80
|
+
if (option('server') && serverURL(option('server')) !== serverURL(existing.server))
|
|
81
|
+
throw new Error('Already connected to a different service.');
|
|
82
|
+
delete settings.server;
|
|
83
|
+
ui.log(`Updating the installation for ${existing.name}; keeping your connection and sharing settings.`);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
if (!option('invite') && !option('server') && !process.env.RELAY_SERVER_URL) {
|
|
87
|
+
if (!ui.interactive)
|
|
88
|
+
throw new Error('Run relay setup in a terminal to paste an invitation, or supply --invite CODE.');
|
|
89
|
+
settings.invite = await ui.ask('Paste your friend\'s Relay invitation');
|
|
90
|
+
}
|
|
91
|
+
if (option('invite'))
|
|
92
|
+
parseNetworkInvitation(option('invite'));
|
|
93
|
+
else if (!(option('create-key') ?? process.env.RELAY_CREATE_KEY))
|
|
94
|
+
throw new Error('To join, use your friend\'s invitation. Creating a network requires an operator key.');
|
|
95
|
+
if (!option('name'))
|
|
96
|
+
settings.name = ui.interactive ? await ui.ask('Your display name', userInfo().username) : userInfo().username;
|
|
97
|
+
if (!option('name')?.trim() || option('name').length > 80)
|
|
98
|
+
throw new Error('Choose a display name between 1 and 80 characters.');
|
|
99
|
+
}
|
|
100
|
+
if (option('repo'))
|
|
101
|
+
settings.repo = realpathSync(option('repo'));
|
|
102
|
+
// Preflight all clients before consuming an invitation or modifying any client config.
|
|
103
|
+
for (const provider of providers)
|
|
104
|
+
planNetworkInstall(provider, cli, dir);
|
|
105
|
+
const installed = installRuntime(cli, dir);
|
|
106
|
+
const node = runtimeNode(installed);
|
|
107
|
+
for (const provider of providers)
|
|
108
|
+
planNetworkInstall(provider, installed, dir, undefined, node);
|
|
109
|
+
const probe = await promisify(execFile)(node, [installed, '--version'], { windowsHide: true, timeout: 10000 });
|
|
110
|
+
if (probe.stdout.trim() !== VERSION)
|
|
111
|
+
throw new Error('The installed Relay runtime did not pass its startup check.');
|
|
112
|
+
await networkMain('connect', { ...settings, dir, quiet: true }, installed);
|
|
113
|
+
ui.log(`\nRelay is configured for ${providers.map(p => p === 'codex' ? 'Codex' : 'Claude Code').join(' + ')}.\nOpen or resume your coding client once and accept its Relay trust prompt if shown.\nThen say: "Find my friend\'s agent on Relay."\n\nNo per-project setup or separate terminal is needed. Incoming delivery is confirmed only after the receiving agent acknowledges it.`);
|
|
114
|
+
if (settings['no-start'])
|
|
115
|
+
ui.log('Background receiver startup was skipped (--no-start).');
|
|
116
|
+
return { dir, cli: installed, providers };
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
reader?.close();
|
|
120
|
+
}
|
|
121
|
+
}
|