@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
package/out/node/cli.js
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, openSync, closeSync, unlinkSync } from 'node:fs';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { hostname } from 'node:os';
|
|
9
|
+
import { secret } from '../core/types.js';
|
|
10
|
+
import { root, snapshot } from './git.js';
|
|
11
|
+
import { loadConnection, saveConnection, stateDir, State } from './state.js';
|
|
12
|
+
import { api, Remote, serverURL } from './client.js';
|
|
13
|
+
import { install } from './install.js';
|
|
14
|
+
import { handleHook } from './hooks.js';
|
|
15
|
+
import { serveMcp, callRelayTool } from './tools.js';
|
|
16
|
+
import { Connector } from './connector.js';
|
|
17
|
+
import { nativeDelivery } from './adapters.js';
|
|
18
|
+
import { diagnose, mcpProbe } from './diagnostics.js';
|
|
19
|
+
import { accept } from './integration.js';
|
|
20
|
+
import { backupProject, pruneArtifacts } from './maintenance.js';
|
|
21
|
+
import { startHub } from './hub.js';
|
|
22
|
+
import { clientBudget } from './request-budget.js';
|
|
23
|
+
import { networkMain } from './network-cli.js';
|
|
24
|
+
import { setupNetwork } from './network-setup.js';
|
|
25
|
+
import { openSetupWeb } from './setup-web.js';
|
|
26
|
+
import { setupRemote, useRemoteSetup } from './remote-setup.js';
|
|
27
|
+
import { VERSION } from '../core/version.js';
|
|
28
|
+
const cli = fileURLToPath(import.meta.url);
|
|
29
|
+
const parsed = parseArgs({ allowPositionals: true, options: Object.fromEntries([
|
|
30
|
+
'repo', 'server', 'invite', 'name', 'provider', 'repository', 'session', 'from', 'to', 'message', 'query', 'dir', 'port', 'create-key', 'args', 'id', 'backup', 'days', 'member', 'role'
|
|
31
|
+
].map(name => [name, { type: 'string' }]).concat([['version', { type: 'boolean' }], ['approve', { type: 'boolean' }], ['json', { type: 'boolean' }], ['no-install', { type: 'boolean' }], ['no-start', { type: 'boolean' }], ['help', { type: 'boolean' }], ['relay-hook-v1', { type: 'boolean' }], ['global', { type: 'boolean' }], ['all-workspaces', { type: 'boolean' }], ['relay-network-hook-v1', { type: 'boolean' }], ['web', { type: 'boolean' }], ['no-open', { type: 'boolean' }], ['terminal', { type: 'boolean' }], ['remote', { type: 'boolean' }], ['legacy-network', { type: 'boolean' }]])) });
|
|
32
|
+
const values = parsed.values, positionals = parsed.positionals;
|
|
33
|
+
const option = (name) => typeof values[name] === 'string' ? values[name] : undefined;
|
|
34
|
+
const help = `Relay Next — shared context for your existing coding sessions
|
|
35
|
+
|
|
36
|
+
setup Open simple Relay setup in your browser
|
|
37
|
+
setup --web Open the setup screen in your browser
|
|
38
|
+
setup --legacy-network Configure the optional legacy network connector
|
|
39
|
+
setup --terminal --invite URL --name NAME Install without opening a browser
|
|
40
|
+
setup --invite CODE Join a friend; detect clients and configure all workspaces
|
|
41
|
+
connect Connect a Git repository; install its agent integration
|
|
42
|
+
connect --global Install once; connect agents across shared workspaces
|
|
43
|
+
invite --global Invite a friend to your agent network
|
|
44
|
+
share --global --repo PATH Share sessions in a workspace and its subfolders
|
|
45
|
+
unshare --global --repo PATH Hide sessions in that workspace
|
|
46
|
+
share --global --all-workspaces Share sessions across all workspaces
|
|
47
|
+
discover --global --session ID --name PERSON --query TOPIC
|
|
48
|
+
connect --invite CODE Join your teammate's project
|
|
49
|
+
invite Create a single-use project invitation (24 hours)
|
|
50
|
+
members List project members and roles
|
|
51
|
+
member-role --member ID --role admin|member Owner changes a project role
|
|
52
|
+
status Show sessions, freshness, pending uploads and receipts
|
|
53
|
+
context --session ID Inspect shared context and its code references
|
|
54
|
+
ask --from ID --to ID --message TEXT
|
|
55
|
+
run Run this repository's connector in the foreground
|
|
56
|
+
stop Stop only this repository's Relay connector
|
|
57
|
+
doctor Check connector, MCP startup, session registration and legacy conflicts
|
|
58
|
+
project Read accepted revision, tasks and current decisions
|
|
59
|
+
metrics Read payload sizes and operation counts
|
|
60
|
+
budget Read cached shared monthly budget and pause alerts (local only)
|
|
61
|
+
budget-refresh Explicitly check whether the owner approved resuming this service
|
|
62
|
+
task --args JSON Reserve/renew/handoff/release scopes
|
|
63
|
+
artifact --args JSON Export/receive/read exact selected files
|
|
64
|
+
integration --args JSON Prepare or validate changes in an isolated worktree
|
|
65
|
+
accept --id ID --session ID --approve Accept a validated integration
|
|
66
|
+
backup Export project evidence and artifacts to a local archive
|
|
67
|
+
prune --backup FILE --days 7 --approve Prune archived, unreferenced artifacts
|
|
68
|
+
dev --port 7337 Start a local-only service for development
|
|
69
|
+
|
|
70
|
+
Connection options: --repo PATH --server HTTPS_URL --provider codex|claude|both
|
|
71
|
+
--name NAME --repository SHARED_ID --no-start --no-install
|
|
72
|
+
Creating a hosted project requires RELAY_CREATE_KEY from the service operator.
|
|
73
|
+
Use npm run demo for an isolated two-connector demonstration.
|
|
74
|
+
`;
|
|
75
|
+
async function prompt(question, fallback) {
|
|
76
|
+
if (!process.stdin.isTTY) {
|
|
77
|
+
if (fallback !== undefined)
|
|
78
|
+
return fallback;
|
|
79
|
+
throw new Error(`${question} Supply the corresponding command option in non-interactive mode.`);
|
|
80
|
+
}
|
|
81
|
+
const reader = createInterface({ input: process.stdin, output: process.stdout });
|
|
82
|
+
try {
|
|
83
|
+
return (await reader.question(`${question}${fallback ? ` [${fallback}]` : ''}: `)).trim() || fallback || '';
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
reader.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function startDaemon(repo) {
|
|
90
|
+
const dir = stateDir(repo), lock = join(dir, 'connector.lock');
|
|
91
|
+
if (existsSync(lock)) {
|
|
92
|
+
const old = JSON.parse(readFileSync(lock, 'utf8'));
|
|
93
|
+
let alive = false;
|
|
94
|
+
try {
|
|
95
|
+
process.kill(old.pid, 0);
|
|
96
|
+
alive = true;
|
|
97
|
+
}
|
|
98
|
+
catch { }
|
|
99
|
+
if (alive) {
|
|
100
|
+
if (old.version === VERSION && old.cli === cli)
|
|
101
|
+
return;
|
|
102
|
+
writeFileSync(join(dir, 'stop'), 'upgrade');
|
|
103
|
+
for (let i = 0; i < 50 && existsSync(lock); i++)
|
|
104
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
105
|
+
if (existsSync(lock))
|
|
106
|
+
throw new Error('Existing connector has not stopped for upgrade; retry after it exits. Coding sessions were not stopped.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (existsSync(join(dir, 'stop')))
|
|
110
|
+
unlinkSync(join(dir, 'stop'));
|
|
111
|
+
const fd = openSync(join(dir, 'connector.log'), 'a', 0o600);
|
|
112
|
+
const child = spawn(process.execPath, [cli, 'run', '--repo', repo], { cwd: repo, windowsHide: true, detached: true, stdio: ['ignore', fd, fd], env: { ...process.env, RELAY_CREATE_KEY: undefined } });
|
|
113
|
+
child.unref();
|
|
114
|
+
closeSync(fd);
|
|
115
|
+
}
|
|
116
|
+
async function connectProject(repo) {
|
|
117
|
+
const dir = stateDir(repo);
|
|
118
|
+
let config;
|
|
119
|
+
if (existsSync(join(dir, 'connection.json'))) {
|
|
120
|
+
if (option('server') || option('invite'))
|
|
121
|
+
throw new Error('This checkout is already connected. Use another checkout to connect a different project.');
|
|
122
|
+
config = loadConnection(dir);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const git = snapshot(repo, option('repository')), name = option('name') ?? await prompt('Your display name', hostname());
|
|
126
|
+
let server = option('server') ?? process.env.RELAY_SERVER_URL, projectId = crypto.randomUUID();
|
|
127
|
+
if (option('invite')) {
|
|
128
|
+
const encoded = option('invite');
|
|
129
|
+
if (!encoded.startsWith('relay:v1:'))
|
|
130
|
+
throw new Error('Paste the complete Relay invitation');
|
|
131
|
+
const invite = JSON.parse(Buffer.from(encoded.slice(9), 'base64url').toString('utf8'));
|
|
132
|
+
if (invite.repository && invite.repository !== git.repository)
|
|
133
|
+
throw new Error(`Invitation is for ${invite.repository}; this checkout is ${git.repository}. Run connect from the matching checkout.`);
|
|
134
|
+
server = serverURL(invite.server);
|
|
135
|
+
projectId = invite.projectId;
|
|
136
|
+
const joined = await api(server, projectId, '', 'join', { code: invite.code, name, repository: git.repository });
|
|
137
|
+
config = { server, projectId, ...joined, repository: git.repository, name, repo };
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
server = serverURL(server ?? await prompt('Relay service URL'));
|
|
141
|
+
let key = option('create-key') ?? process.env.RELAY_CREATE_KEY;
|
|
142
|
+
const local = join(dir, 'local-service.json');
|
|
143
|
+
if (!key && existsSync(local)) {
|
|
144
|
+
const dev = JSON.parse(readFileSync(local, 'utf8'));
|
|
145
|
+
if (dev.server === server)
|
|
146
|
+
key = dev.key;
|
|
147
|
+
}
|
|
148
|
+
if (!key)
|
|
149
|
+
throw new Error('Set RELAY_CREATE_KEY to create a project, or join using your teammate’s invitation.');
|
|
150
|
+
const created = await api(server, projectId, key, 'create', { name: repo.split(/[\\/]/).pop(), ownerName: name, repository: git.repository });
|
|
151
|
+
config = { server, projectId, ...created, repository: git.repository, name, repo };
|
|
152
|
+
}
|
|
153
|
+
saveConnection(dir, config);
|
|
154
|
+
}
|
|
155
|
+
const state = new State(dir);
|
|
156
|
+
state.set('repo', repo);
|
|
157
|
+
state.close();
|
|
158
|
+
if (!values['no-install']) {
|
|
159
|
+
const provider = option('provider') ?? await prompt('Coding agent: codex, claude or both', 'both');
|
|
160
|
+
if (!['codex', 'claude', 'both'].includes(provider))
|
|
161
|
+
throw new Error('Provider must be codex, claude or both');
|
|
162
|
+
for (const p of provider === 'both' ? ['codex', 'claude'] : [provider])
|
|
163
|
+
install(repo, p, cli);
|
|
164
|
+
}
|
|
165
|
+
if (!values['no-start'])
|
|
166
|
+
await startDaemon(repo);
|
|
167
|
+
const mcpReady = values['no-install'] ? false : await mcpProbe(cli, repo);
|
|
168
|
+
console.log(`MCP executable startup: ${mcpReady ? 'passed' : 'not verified'}. This does not confirm the coding session has loaded it.`);
|
|
169
|
+
console.log(`Connected ${config.name} to ${config.repository}.\n${values['no-start'] ? 'Run relay-next run to synchronize.' : 'Connector started. Session registration begins only when the coding client loads Relay; run relay-next doctor.'}\nResume your coding session once to load Relay. Approve its tools/hooks in the coding client.\nUse relay-next invite to invite your teammate.`);
|
|
170
|
+
}
|
|
171
|
+
async function run(repo) {
|
|
172
|
+
const dir = stateDir(repo), config = loadConnection(dir), lock = join(dir, 'connector.lock');
|
|
173
|
+
if (existsSync(lock)) {
|
|
174
|
+
const existing = JSON.parse(readFileSync(lock, 'utf8'));
|
|
175
|
+
let alive = false;
|
|
176
|
+
try {
|
|
177
|
+
process.kill(existing.pid, 0);
|
|
178
|
+
alive = true;
|
|
179
|
+
}
|
|
180
|
+
catch { /* exited */ }
|
|
181
|
+
if (alive)
|
|
182
|
+
throw new Error('A Relay connector is already running for this repository');
|
|
183
|
+
unlinkSync(lock);
|
|
184
|
+
}
|
|
185
|
+
const fd = openSync(lock, 'wx', 0o600);
|
|
186
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), version: VERSION, cli }));
|
|
187
|
+
closeSync(fd);
|
|
188
|
+
if (existsSync(join(dir, 'stop')))
|
|
189
|
+
unlinkSync(join(dir, 'stop'));
|
|
190
|
+
const state = new State(dir);
|
|
191
|
+
state.set('repo', repo);
|
|
192
|
+
const connector = new Connector(state, new Remote(config), nativeDelivery(repo));
|
|
193
|
+
connector.start();
|
|
194
|
+
console.log('Relay connector running. Ctrl+C stops Relay only.');
|
|
195
|
+
await new Promise(resolve => { const stop = () => { clearInterval(timer); resolve(); }; const timer = setInterval(() => { if (existsSync(join(dir, 'stop')))
|
|
196
|
+
stop(); }, 1000); process.once('SIGINT', stop); process.once('SIGTERM', stop); });
|
|
197
|
+
await connector.stop();
|
|
198
|
+
state.close();
|
|
199
|
+
if (existsSync(lock))
|
|
200
|
+
unlinkSync(lock);
|
|
201
|
+
}
|
|
202
|
+
async function main() {
|
|
203
|
+
if (values.version) {
|
|
204
|
+
console.log(VERSION);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const cmd = positionals[0] ?? 'help';
|
|
208
|
+
if (cmd === 'help' || values.help) {
|
|
209
|
+
console.log(help);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (cmd === 'budget') {
|
|
213
|
+
console.log(JSON.stringify(clientBudget.status(), null, 2));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (cmd === 'setup') {
|
|
217
|
+
if (useRemoteSetup(values)) {
|
|
218
|
+
if (values.terminal) {
|
|
219
|
+
await setupRemote(values, cli);
|
|
220
|
+
console.log('Relay installed. Reopen Codex or use the Claude with Relay desktop shortcut.');
|
|
221
|
+
}
|
|
222
|
+
else
|
|
223
|
+
await openSetupWeb({ ...values, remote: true }, cli);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (values.web || (!values.terminal && process.stdin.isTTY && !option('invite') && !option('server')))
|
|
227
|
+
await openSetupWeb(values, cli);
|
|
228
|
+
else
|
|
229
|
+
await setupNetwork(values, cli);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (values.global) {
|
|
233
|
+
await networkMain(cmd, values, cli);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const repo = cmd === 'hook' ? resolve(option('repo') ?? process.cwd()) : root(option('repo') ?? process.cwd());
|
|
237
|
+
if (cmd === 'hook') {
|
|
238
|
+
let data = '';
|
|
239
|
+
for await (const chunk of process.stdin) {
|
|
240
|
+
data += chunk;
|
|
241
|
+
if (data.length > 1_000_000)
|
|
242
|
+
throw new Error('Hook input too large');
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
console.log(JSON.stringify(handleHook(repo, option('provider'), JSON.parse(data))));
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
console.error(`Relay hook unavailable: ${error.message}`);
|
|
249
|
+
console.log('{}');
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (cmd === 'connect') {
|
|
254
|
+
await connectProject(repo);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (cmd === 'run') {
|
|
258
|
+
await run(repo);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (cmd === 'budget-refresh') {
|
|
262
|
+
console.log(JSON.stringify(await new Remote(loadConnection(stateDir(repo))).refreshBudget(), null, 2));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (cmd === 'dev') {
|
|
266
|
+
const dir = stateDir(repo);
|
|
267
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
268
|
+
const key = secret(), hub = await startHub(option('dir') ?? join(dir, 'dev-hub'), key, Number(option('port') ?? 7337));
|
|
269
|
+
writeFileSync(join(dir, 'local-service.json'), JSON.stringify({ server: hub.url, key }), { mode: 0o600 });
|
|
270
|
+
console.log(`Local development service: ${hub.url}\nConnect this checkout with: relay-next connect --server ${hub.url}\nThis address is available only on this computer.`);
|
|
271
|
+
await new Promise(resolve => { process.once('SIGINT', resolve); process.once('SIGTERM', resolve); });
|
|
272
|
+
await hub.close();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (cmd === 'doctor') {
|
|
276
|
+
console.log(JSON.stringify({ ...await diagnose(repo), mcpStarts: existsSync(join(stateDir(repo), 'connection.json')) ? await mcpProbe(cli, repo) : false }, null, 2));
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const dir = stateDir(repo), config = loadConnection(dir);
|
|
280
|
+
if (cmd === 'stop') {
|
|
281
|
+
writeFileSync(join(dir, 'stop'), 'stop');
|
|
282
|
+
console.log('Requested this repository’s connector to stop. Coding agents continue running.');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (cmd === 'members') {
|
|
286
|
+
const shared = await new Remote(config).call('snapshot');
|
|
287
|
+
console.log(JSON.stringify(shared.members, null, 2));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (cmd === 'member-role') {
|
|
291
|
+
const memberId = option('member'), role = option('role');
|
|
292
|
+
if (!memberId || !['admin', 'member'].includes(role ?? ''))
|
|
293
|
+
throw new Error('Use --member ID --role admin|member');
|
|
294
|
+
console.log(JSON.stringify(await new Remote(config).call('members', { memberId, role }), null, 2));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (cmd === 'invite') {
|
|
298
|
+
const invitation = await new Remote(config).call('invite', {});
|
|
299
|
+
const code = 'relay:v1:' + Buffer.from(JSON.stringify({ server: config.server, projectId: config.projectId, repository: config.repository, code: invitation.code })).toString('base64url');
|
|
300
|
+
console.log(`Single-use invitation; expires ${new Date(invitation.expiresAt).toISOString()}:\n${code}\n\nTeammate: relay-next connect --invite ${code}`);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const state = new State(dir);
|
|
304
|
+
state.set('repo', repo);
|
|
305
|
+
try {
|
|
306
|
+
if (cmd === 'mcp') {
|
|
307
|
+
await serveMcp(state);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (cmd === 'status' && !state.bindings().length) {
|
|
311
|
+
console.log(JSON.stringify(await diagnose(repo), null, 2));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (cmd === 'backup') {
|
|
315
|
+
console.log(JSON.stringify(await backupProject(state), null, 2));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const sessionId = option('session') ?? option('from') ?? ((cmd === 'status' || state.bindings().length === 1) ? state.bindings()[0]?.id : undefined);
|
|
319
|
+
if (!sessionId)
|
|
320
|
+
throw new Error('Choose --session from these connected sessions:\n' + state.bindings().map(s => `${s.id} ${s.name}`).join('\n'));
|
|
321
|
+
if (cmd === 'backup') {
|
|
322
|
+
console.log(JSON.stringify(await backupProject(state), null, 2));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (cmd === 'prune') {
|
|
326
|
+
if (!values.approve)
|
|
327
|
+
throw new Error('Pruning requires --approve and a verified backup');
|
|
328
|
+
console.log(JSON.stringify(await pruneArtifacts(state, sessionId, option('backup') ?? '', Number(option('days') ?? 7)), null, 2));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (cmd === 'accept') {
|
|
332
|
+
console.log(JSON.stringify(await accept(state, option('id') ?? '', sessionId, values.approve === true), null, 2));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const tool = { status: 'relay_status', context: 'relay_context', ask: 'relay_ask', project: 'relay_project', metrics: 'relay_metrics', task: 'relay_task', artifact: 'relay_artifact', integration: 'relay_integration' }[cmd];
|
|
336
|
+
const args = { ...(option('args') ? JSON.parse(option('args')) : {}), session_id: sessionId, ...(cmd === 'context' ? { query: option('query') ?? '' } : {}), ...(cmd === 'ask' ? { target_session_id: option('to'), question: option('message') } : {}) };
|
|
337
|
+
const result = tool ? await callRelayTool(state, tool, args) : null;
|
|
338
|
+
if (result === null)
|
|
339
|
+
throw new Error('Unknown command. Run relay-next help.');
|
|
340
|
+
if (cmd === 'status' && !values.json) {
|
|
341
|
+
const status = result;
|
|
342
|
+
console.log(`Relay · ${config.repository}\n${status.syncError ? `Sync unavailable: ${status.syncError.message}` : `Last sync: ${status.lastSync ?? 'waiting'}`} · ${status.pendingUploads} pending uploads\n`);
|
|
343
|
+
for (const session of status.sessions)
|
|
344
|
+
console.log(`${session.name} ${session.provider} ${session.state}${session.freshActivity ? '' : ' (activity stale)'}\n ${session.task || 'No task shared yet'}\n ${session.git.branch ?? 'detached'} @ ${session.git.head?.slice(0, 8) ?? 'unborn'}${session.git.dirty ? ' + uncommitted changes' : ''}\n ${session.id}\n`);
|
|
345
|
+
if (status.overlaps.length)
|
|
346
|
+
console.log('Overlapping work:', status.overlaps.map((c) => c.paths.join(', ')).join('; '));
|
|
347
|
+
if (status.rejectedUploads.length)
|
|
348
|
+
console.log('Uploads needing attention:', status.rejectedUploads);
|
|
349
|
+
console.log('Recent directed messages:', status.receipts.slice(0, 8).map((r) => `${r.eventId.slice(0, 8)} ${r.state}`).join(' · ') || 'none');
|
|
350
|
+
}
|
|
351
|
+
else
|
|
352
|
+
console.log(JSON.stringify(result, null, 2));
|
|
353
|
+
}
|
|
354
|
+
finally {
|
|
355
|
+
state.close();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
main().catch(error => { console.error(`Relay: ${error.message}`); process.exitCode = 1; });
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
import { clientBudget, observeBudgetHeader } from './request-budget.js';
|
|
3
|
+
export class HttpError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
constructor(status, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function serverURL(value) {
|
|
11
|
+
const url = new URL(value);
|
|
12
|
+
if (url.username || url.password || url.search || url.hash)
|
|
13
|
+
throw new Error('Use a service URL without credentials, query parameters or fragments');
|
|
14
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
|
|
15
|
+
throw new Error('Relay requires HTTPS except for a local development service');
|
|
16
|
+
return url.toString().replace(/\/$/, '');
|
|
17
|
+
}
|
|
18
|
+
export async function api(server, projectId, token, action, body, cache = clientBudget) {
|
|
19
|
+
serverURL(server);
|
|
20
|
+
cache.assertAllowed(server);
|
|
21
|
+
const response = await fetch(`${serverURL(server)}/projects/${encodeURIComponent(projectId)}/${action}`, {
|
|
22
|
+
method: body === undefined ? 'GET' : 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
23
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(10_000), redirect: 'error'
|
|
24
|
+
});
|
|
25
|
+
observeBudgetHeader(server, response.headers.get('X-Relay-Budget'), cache);
|
|
26
|
+
const result = await response.json().catch(() => null);
|
|
27
|
+
if (!response.ok)
|
|
28
|
+
throw new HttpError(response.status, typeof result?.error === 'string' ? result.error : `Relay returned HTTP ${response.status}`);
|
|
29
|
+
if (result === null)
|
|
30
|
+
throw new Error('Relay returned an invalid JSON response');
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
export class Remote {
|
|
34
|
+
connection;
|
|
35
|
+
budget;
|
|
36
|
+
constructor(connection, budget = clientBudget) {
|
|
37
|
+
this.connection = connection;
|
|
38
|
+
this.budget = budget;
|
|
39
|
+
}
|
|
40
|
+
call(action, body) { const c = this.connection; return api(c.server, c.projectId, c.token, action, body, this.budget); }
|
|
41
|
+
events(after) { return this.call(`events?after=${after}`); }
|
|
42
|
+
async refreshBudget() {
|
|
43
|
+
// Explicit operator/user command only. Normal reconnects never bypass a pause.
|
|
44
|
+
const c = this.connection;
|
|
45
|
+
const response = await fetch(`${serverURL(c.server)}/projects/${encodeURIComponent(c.projectId)}/budgetstatus`, { headers: { Authorization: `Bearer ${c.token}` }, signal: AbortSignal.timeout(10000), redirect: 'error' });
|
|
46
|
+
if (!response.ok)
|
|
47
|
+
throw new HttpError(response.status, 'Could not refresh shared budget status');
|
|
48
|
+
const status = await response.json();
|
|
49
|
+
this.budget.observe(c.server, status);
|
|
50
|
+
return status;
|
|
51
|
+
}
|
|
52
|
+
subscribe(changed, closed) {
|
|
53
|
+
const c = this.connection, url = new URL(`${serverURL(c.server)}/projects/${c.projectId}/stream`);
|
|
54
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
55
|
+
this.budget.assertAllowed(c.server);
|
|
56
|
+
const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${c.token}` }, handshakeTimeout: 10_000, maxPayload: 4096 });
|
|
57
|
+
ws.on('upgrade', response => { const header = response.headers['x-relay-budget']; if (typeof header === 'string')
|
|
58
|
+
observeBudgetHeader(c.server, header, this.budget); });
|
|
59
|
+
ws.on('unexpected-response', (_request, response) => { try {
|
|
60
|
+
const header = response.headers['x-relay-budget'];
|
|
61
|
+
if (typeof header === 'string')
|
|
62
|
+
observeBudgetHeader(c.server, header, this.budget);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
response.resume();
|
|
66
|
+
ws.terminate();
|
|
67
|
+
} });
|
|
68
|
+
ws.on('message', data => { try {
|
|
69
|
+
const message = JSON.parse(data.toString());
|
|
70
|
+
if (message.type === 'budget_paused') {
|
|
71
|
+
this.budget.observe(c.server, message.budget);
|
|
72
|
+
ws.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch { } changed(); });
|
|
76
|
+
ws.on('error', () => { });
|
|
77
|
+
ws.on('close', closed);
|
|
78
|
+
return ws;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { publicSession } from './state.js';
|
|
2
|
+
import { HttpError } from './client.js';
|
|
3
|
+
import { message } from './adapters.js';
|
|
4
|
+
import { terminalStatus } from './terminal.js';
|
|
5
|
+
export class Connector {
|
|
6
|
+
state;
|
|
7
|
+
remote;
|
|
8
|
+
deliver;
|
|
9
|
+
ticking = false;
|
|
10
|
+
stopped = false;
|
|
11
|
+
socket;
|
|
12
|
+
timer;
|
|
13
|
+
reconnect;
|
|
14
|
+
failures = 0;
|
|
15
|
+
dirty = true;
|
|
16
|
+
lastNetwork = 0;
|
|
17
|
+
protocol;
|
|
18
|
+
retryAfter = 0;
|
|
19
|
+
constructor(state, remote, deliver) {
|
|
20
|
+
this.state = state;
|
|
21
|
+
this.remote = remote;
|
|
22
|
+
this.deliver = deliver;
|
|
23
|
+
}
|
|
24
|
+
async tick(force = true) {
|
|
25
|
+
if (this.ticking || this.stopped || (!force && Date.now() < this.retryAfter))
|
|
26
|
+
return;
|
|
27
|
+
this.ticking = true;
|
|
28
|
+
try {
|
|
29
|
+
const pending = this.state.pending();
|
|
30
|
+
if (this.protocol === undefined) {
|
|
31
|
+
const info = await this.remote.call('snapshot');
|
|
32
|
+
this.protocol = info.protocol ?? 1;
|
|
33
|
+
this.state.set('serverProtocol', this.protocol);
|
|
34
|
+
}
|
|
35
|
+
if (this.protocol < 2 && pending.some(j => j.action === 'events' && (['artifact', 'integration'].includes(j.body.kind) || j.body.phase)))
|
|
36
|
+
throw new Error('Update the Relay service to protocol 2 before synchronizing collaboration events; outbox retained');
|
|
37
|
+
const bindings = this.state.bindings();
|
|
38
|
+
const changed = bindings.some(b => { const previous = this.state.get(`registered:${b.id}`); return !previous || (previous.json !== JSON.stringify(publicSession(b)) && Date.now() - previous.at >= 10_000); });
|
|
39
|
+
if (!force && !this.dirty && !pending.length && !changed && Date.now() - this.lastNetwork < 300_000)
|
|
40
|
+
return;
|
|
41
|
+
if (!force && this.lastNetwork && Date.now() - this.lastNetwork < 1000)
|
|
42
|
+
return;
|
|
43
|
+
this.dirty = false;
|
|
44
|
+
this.lastNetwork = Date.now();
|
|
45
|
+
// Register before draining events. Native credentials never leave this machine.
|
|
46
|
+
for (const binding of this.state.bindings()) {
|
|
47
|
+
const key = `registered:${binding.id}`, previous = this.state.get(key);
|
|
48
|
+
const session = publicSession(binding), json = JSON.stringify(session);
|
|
49
|
+
if (session.state === 'closed' && previous?.json === json)
|
|
50
|
+
continue;
|
|
51
|
+
if (!previous || (previous.json !== json && (force || Date.now() - previous.at >= 10_000))) {
|
|
52
|
+
await this.remote.call('sessions', session);
|
|
53
|
+
this.state.set(key, { json, at: Date.now() });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const job of this.state.pending()) {
|
|
57
|
+
try {
|
|
58
|
+
const result = await this.remote.call(job.action, job.body);
|
|
59
|
+
this.state.remove(job.id);
|
|
60
|
+
if (job.action === 'events' && result?.kind === 'reply' && result.phase !== 'progress')
|
|
61
|
+
this.state.mark(result.replyTo, result.sessionId, 'answered', 'Reply stored remotely');
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error instanceof HttpError && [400, 403, 404, 409, 413].includes(error.status)) {
|
|
65
|
+
this.state.set(`rejected:${job.id}`, { action: job.action, error: error.message, at: new Date().toISOString() });
|
|
66
|
+
this.state.remove(job.id);
|
|
67
|
+
}
|
|
68
|
+
else
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Persist pages and cursor in the same transaction. Reconnect resumes from it.
|
|
73
|
+
while (true) {
|
|
74
|
+
const page = await this.remote.events(this.state.get('cursor') ?? 0);
|
|
75
|
+
this.state.measure('network:events', JSON.stringify(page));
|
|
76
|
+
this.state.cache(page.events);
|
|
77
|
+
if (page.events.length < 200)
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
const snapshot = await this.remote.call('snapshot');
|
|
81
|
+
this.state.measure('network:snapshot', JSON.stringify(snapshot));
|
|
82
|
+
this.state.set('snapshot', snapshot);
|
|
83
|
+
this.state.set('lastSync', new Date().toISOString());
|
|
84
|
+
this.state.set('syncError', null);
|
|
85
|
+
this.failures = 0;
|
|
86
|
+
await this.updateTerminals('online');
|
|
87
|
+
for (const binding of this.state.bindings())
|
|
88
|
+
for (const event of this.state.messages(binding.id)) {
|
|
89
|
+
const receipt = snapshot.receipts.find(r => r.eventId === event.id && r.sessionId === binding.id);
|
|
90
|
+
if (receipt && ['answered', 'delivered'].includes(receipt.state)) {
|
|
91
|
+
this.state.mark(event.id, binding.id, receipt.state);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (event.phase === 'progress' && !event.urgent) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const previous = this.state.delivery(event.id, binding.id);
|
|
98
|
+
if (previous && previous.state !== 'held')
|
|
99
|
+
continue;
|
|
100
|
+
const heldKey = `held-binding:${event.id}:${binding.id}`;
|
|
101
|
+
const readiness = JSON.stringify([binding.state, binding.hookSeenAt, binding.endpoint, Boolean(binding.inboxToken)]);
|
|
102
|
+
if (previous?.state === 'held' && this.state.get(heldKey) === readiness)
|
|
103
|
+
continue;
|
|
104
|
+
// Persist before the side effect. A process crash here is ambiguous, so no blind replay.
|
|
105
|
+
this.state.measure('native:' + event.kind, message(event));
|
|
106
|
+
this.state.mark(event.id, binding.id, 'attempting', 'Native delivery in progress');
|
|
107
|
+
let result;
|
|
108
|
+
try {
|
|
109
|
+
result = await this.deliver(binding, event);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
result = { state: 'uncertain', detail: 'Native delivery interrupted; use hook delivery or inspect the session' };
|
|
113
|
+
}
|
|
114
|
+
if (this.state.delivery(event.id, binding.id)?.state !== 'delivered') {
|
|
115
|
+
this.state.mark(event.id, binding.id, result.state, result.detail);
|
|
116
|
+
if (result.state === 'held')
|
|
117
|
+
this.state.set(heldKey, readiness);
|
|
118
|
+
if (previous?.state !== result.state || previous?.detail !== result.detail)
|
|
119
|
+
this.state.enqueue('receipts', { eventId: event.id, sessionId: binding.id, ...result }, `receipt:${event.id}:${binding.id}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
this.failures++;
|
|
125
|
+
this.retryAfter = Date.now() + Math.min(300_000, 1000 * 2 ** Math.min(this.failures, 9));
|
|
126
|
+
this.state.set('syncError', { message: error.message, at: new Date().toISOString() });
|
|
127
|
+
await this.updateTerminals('offline');
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
this.ticking = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async updateTerminals(status) {
|
|
134
|
+
for (const binding of this.state.bindings()) {
|
|
135
|
+
if (!binding.terminal)
|
|
136
|
+
continue;
|
|
137
|
+
const key = `terminal:${binding.id}`, last = this.state.get(key);
|
|
138
|
+
if (last?.status === status && (last.ok || Date.now() - last.at < 30_000))
|
|
139
|
+
continue;
|
|
140
|
+
const ok = await terminalStatus(binding, status);
|
|
141
|
+
this.state.set(key, { status, at: Date.now(), ok });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
start() {
|
|
145
|
+
for (const event of this.state.events())
|
|
146
|
+
if (event.targetSessionId && this.state.delivery(event.id, event.targetSessionId)?.state === 'attempting') {
|
|
147
|
+
this.state.mark(event.id, event.targetSessionId, 'uncertain', 'Connector restarted during native delivery; no automatic replay');
|
|
148
|
+
this.state.enqueue('receipts', { eventId: event.id, sessionId: event.targetSessionId, state: 'uncertain', detail: 'Connector restarted during native delivery' }, `receipt:${event.id}:${event.targetSessionId}`);
|
|
149
|
+
}
|
|
150
|
+
let reconnectFailures = 0;
|
|
151
|
+
const reconnect = () => { if (!this.stopped)
|
|
152
|
+
this.reconnect = setTimeout(connect, Math.min(300_000, 1000 * 2 ** Math.min(++reconnectFailures, 9))); };
|
|
153
|
+
const connect = () => {
|
|
154
|
+
if (this.stopped)
|
|
155
|
+
return;
|
|
156
|
+
try {
|
|
157
|
+
this.socket = this.remote.subscribe(() => { reconnectFailures = 0; this.dirty = true; void this.tick(false); }, reconnect);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
this.state.set('syncError', { message: error.message, at: new Date().toISOString() });
|
|
161
|
+
reconnect();
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
connect();
|
|
165
|
+
const loop = async () => { await this.tick(false); if (!this.stopped)
|
|
166
|
+
this.timer = setTimeout(loop, this.failures ? Math.min(30_000, 1000 * 2 ** Math.min(this.failures, 5)) : 1000); };
|
|
167
|
+
void loop();
|
|
168
|
+
}
|
|
169
|
+
async stop() { this.stopped = true; clearTimeout(this.timer); clearTimeout(this.reconnect); this.socket?.terminate(); while (this.ticking)
|
|
170
|
+
await new Promise(r => setTimeout(r, 10)); }
|
|
171
|
+
}
|