@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,129 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { userInfo } from 'node:os';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { setupPage } from './setup-page.js';
|
|
8
|
+
import { setupNetwork, detectClients } from './network-setup.js';
|
|
9
|
+
import { networkDir } from './network-state.js';
|
|
10
|
+
import { loadConnection } from './state.js';
|
|
11
|
+
import { launchRemoteClaude, remoteSetupDir, setupRemote } from './remote-setup.js';
|
|
12
|
+
export async function startSetupWeb(values, cli, execute = values.remote ? setupRemote : setupNetwork, launch = launchRemoteClaude) {
|
|
13
|
+
const dir = resolve(typeof values.dir === 'string' ? values.dir : values.remote ? remoteSetupDir() : networkDir());
|
|
14
|
+
const existing = existsSync(join(dir, 'connection.json')) ? loadConnection(dir) : undefined;
|
|
15
|
+
const token = randomBytes(32).toString('base64url');
|
|
16
|
+
let finish = () => { };
|
|
17
|
+
const completedSetup = new Promise(done => { finish = done; });
|
|
18
|
+
let origin = '', busy = false, completed = false;
|
|
19
|
+
let installed, launched = false;
|
|
20
|
+
const server = createServer(async (req, res) => {
|
|
21
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
22
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
23
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
24
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
25
|
+
const json = (status, value) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(value)); };
|
|
26
|
+
if (req.headers.host !== origin.slice('http://'.length) || (req.headers.origin && req.headers.origin !== origin)) {
|
|
27
|
+
json(403, { error: 'Open setup using the link from the Relay installer.' });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (req.method === 'GET' && req.url === '/') {
|
|
31
|
+
const nonce = randomBytes(18).toString('base64url');
|
|
32
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'` });
|
|
33
|
+
res.end(setupPage(nonce));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (req.headers.authorization !== 'Bearer ' + token) {
|
|
37
|
+
json(403, { error: 'Reopen the Relay installer to continue setup.' });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (req.method === 'GET' && req.url === '/api/status') {
|
|
41
|
+
json(200, { clients: detectClients(), name: existing?.name ?? userInfo().username, connected: !!existing, remote: !!values.remote, invitation: typeof values.invite === 'string' ? values.invite : '' });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (req.method === 'POST' && req.url === '/api/launch') {
|
|
45
|
+
if (!installed?.launcher) {
|
|
46
|
+
json(409, { error: 'Connect Claude Code first.' });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (launched) {
|
|
50
|
+
json(200, { launched: true });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
launched = true;
|
|
54
|
+
try {
|
|
55
|
+
await launch(installed.launcher);
|
|
56
|
+
json(200, { launched: true });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
launched = false;
|
|
60
|
+
json(400, { error: 'Open the Claude with Relay shortcut on your desktop. Make sure Claude Code is installed.' });
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (req.method !== 'POST' || req.url !== '/api/setup') {
|
|
65
|
+
json(404, { error: 'Not found' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const result = () => ({ connected: true, ...(installed ? { remote: true, providers: installed.providers, canLaunch: !!installed.launcher, roomURL: installed.roomURL } : {}) });
|
|
69
|
+
if (completed) {
|
|
70
|
+
json(200, result());
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (busy) {
|
|
74
|
+
json(409, { error: 'Setup is already running. Please wait.' });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
busy = true;
|
|
78
|
+
try {
|
|
79
|
+
if (req.headers['content-type'] !== 'application/json')
|
|
80
|
+
throw new Error('Send a JSON setup request.');
|
|
81
|
+
let body = '';
|
|
82
|
+
for await (const chunk of req) {
|
|
83
|
+
body += chunk;
|
|
84
|
+
if (body.length > 16000)
|
|
85
|
+
throw new Error('The invitation is too long.');
|
|
86
|
+
}
|
|
87
|
+
const input = JSON.parse(body);
|
|
88
|
+
if (typeof input.name !== 'string' || (!existing && !input.name.trim()) || input.name.length > 80 || typeof input.invitation !== 'string' || !['auto', 'codex', 'claude', 'both'].includes(input.provider))
|
|
89
|
+
throw new Error('Enter your name and a valid Relay invitation.');
|
|
90
|
+
if (!existing && !input.invitation.trim())
|
|
91
|
+
throw new Error('Paste the Relay invitation from your friend.');
|
|
92
|
+
const configured = await execute({ ...values, dir, provider: input.provider, ...(!existing ? { name: input.name.trim(), invite: input.invitation.trim() } : {}) }, cli, { interactive: false, ask: async () => { throw new Error('Setup needs additional information.'); }, log: () => { } });
|
|
93
|
+
if ('remote' in configured)
|
|
94
|
+
installed = configured;
|
|
95
|
+
completed = true;
|
|
96
|
+
json(200, result());
|
|
97
|
+
finish();
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
json(400, { error: error instanceof SyntaxError ? 'The setup request was not valid.' : error.message });
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
busy = false;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
await new Promise((done, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => { server.off('error', reject); done(); }); });
|
|
107
|
+
origin = 'http://127.0.0.1:' + server.address().port;
|
|
108
|
+
return { url: origin + '/#' + token, completed: completedSetup, close: () => new Promise((done, reject) => { server.close(error => error ? reject(error) : done()); server.closeIdleConnections(); }) };
|
|
109
|
+
}
|
|
110
|
+
export async function openSetupWeb(values, cli) {
|
|
111
|
+
const web = await startSetupWeb(values, cli);
|
|
112
|
+
console.log(`Finish setting up Relay in your browser:\n${web.url}`);
|
|
113
|
+
if (!values['no-open']) {
|
|
114
|
+
const executable = process.platform === 'win32' ? 'rundll32.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
115
|
+
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', web.url] : [web.url];
|
|
116
|
+
const child = spawn(executable, args, { windowsHide: true, stdio: 'ignore' });
|
|
117
|
+
child.on('error', () => console.error('Open the setup link above in your browser.'));
|
|
118
|
+
child.unref();
|
|
119
|
+
}
|
|
120
|
+
let stopped = false;
|
|
121
|
+
const stop = () => { if (stopped)
|
|
122
|
+
return; stopped = true; clearTimeout(timer); process.off('SIGINT', stop); process.off('SIGTERM', stop); void web.close(); };
|
|
123
|
+
const timer = setTimeout(stop, 15 * 60_000);
|
|
124
|
+
timer.unref();
|
|
125
|
+
process.once('SIGINT', stop);
|
|
126
|
+
process.once('SIGTERM', stop);
|
|
127
|
+
if (!values.remote)
|
|
128
|
+
void web.completed.then(() => { const finished = setTimeout(stop, 10000); finished.unref(); });
|
|
129
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
export class SQLite {
|
|
5
|
+
db;
|
|
6
|
+
constructor(path) {
|
|
7
|
+
if (path !== ':memory:')
|
|
8
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
9
|
+
this.db = new DatabaseSync(path);
|
|
10
|
+
try {
|
|
11
|
+
this.db.exec('PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL');
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
this.db.close();
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
all(sql, ...values) { return this.db.prepare(sql).all(...values); }
|
|
19
|
+
exec(sql, ...values) { this.db.prepare(sql).run(...values); }
|
|
20
|
+
transaction(body) {
|
|
21
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
22
|
+
try {
|
|
23
|
+
const result = body();
|
|
24
|
+
this.db.exec('COMMIT');
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
this.db.exec('ROLLBACK');
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
close() { this.db.close(); }
|
|
33
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { SQLite } from './sqlite.js';
|
|
4
|
+
export class State {
|
|
5
|
+
dir;
|
|
6
|
+
sql;
|
|
7
|
+
constructor(dir) {
|
|
8
|
+
this.dir = dir;
|
|
9
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
10
|
+
this.sql = new SQLite(join(dir, 'state.sqlite3'));
|
|
11
|
+
for (const statement of [
|
|
12
|
+
'CREATE TABLE IF NOT EXISTS kv(key TEXT PRIMARY KEY,value TEXT NOT NULL)',
|
|
13
|
+
'CREATE TABLE IF NOT EXISTS bindings(id TEXT PRIMARY KEY,data TEXT NOT NULL)',
|
|
14
|
+
'CREATE TABLE IF NOT EXISTS outbox(id TEXT PRIMARY KEY,action TEXT NOT NULL,body TEXT NOT NULL,created INTEGER NOT NULL)',
|
|
15
|
+
'CREATE TABLE IF NOT EXISTS cache(seq INTEGER PRIMARY KEY,id TEXT UNIQUE NOT NULL,data TEXT NOT NULL)',
|
|
16
|
+
"CREATE INDEX IF NOT EXISTS cache_target ON cache(json_extract(data,'$.targetSessionId'),seq)",
|
|
17
|
+
'CREATE TABLE IF NOT EXISTS current_context(id TEXT PRIMARY KEY,data TEXT NOT NULL)',
|
|
18
|
+
'CREATE TABLE IF NOT EXISTS supersessions(previous_id TEXT PRIMARY KEY,replacement_id TEXT NOT NULL)',
|
|
19
|
+
'CREATE VIRTUAL TABLE IF NOT EXISTS history_search USING fts5(event_id UNINDEXED, text, paths)',
|
|
20
|
+
'CREATE TABLE IF NOT EXISTS delivery(event_id TEXT NOT NULL,session_id TEXT NOT NULL,state TEXT NOT NULL,detail TEXT NOT NULL,PRIMARY KEY(event_id,session_id))',
|
|
21
|
+
'CREATE TABLE IF NOT EXISTS metrics(name TEXT PRIMARY KEY,count INTEGER NOT NULL,chars INTEGER NOT NULL,bytes INTEGER NOT NULL)',
|
|
22
|
+
'CREATE TABLE IF NOT EXISTS supplied(session_id TEXT NOT NULL,event_id TEXT NOT NULL,PRIMARY KEY(session_id,event_id))'
|
|
23
|
+
])
|
|
24
|
+
this.sql.exec(statement);
|
|
25
|
+
}
|
|
26
|
+
measure(name, value) { this.sql.exec('INSERT INTO metrics VALUES (?,1,?,?) ON CONFLICT(name) DO UPDATE SET count=count+1,chars=chars+excluded.chars,bytes=bytes+excluded.bytes', name, value.length, Buffer.byteLength(value)); }
|
|
27
|
+
metrics() { return this.sql.all('SELECT name,count,chars,bytes FROM metrics ORDER BY name'); }
|
|
28
|
+
get(key) { const row = this.sql.all('SELECT value FROM kv WHERE key=?', key)[0]; return row ? JSON.parse(row.value) : undefined; }
|
|
29
|
+
set(key, value) { this.sql.exec('INSERT INTO kv VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value', key, JSON.stringify(value)); }
|
|
30
|
+
bindings() { return this.sql.all('SELECT data FROM bindings').map(r => JSON.parse(r.data)); }
|
|
31
|
+
binding(sessionId) {
|
|
32
|
+
const result = this.bindings().find(s => s.id === sessionId);
|
|
33
|
+
if (!result)
|
|
34
|
+
throw new Error('This session is not connected to this repository. Run relay-next connect and resume the coding session.');
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
bind(binding) { this.sql.exec('INSERT INTO bindings VALUES (?,?) ON CONFLICT(id) DO UPDATE SET data=excluded.data', binding.id, JSON.stringify(binding)); }
|
|
38
|
+
enqueue(action, body, key = crypto.randomUUID()) {
|
|
39
|
+
if (action === 'receipts') {
|
|
40
|
+
const previous = this.sql.all('SELECT body FROM outbox WHERE id=?', key)[0];
|
|
41
|
+
if (previous && ['delivered', 'answered'].includes(JSON.parse(previous.body).state))
|
|
42
|
+
return key;
|
|
43
|
+
}
|
|
44
|
+
this.sql.exec('INSERT INTO outbox VALUES (?,?,?,?) ON CONFLICT(id) DO UPDATE SET body=excluded.body', key, action, JSON.stringify(body), Date.now());
|
|
45
|
+
return key;
|
|
46
|
+
}
|
|
47
|
+
pending() { return this.sql.all('SELECT id,action,body FROM outbox ORDER BY created,rowid LIMIT 100').map(r => ({ ...r, body: JSON.parse(r.body) })); }
|
|
48
|
+
remove(jobId) { this.sql.exec('DELETE FROM outbox WHERE id=?', jobId); }
|
|
49
|
+
cache(events) {
|
|
50
|
+
this.sql.transaction(() => {
|
|
51
|
+
for (const event of events) {
|
|
52
|
+
const known = this.sql.all('SELECT id FROM cache WHERE id=?', event.id).length;
|
|
53
|
+
this.sql.exec('INSERT INTO cache VALUES (?,?,?) ON CONFLICT(id) DO NOTHING', event.seq, event.id, JSON.stringify(event));
|
|
54
|
+
if (!known) {
|
|
55
|
+
this.sql.exec('INSERT INTO history_search VALUES (?,?,?)', event.id, event.text, event.paths.join(' '));
|
|
56
|
+
if (event.supersedes) {
|
|
57
|
+
this.sql.exec('DELETE FROM current_context WHERE id=?', event.supersedes);
|
|
58
|
+
this.sql.exec('INSERT OR REPLACE INTO supersessions VALUES (?,?)', event.supersedes, event.id);
|
|
59
|
+
}
|
|
60
|
+
if (['decision', 'contract'].includes(event.kind))
|
|
61
|
+
this.sql.exec('INSERT OR IGNORE INTO current_context VALUES (?,?)', event.id, JSON.stringify(event));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (events.length)
|
|
65
|
+
this.set('cursor', Math.max(this.get('cursor') ?? 0, ...events.map(e => e.seq)));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
events() { return this.sql.all('SELECT data FROM cache ORDER BY seq DESC LIMIT 2000').map(r => JSON.parse(r.data)).reverse(); }
|
|
69
|
+
event(eventId) { const row = this.sql.all('SELECT data FROM cache WHERE id=?', eventId)[0]; return row ? JSON.parse(row.data) : undefined; }
|
|
70
|
+
decisions() { return this.sql.all('SELECT data FROM current_context ORDER BY rowid DESC LIMIT 500').map(r => JSON.parse(r.data)); }
|
|
71
|
+
superseded(eventId) { return !!this.sql.all('SELECT 1 FROM supersessions WHERE previous_id=?', eventId).length; }
|
|
72
|
+
search(query) {
|
|
73
|
+
const terms = query.match(/[\p{L}\p{N}_]+/gu)?.slice(0, 12).map(t => '"' + t + '"').join(' OR ');
|
|
74
|
+
if (!terms)
|
|
75
|
+
return [];
|
|
76
|
+
return this.sql.all('SELECT c.data FROM history_search h JOIN cache c ON c.id=h.event_id WHERE history_search MATCH ? ORDER BY rank LIMIT 100', terms).map(r => JSON.parse(r.data));
|
|
77
|
+
}
|
|
78
|
+
messages(sessionId) { return this.sql.all("SELECT c.data FROM cache c LEFT JOIN delivery d ON d.event_id=c.id AND d.session_id=? WHERE json_extract(c.data,'$.targetSessionId')=? AND (json_extract(c.data,'$.phase') IS NULL OR json_extract(c.data,'$.phase')!='progress' OR json_extract(c.data,'$.urgent')=1) AND (d.state IS NULL OR d.state='held') ORDER BY c.seq LIMIT 50", sessionId, sessionId).map(r => JSON.parse(r.data)); }
|
|
79
|
+
delivery(eventId, sessionId) { return this.sql.all('SELECT state,detail FROM delivery WHERE event_id=? AND session_id=?', eventId, sessionId)[0]; }
|
|
80
|
+
mark(eventId, sessionId, status, detail = '') {
|
|
81
|
+
const previous = this.delivery(eventId, sessionId);
|
|
82
|
+
if (previous && (previous.state === 'answered' || previous.state === 'delivered') && status !== 'answered')
|
|
83
|
+
return;
|
|
84
|
+
this.sql.exec('INSERT INTO delivery VALUES (?,?,?,?) ON CONFLICT(event_id,session_id) DO UPDATE SET state=excluded.state,detail=excluded.detail', eventId, sessionId, status, detail);
|
|
85
|
+
}
|
|
86
|
+
supplied(sessionId, eventId) { return !!this.sql.all('SELECT 1 FROM supplied WHERE session_id=? AND event_id=?', sessionId, eventId).length; }
|
|
87
|
+
supply(sessionId, eventId) { this.sql.exec('INSERT OR IGNORE INTO supplied VALUES (?,?)', sessionId, eventId); }
|
|
88
|
+
publish(event) {
|
|
89
|
+
const captured = { ...event, git: event.git ?? this.binding(event.sessionId).git, occurredAt: event.occurredAt ?? new Date().toISOString() };
|
|
90
|
+
this.enqueue('events', captured, event.id);
|
|
91
|
+
return { eventId: event.id, status: 'pending_sync' };
|
|
92
|
+
}
|
|
93
|
+
close() { this.sql.close(); }
|
|
94
|
+
}
|
|
95
|
+
export function stateDir(repo) { return join(resolve(repo), '.relay', 'next'); }
|
|
96
|
+
export function loadConnection(dir) {
|
|
97
|
+
const path = join(dir, 'connection.json');
|
|
98
|
+
if (!existsSync(path))
|
|
99
|
+
throw new Error('This repository is not connected. Run relay-next connect.');
|
|
100
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
101
|
+
}
|
|
102
|
+
export function saveConnection(dir, config) {
|
|
103
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
104
|
+
const temp = join(dir, `connection.${crypto.randomUUID()}.tmp`), target = join(dir, 'connection.json');
|
|
105
|
+
writeFileSync(temp, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
106
|
+
renameSync(temp, target);
|
|
107
|
+
if (process.platform !== 'win32')
|
|
108
|
+
chmodSync(target, 0o600);
|
|
109
|
+
}
|
|
110
|
+
export function publicSession(binding) {
|
|
111
|
+
const { endpoint: _, inboxToken: __, transcriptPath: ___, hookSeenAt: ____, terminal: _____, workspace: ______, sessionKey: _______, ...session } = binding;
|
|
112
|
+
return session;
|
|
113
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { connect } from 'node:net';
|
|
2
|
+
// Optional presentation only. Exact recorded targets; never type input, press keys,
|
|
3
|
+
// change focus, expose sockets remotely, or relax a terminal's access policy.
|
|
4
|
+
export async function terminalStatus(binding, status) {
|
|
5
|
+
const terminal = binding.terminal;
|
|
6
|
+
if (!terminal?.socket || !terminal.workspace || !terminal.surface)
|
|
7
|
+
return false;
|
|
8
|
+
if (!/^[\w:-]+$/.test(terminal.workspace) || !/^[\w:-]+$/.test(terminal.surface))
|
|
9
|
+
return false;
|
|
10
|
+
if (terminal.kind === 'wmux' && !terminal.token)
|
|
11
|
+
return false;
|
|
12
|
+
return new Promise(resolve => {
|
|
13
|
+
const socket = connect(terminal.socket);
|
|
14
|
+
let finished = false;
|
|
15
|
+
const finish = (success) => { if (finished)
|
|
16
|
+
return; finished = true; clearTimeout(timer); socket.destroy(); resolve(success); };
|
|
17
|
+
const timer = setTimeout(() => finish(false), 500);
|
|
18
|
+
socket.on('connect', () => {
|
|
19
|
+
const request = terminal.kind === 'wmux'
|
|
20
|
+
? JSON.stringify({ id: crypto.randomUUID(), method: 'sidebar.set_status', params: { key: 'relay_next', value: status, caller: terminal.surface, workspaceId: terminal.workspace }, token: terminal.token })
|
|
21
|
+
: `set_status relay_next ${status} --tab=${terminal.workspace}`;
|
|
22
|
+
socket.write(request + '\n');
|
|
23
|
+
});
|
|
24
|
+
socket.on('data', data => { const text = String(data); finish(!/"ok"\s*:\s*false|"error"|^ERR/i.test(text)); });
|
|
25
|
+
socket.on('error', () => finish(false));
|
|
26
|
+
socket.on('end', () => finish(false));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { bounded, paths, redact, fresh } from '../core/types.js';
|
|
4
|
+
import { context, conflicts, INSTRUCTIONS, compactCode, eventPage, integer } from './context.js';
|
|
5
|
+
import { snapshot } from './git.js';
|
|
6
|
+
import { advancedTools, callAdvanced } from './workflow.js';
|
|
7
|
+
import { loadConnection } from './state.js';
|
|
8
|
+
import { Remote } from './client.js';
|
|
9
|
+
import { discover, NETWORK_INSTRUCTIONS, workspaceCode, sharedWorkspace } from './network-state.js';
|
|
10
|
+
import { VERSION } from '../core/version.js';
|
|
11
|
+
const string = { type: 'string' };
|
|
12
|
+
const definition = (name, description, properties, required) => ({ name, description, inputSchema: { type: 'object', properties: { session_id: string, ...properties }, required: ['session_id', ...required], additionalProperties: false } });
|
|
13
|
+
export const TOOLS = [
|
|
14
|
+
...advancedTools,
|
|
15
|
+
definition('relay_event', 'Read an exact event preview or next character range; does not wake another model.', { event_id: string, offset: { type: 'integer' }, limit: { type: 'integer' } }, ['event_id']),
|
|
16
|
+
definition('relay_status', 'See peer activity, last sync, actual delivery receipts and overlapping work. Paginated; receipts only on request.', { offset: { type: 'integer' }, limit: { type: 'integer' }, include_receipts: { type: 'boolean' } }, []),
|
|
17
|
+
definition('relay_context', 'Retrieve source-attributed decisions and history relevant to the current work. Code presence is reported separately.', { query: string, max_chars: { type: 'integer' } }, []),
|
|
18
|
+
definition('relay_publish', 'Publish meaningful engineering intent, decisions, contracts or test evidence. Include why; never publish secrets.', { kind: { type: 'string', enum: ['intent', 'decision', 'contract', 'test'] }, text: string, paths: { type: 'array', items: string }, evidence: string, supersedes: string, urgent: { type: 'boolean' } }, ['kind', 'text', 'paths']),
|
|
19
|
+
definition('relay_ask', 'Ask an exact peer session. Returns a request ID immediately; the answer arrives asynchronously.', { target_session_id: string, question: string, paths: { type: 'array', items: string } }, ['target_session_id', 'question']),
|
|
20
|
+
definition('relay_reply', 'Answer a question addressed to this session using retained source knowledge; state uncertainties.', { question_id: string, answer: string, evidence: string, phase: { type: 'string', enum: ['progress', 'final'] } }, ['question_id', 'answer']),
|
|
21
|
+
definition('relay_ack', 'Acknowledge a direct message that was actually supplied to this session. This is receipt, not approval.', { event_id: string, event_ids: { type: 'array', items: string } }, [])
|
|
22
|
+
];
|
|
23
|
+
const NETWORK_NAMES = new Set(['relay_invite', 'relay_discover', 'relay_presence', 'relay_event', 'relay_status', 'relay_context', 'relay_ask', 'relay_reply', 'relay_ack']);
|
|
24
|
+
export const NETWORK_TOOLS = [
|
|
25
|
+
definition('relay_invite', 'Create a single-use invitation when the user asks to add a friend. Owner/admin only. Return it to the user; never send it to peers automatically. The invite grants group membership, including access to shared network records.', {}, []),
|
|
26
|
+
definition('relay_discover', 'Find invited peers across workspaces by person and task/topic. Multiple matches require a session choice.', { person: string, topic: string, active_only: { type: 'boolean' }, offset: { type: 'integer' }, limit: { type: 'integer' } }, []),
|
|
27
|
+
definition('relay_presence', 'Share a brief, non-sensitive description of the current task. No prompt/transcript upload.', { task: string }, ['task']),
|
|
28
|
+
...TOOLS.filter(t => NETWORK_NAMES.has(t.name))
|
|
29
|
+
].map(tool => ({ ...tool, inputSchema: { ...tool.inputSchema, properties: { ...tool.inputSchema.properties, session_key: string }, required: [...tool.inputSchema.required, 'session_key'] } }));
|
|
30
|
+
export function callTool(state, name, args) {
|
|
31
|
+
const binding = state.binding(String(args.session_id)), sessionId = binding.id;
|
|
32
|
+
const network = state.get('mode') === 'network';
|
|
33
|
+
if (network && !NETWORK_NAMES.has(name))
|
|
34
|
+
throw new Error('This tool requires a repository connection');
|
|
35
|
+
if (network && (!binding.sessionKey || args.session_key !== binding.sessionKey))
|
|
36
|
+
throw new Error('Use the session_id and local session_key supplied by this session\'s Relay Network hook');
|
|
37
|
+
if (network && (!binding.workspace || !sharedWorkspace(state, binding.workspace) || binding.state === 'closed'))
|
|
38
|
+
throw new Error('This session is no longer shared; resume it in an allowed workspace');
|
|
39
|
+
if (name === 'relay_invite') {
|
|
40
|
+
if (!network)
|
|
41
|
+
throw new Error('Inviting a friend requires a network connection');
|
|
42
|
+
const config = loadConnection(state.dir);
|
|
43
|
+
return new Remote(config).call('invite', {}).then(invite => ({
|
|
44
|
+
invitation: 'relay:network:v1:' + Buffer.from(JSON.stringify({ server: config.server, projectId: config.projectId, repository: config.repository, code: invite.code })).toString('base64url'),
|
|
45
|
+
expiresAt: new Date(invite.expiresAt).toISOString(), singleUse: true,
|
|
46
|
+
instructions: 'Give this invitation to your friend. They run Relay setup and paste it when asked. All network members can access shared network records.'
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
if (name === 'relay_discover')
|
|
50
|
+
return discover(state, sessionId, args);
|
|
51
|
+
if (name === 'relay_presence') {
|
|
52
|
+
binding.task = redact(bounded(args.task, 1000));
|
|
53
|
+
binding.updatedAt = new Date().toISOString();
|
|
54
|
+
state.bind(binding);
|
|
55
|
+
return { sessionId, task: binding.task, status: 'pending_sync' };
|
|
56
|
+
}
|
|
57
|
+
if (name === 'relay_status') {
|
|
58
|
+
const saved = state.get('snapshot');
|
|
59
|
+
const sessions = saved?.sessions ?? [];
|
|
60
|
+
const offset = integer(args.offset, 0, 0, 10000), limit = integer(args.limit, 8, 1, 20);
|
|
61
|
+
return { sessions: sessions.slice(offset, offset + limit).map(s => ({ id: s.id, name: s.name, provider: s.provider, state: s.state, task: s.task.slice(0, 300), updatedAt: s.updatedAt, freshActivity: fresh(s), git: compactCode(s.git), scopes: s.scopes.slice(0, 8) })), nextOffset: offset + limit < sessions.length ? offset + limit : null, totalSessions: sessions.length, receipts: args.include_receipts ? saved?.receipts?.slice(offset, offset + limit) ?? [] : [], overlaps: conflicts(sessions).slice(0, 8), lastSync: state.get('lastSync') ?? null, syncError: state.get('syncError') ?? null, pendingUploads: state.pending().length, rejectedUploads: state.sql.all("SELECT value FROM kv WHERE key LIKE 'rejected:%' LIMIT 8").map(r => JSON.parse(r.value)) };
|
|
62
|
+
}
|
|
63
|
+
if (name === 'relay_event') {
|
|
64
|
+
const event = state.event(String(args.event_id));
|
|
65
|
+
if (!event)
|
|
66
|
+
throw new Error('Event not in local cache; wait for synchronization');
|
|
67
|
+
if (network && event.sessionId !== sessionId && event.targetSessionId !== sessionId)
|
|
68
|
+
throw new Error('This message belongs to another conversation');
|
|
69
|
+
state.supply(sessionId, event.id);
|
|
70
|
+
return eventPage(event, integer(args.offset, 0, 0, 11000), integer(args.limit, 2000, 100, 4000));
|
|
71
|
+
}
|
|
72
|
+
if (name === 'relay_context') {
|
|
73
|
+
const budget = integer(args.max_chars, 5000, 1500, 12000), pack = context(state, sessionId, String(args.query ?? ''), true, budget - 700);
|
|
74
|
+
const result = { expand: 'relay_event(event_id, offset) for full evidence', text: pack.text, eventIds: pack.events.map(e => e.id), omitted: pack.omitted, cursor: pack.cursor, lastSync: pack.lastSync, coverage: pack.coverage };
|
|
75
|
+
// Bound the serialized response, not only the readable body.
|
|
76
|
+
while (JSON.stringify(result).length > budget && result.text.length) {
|
|
77
|
+
result.text = result.text.slice(0, -100);
|
|
78
|
+
}
|
|
79
|
+
for (const event of pack.events)
|
|
80
|
+
state.supply(sessionId, event.id);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
if (name === 'relay_ack') {
|
|
84
|
+
const ids = args.event_ids ?? [args.event_id];
|
|
85
|
+
if (!Array.isArray(ids) || !ids.length || ids.length > 50)
|
|
86
|
+
throw new Error('Supply 1..50 event IDs');
|
|
87
|
+
const events = ids.map(id => state.event(String(id)));
|
|
88
|
+
if (events.some(event => !event || event.targetSessionId !== sessionId))
|
|
89
|
+
throw new Error('This message is not addressed to this session');
|
|
90
|
+
state.sql.transaction(() => { for (const event of events) {
|
|
91
|
+
state.supply(sessionId, event.id);
|
|
92
|
+
state.mark(event.id, sessionId, 'delivered', 'Recipient acknowledged receipt');
|
|
93
|
+
state.enqueue('receipts', { eventId: event.id, sessionId, state: 'delivered', detail: 'Recipient acknowledged receipt' }, `receipt:${event.id}:${sessionId}`);
|
|
94
|
+
} });
|
|
95
|
+
return { received: true, eventIds: ids };
|
|
96
|
+
}
|
|
97
|
+
const repo = binding.workspace ?? state.get('repo');
|
|
98
|
+
if (!repo)
|
|
99
|
+
throw new Error('Missing connected repository');
|
|
100
|
+
binding.git = network ? workspaceCode(repo) : snapshot(repo, binding.git.repository);
|
|
101
|
+
binding.updatedAt = new Date().toISOString();
|
|
102
|
+
const event = { id: crypto.randomUUID(), sessionId, kind: 'decision', text: '', paths: paths(args.paths ?? []) };
|
|
103
|
+
if (name === 'relay_publish') {
|
|
104
|
+
if (!['intent', 'decision', 'contract', 'test'].includes(args.kind))
|
|
105
|
+
throw new Error('Invalid publication kind');
|
|
106
|
+
event.kind = args.kind;
|
|
107
|
+
event.text = redact(bounded(args.text, 8000));
|
|
108
|
+
if (args.evidence)
|
|
109
|
+
event.evidence = redact(bounded(args.evidence, 3000));
|
|
110
|
+
if (args.supersedes)
|
|
111
|
+
event.supersedes = args.supersedes;
|
|
112
|
+
event.urgent = args.urgent === true;
|
|
113
|
+
if (event.kind === 'intent') {
|
|
114
|
+
binding.task = event.text.slice(0, 1000);
|
|
115
|
+
binding.scopes = event.paths;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else if (name === 'relay_ask') {
|
|
119
|
+
const target = state.get('snapshot')?.sessions.find(s => s.id === args.target_session_id);
|
|
120
|
+
if (!target || target.id === sessionId)
|
|
121
|
+
throw new Error('Choose a peer session ID from relay_status');
|
|
122
|
+
event.kind = 'question';
|
|
123
|
+
event.targetSessionId = target.id;
|
|
124
|
+
event.text = redact(bounded(args.question, 8000));
|
|
125
|
+
}
|
|
126
|
+
else if (name === 'relay_reply') {
|
|
127
|
+
const question = state.event(args.question_id);
|
|
128
|
+
if (question?.kind !== 'question' || question.targetSessionId !== sessionId)
|
|
129
|
+
throw new Error('Question was not addressed to this session');
|
|
130
|
+
event.kind = 'reply';
|
|
131
|
+
event.replyTo = question.id;
|
|
132
|
+
event.targetSessionId = question.sessionId;
|
|
133
|
+
event.text = redact(bounded(args.answer, 8000));
|
|
134
|
+
if (args.evidence)
|
|
135
|
+
event.evidence = redact(bounded(args.evidence, 3000));
|
|
136
|
+
if (args.phase !== undefined && !['progress', 'final'].includes(args.phase))
|
|
137
|
+
throw new Error('Invalid reply phase');
|
|
138
|
+
event.phase = args.phase ?? 'final';
|
|
139
|
+
if (event.phase === 'final') {
|
|
140
|
+
event.id = 'answer_' + createHash('sha256').update(sessionId + question.id).digest('hex').slice(0, 40);
|
|
141
|
+
const previous = state.get('answer:' + event.id) ?? state.event(event.id) ?? state.pending().map(j => j.body).find(e => e.id === event.id);
|
|
142
|
+
if (previous) {
|
|
143
|
+
if (previous.text !== event.text || previous.evidence !== event.evidence)
|
|
144
|
+
throw new Error('Final answer already recorded; use progress for an explicit correction/update');
|
|
145
|
+
return { eventId: event.id, status: 'already_recorded' };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
state.supply(sessionId, question.id);
|
|
149
|
+
state.mark(question.id, sessionId, event.phase === 'final' ? 'answer_pending' : 'delivered', 'Reply in durable local outbox');
|
|
150
|
+
}
|
|
151
|
+
else
|
|
152
|
+
throw new Error('Unknown Relay tool');
|
|
153
|
+
return state.sql.transaction(() => { state.bind(binding); const result = state.publish(event); if (event.kind === 'reply' && event.phase === 'final')
|
|
154
|
+
state.set('answer:' + event.id, event); return result; });
|
|
155
|
+
}
|
|
156
|
+
export async function callRelayTool(state, name, args) {
|
|
157
|
+
if (state.get('mode') === 'network' && !NETWORK_NAMES.has(name))
|
|
158
|
+
throw new Error('This tool requires a repository connection');
|
|
159
|
+
const result = advancedTools.some(t => t.name === name) ? await callAdvanced(state, name, args) : await callTool(state, name, args);
|
|
160
|
+
state.measure('tool:' + name, JSON.stringify(result));
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
export async function serveMcp(state, input = process.stdin, output = process.stdout) {
|
|
164
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
165
|
+
for await (const line of lines) {
|
|
166
|
+
if (line.length > 100_000)
|
|
167
|
+
continue;
|
|
168
|
+
let request;
|
|
169
|
+
try {
|
|
170
|
+
request = JSON.parse(line);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
output.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) + '\n');
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (request.id === undefined)
|
|
177
|
+
continue;
|
|
178
|
+
let result;
|
|
179
|
+
try {
|
|
180
|
+
if (request.method === 'initialize')
|
|
181
|
+
result = { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: state.get('mode') === 'network' ? 'relay-network' : 'relay-next', version: VERSION }, instructions: state.get('mode') === 'network' ? NETWORK_INSTRUCTIONS : INSTRUCTIONS };
|
|
182
|
+
else if (request.method === 'ping')
|
|
183
|
+
result = {};
|
|
184
|
+
else if (request.method === 'tools/list')
|
|
185
|
+
result = { tools: state.get('mode') === 'network' ? NETWORK_TOOLS : TOOLS };
|
|
186
|
+
else if (request.method === 'tools/call') {
|
|
187
|
+
try {
|
|
188
|
+
result = { content: [{ type: 'text', text: JSON.stringify(await callRelayTool(state, request.params.name, request.params.arguments ?? {})) }] };
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
result = { isError: true, content: [{ type: 'text', text: error.message }] };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
output.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found' } }) + '\n');
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
output.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n');
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
output.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32603, message: 'Relay request failed' } }) + '\n');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { loadConnection, publicSession } from './state.js';
|
|
2
|
+
import { Remote } from './client.js';
|
|
3
|
+
import { snapshot } from './git.js';
|
|
4
|
+
import { exportArtifact, receiveArtifact, readArtifact } from './artifacts.js';
|
|
5
|
+
import { prepare, validate } from './integration.js';
|
|
6
|
+
import { integer, compactCode } from './context.js';
|
|
7
|
+
const str = { type: 'string' }, num = { type: 'integer' }, bool = { type: 'boolean' }, strings = { type: 'array', items: str };
|
|
8
|
+
const tool = (name, description, properties, required = []) => ({ name, description, inputSchema: { type: 'object', properties: { session_id: str, ...properties }, required: ['session_id', ...required], additionalProperties: false } });
|
|
9
|
+
export const advancedTools = [
|
|
10
|
+
tool('relay_project', 'Read the local shared project view without waking peers. Source claims and freshness remain explicit.', {}),
|
|
11
|
+
tool('relay_task', 'Reserve file scopes with an expiring lease. Refresh versions before renew/handoff/complete/release. Native edits outside Relay are not locked.', { operation: { type: 'string', enum: ['list', 'claim', 'renew', 'handoff', 'complete', 'release', 'block'] }, id: str, version: num, title: str, paths: strings, dependencies: strings, target_session_id: str, ttl_seconds: num, offset: num, limit: num }, ['operation']),
|
|
12
|
+
tool('relay_artifact', 'Transfer exact selected file bytes, not source text in replies. Export requires local user sharing authorization. Read returns a bounded range; receiving never applies files.', { operation: { type: 'string', enum: ['list', 'export', 'receive', 'read', 'abort'] }, artifact_id: str, paths: strings, share: bool, allow_untracked: bool, base_ref: str, summary: str, target_session_id: str, path: str, offset: num, limit: num }, ['operation']),
|
|
13
|
+
tool('relay_integration', 'Prepare an artifact in an isolated Git worktree or validate using an explicit local command. Accept is a separate developer-authorized CLI step.', { operation: { type: 'string', enum: ['prepare', 'validate'] }, artifact_id: str, id: str, command: strings, timeout_ms: num }, ['operation']),
|
|
14
|
+
tool('relay_metrics', 'Read local payload sizes and operation counts. These are not provider tokens or billing.', {})
|
|
15
|
+
];
|
|
16
|
+
export async function callAdvanced(state, name, args) {
|
|
17
|
+
const session = state.binding(args.session_id), remote = new Remote(loadConnection(state.dir)), repo = state.get('repo');
|
|
18
|
+
if (name === 'relay_metrics')
|
|
19
|
+
return { counters: state.metrics(), unit: 'characters and UTF-8 bytes; no model token or billing claim', providerUsage: 'Not collected' };
|
|
20
|
+
if (name === 'relay_project') {
|
|
21
|
+
const shared = state.get('snapshot') ?? {};
|
|
22
|
+
return { project: shared.project ?? { repository: session.git.repository }, acceptedRevision: shared.acceptedRevision ?? null, lastSync: state.get('lastSync') ?? null, tasks: (shared.tasks ?? []).filter((t) => t.status !== 'released' && t.status !== 'complete').slice(0, 12).map((t) => ({ ...t, leaseExpired: t.expiresAt <= Date.now() })), decisions: state.decisions().slice(-8).reverse().map(e => ({ id: e.id, kind: e.kind, text: e.text.slice(0, 300), source: e.sessionId, at: e.createdAt, head: e.git.head, dirty: e.git.dirty })), coverage: 'Up to 12 unfinished tasks and 8 current decisions; use context search for more. Recorded facts are not a fresh owner answer.' };
|
|
23
|
+
}
|
|
24
|
+
if (name === 'relay_task') {
|
|
25
|
+
if (args.operation === 'list') {
|
|
26
|
+
const saved = await remote.call('tasks'), offset = integer(args.offset, 0, 0, 10000), limit = integer(args.limit, 10, 1, 25);
|
|
27
|
+
return { tasks: saved.tasks.slice(offset, offset + limit), total: saved.tasks.length, nextOffset: offset + limit < saved.tasks.length ? offset + limit : null, acceptedRevision: saved.acceptedRevision };
|
|
28
|
+
}
|
|
29
|
+
session.git = snapshot(repo, session.git.repository);
|
|
30
|
+
state.bind(session);
|
|
31
|
+
await remote.call('sessions', publicSession(session));
|
|
32
|
+
return remote.call('tasks', { ...args, sessionId: session.id, targetSessionId: args.target_session_id, ttlSeconds: args.ttl_seconds, baseHead: session.git.head });
|
|
33
|
+
}
|
|
34
|
+
if (name === 'relay_artifact') {
|
|
35
|
+
if (args.operation === 'list') {
|
|
36
|
+
const saved = await remote.call('artifacts'), offset = integer(args.offset, 0, 0, 10000), limit = integer(args.limit, 10, 1, 25);
|
|
37
|
+
return { artifacts: saved.artifacts.slice(offset, offset + limit).map((a) => ({ ...a, source: compactCode(a.source), paths: a.paths.slice(0, 5), pathCount: a.paths.length })), total: saved.artifacts.length, nextOffset: offset + limit < saved.artifacts.length ? offset + limit : null };
|
|
38
|
+
}
|
|
39
|
+
if (args.operation === 'abort')
|
|
40
|
+
return remote.call('artifacts', { operation: 'abort', id: args.artifact_id, sessionId: session.id });
|
|
41
|
+
if (args.operation === 'export') {
|
|
42
|
+
const result = await exportArtifact(state, args);
|
|
43
|
+
return { ...result, artifact: { ...result.artifact, source: compactCode(result.artifact.source) } };
|
|
44
|
+
}
|
|
45
|
+
const { artifact, bundle } = await receiveArtifact(state, String(args.artifact_id));
|
|
46
|
+
if (args.operation === 'receive')
|
|
47
|
+
return { artifact, files: bundle.files.map(f => ({ path: f.path, before: f.before, after: f.after, bytes: f.data ? Buffer.from(f.data, 'base64').length : 0 })), applied: false };
|
|
48
|
+
if (args.operation === 'read')
|
|
49
|
+
return readArtifact(bundle, String(args.path), integer(args.offset, 0, 0, 2_000_000), integer(args.limit, 2000, 100, 4000));
|
|
50
|
+
throw new Error('Invalid artifact operation');
|
|
51
|
+
}
|
|
52
|
+
if (name === 'relay_integration') {
|
|
53
|
+
if (args.operation === 'prepare')
|
|
54
|
+
return prepare(state, String(args.artifact_id));
|
|
55
|
+
if (args.operation === 'validate')
|
|
56
|
+
return validate(state, String(args.id), args.command, integer(args.timeout_ms, 60000, 1000, 120000));
|
|
57
|
+
throw new Error('Acceptance requires the separate local CLI operation');
|
|
58
|
+
}
|
|
59
|
+
throw new Error('Unknown workflow tool');
|
|
60
|
+
}
|