full initial implementation

This commit is contained in:
Shivering Ocelot committed 2026-09-03 23:43:33 -05:00
1 parent 65fc75e509
commit d9d9b7f463
15 files changed
+799 -4

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
spaces/
.remote/
+6 -4
View File
@@ -23,11 +23,13 @@ These are the initial functions for all remote types:
## Implementation
Application uses `deno` with only 2 commands: 'init' to copy the template directory into the local remote, and 'start' to start in the current directory or a given one.
Application is written in javascript using only `node`, no npm required. Readme explans how to run the code.
Main function simply runs the application loading defaults. Also accepts 'init' command that copies the center template remote in the loaded directory, without running.
File structure:
- src/: main.ts (cli, config), runtime.ts (remotes, functions), horde.ts (backend hub connection)
- templates/functions/: create_space.ts and clone_space.ts
- templates/space_functions/: all space functions
- src/: main.js (cli, config), runtime.js (remotes, functions), horde.js (backend hub connection)
- templates/: center and space are template remotes, these are copied as '.remote' in new spaces and initializing the center
- templates/center/functions: create_space.ts and functions/clone_space.ts
- templates/space/functions: all space functions
- spaces/: untracked directory where spaces are stored
- readme.md
- .remote/config.json: holds host, token, name
+6
View File
@@ -0,0 +1,6 @@
{
"name": "remote-center",
"version": "0.1.0",
"private": true,
"type": "module"
}
+17
View File
@@ -0,0 +1,17 @@
# Remote Center
`remote-center` is a slim application that exposes user-written functions to a horde hub as **remotes**. It has no npm dependencies and runs on a plain Node.js install.
A **center** is the main remote: it connects to the hub, exposes its own functions, and manages **spaces** — named sub-remotes, each with a working subdirectory of the center's working path. Spaces share the center's functions and may define their own.
## Requirements
- Node.js >= 22 (uses the built-in global `WebSocket`).
- `git` on PATH, if you want `clone_space` to work with git URLs.
## Quick start
```sh
node src/main.js init # copies the center template into ./.remote
# edit .remote/config.json and set your hub token
node src/main.js # runs the center from the current directory
+163
View File
@@ -0,0 +1,163 @@
// Horde hub WebSocket client for Node.js (>= 22, uses the global WebSocket).
// Modeled on the browser client in reference/backend.js: nonce-tracked actions,
// action listeners, orphan handlers for the auth response, auto-reconnect.
export class Horde {
constructor({ host, token = null }) {
this.host = host;
this.token = token;
this.socket = null;
this.nextNonce = 0;
this.pending = new Map(); // nonce -> { resolve, reject }
this.actionListeners = new Map(); // action -> [callback(packet)]
this.orphanHandlers = []; // callbacks for nonce-less messages (auth replies)
this.connectListeners = [];
this.reconnectTimeout = null;
this.stopped = false;
}
connect() {
if (this.stopped) return;
if (this.socket && (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN)) return;
const socket = new WebSocket(this.host + "/ws");
this.socket = socket;
socket.onopen = async () => {
console.log(`Connected to ${this.host}`);
try {
if (this.token) {
socket.send(JSON.stringify({ token: this.token }));
const response = await this.waitOrphan();
if (response && response.error) {
console.error('Hub authentication failed:', response.error);
socket.close();
return;
}
} else {
console.warn('No auth token configured; the hub may reject this session');
}
for (const cb of this.connectListeners) {
try { cb(); } catch (err) { console.error('Connect listener failed:', err); }
}
} catch (err) {
console.error('Error during hub login:', err);
socket.close();
}
};
socket.onmessage = (event) => {
if (typeof event.data === 'string') this.handleMessage(event.data);
};
socket.onclose = () => {
this.socket = null;
this.clearPending();
if (!this.stopped) {
console.log('Disconnected from hub. Reconnecting in 1s...');
this.reconnectTimeout = setTimeout(() => this.connect(), 1000);
}
};
socket.onerror = (event) => {
console.error('WebSocket error:', event.message || event.error || event);
};
}
close() {
this.stopped = true;
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
if (this.socket) {
this.socket.onclose = null;
this.socket.close();
this.socket = null;
}
this.clearPending();
}
clearPending() {
for (const { reject } of this.pending.values()) {
reject(new Error('Connection lost'));
}
this.pending.clear();
}
onConnect(cb) {
this.connectListeners.push(cb);
}
onAction(action, cb) {
let list = this.actionListeners.get(action);
if (!list) {
list = [];
this.actionListeners.set(action, list);
}
list.push(cb);
}
waitOrphan() {
return new Promise((resolve) => this.orphanHandlers.push(resolve));
}
send(value) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket is not open');
}
this.socket.send(JSON.stringify(value));
}
sendAction(action, body = {}) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error('WebSocket is not open'));
}
const nonce = this.nextNonce++;
const promise = new Promise((resolve, reject) => {
this.pending.set(nonce, { resolve, reject });
});
promise.catch(() => {}); // avoid unhandled rejections when nobody awaits
this.send({ action, nonce, body });
return promise;
}
handleMessage(raw) {
let packet;
try {
packet = JSON.parse(raw);
} catch (err) {
console.error('Failed to parse hub message:', raw, err);
return;
}
if (packet.nonce != null) {
const pending = this.pending.get(packet.nonce);
if (!pending) {
console.warn(`Orphan response for nonce ${packet.nonce}`);
return;
}
this.pending.delete(packet.nonce);
if (packet.error) pending.reject(new Error(packet.error));
else pending.resolve(packet);
return;
}
if (packet.action) {
const listeners = this.actionListeners.get(packet.action);
if (listeners && listeners.length) {
for (const cb of listeners) {
try { cb(packet); } catch (err) {
console.error(`Listener for '${packet.action}' failed:`, err);
}
}
return;
}
}
if (this.orphanHandlers.length) {
this.orphanHandlers.shift()(packet);
return;
}
if (packet.error) console.error('Hub error:', packet.error);
else console.warn('Ignored hub message:', raw);
}
}
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env node
// remote-center: connects local functions to a horde hub as remotes.
//
// Usage:
// node src/main.js run the center from the current directory
// node src/main.js init [dir] copy the center template into <dir>/.remote and exit
import fs from 'node:fs';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { Horde } from './horde.mjs';
import { Remote, Watcher } from './runtime.mjs';
const APP_DIR = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
const TEMPLATES_DIR = path.join(APP_DIR, 'templates');
const DEFAULT_HOST = 'wss://hub.hordelab.com';
const DEFAULT_NAME = 'remote-center';
// --- init command ---
function init(dir = '.') {
const remoteDir = path.resolve(dir, '.remote');
if (fs.existsSync(remoteDir)) {
console.error(`Refusing to overwrite existing ${remoteDir}`);
process.exit(1);
}
fs.cpSync(path.join(TEMPLATES_DIR, 'center'), remoteDir, { recursive: true });
console.log(`Initialized center remote at ${remoteDir}`);
console.log('Set your hub token in .remote/config.json, then run: node src/main.js');
}
// --- run ---
function loadConfig(workPath) {
try {
return JSON.parse(fs.readFileSync(path.join(workPath, '.remote', 'config.json'), 'utf8'));
} catch {
return {};
}
}
function run() {
const workPath = process.cwd();
const config = loadConfig(workPath);
const host = process.env.HORDE_HOST || config.host || DEFAULT_HOST;
const name = config.name || DEFAULT_NAME;
const token = process.env.HORDE_TOKEN || config.token || null;
const spacesDir = path.join(workPath, 'spaces');
console.log(`Running remote-center '${name}' on ${host}, working path ${workPath}`);
if (!token) console.warn('No token set in .remote/config.json');
const horde = new Horde({ host, token });
const center = new Remote({
horde,
name,
functionsDir: path.join(workPath, '.remote', 'functions'),
});
const remotes = new Map([[name, center]]);
function checkSpaceName(spaceName) {
if (typeof spaceName !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(spaceName)) {
throw new Error(`Invalid space name: ${JSON.stringify(spaceName)}`);
}
}
async function createSpace(spaceName) {
checkSpaceName(spaceName);
const dir = path.join(spacesDir, spaceName);
if (fs.existsSync(dir)) throw new Error(`Space already exists: ${spaceName}`);
fs.cpSync(path.join(TEMPLATES_DIR, 'space'), path.join(dir, '.remote'), { recursive: true });
fs.writeFileSync(
path.join(dir, '.remote', 'config.json'),
JSON.stringify({ name: spaceName }, null, 2) + '\n',
);
const remote = addSpaceRemote(spaceName, dir);
if (!(await remote.register())) throw new Error('Failed to register space with the hub');
console.log(`Created space '${spaceName}' at ${dir}`);
return { name: spaceName, dir };
}
async function cloneSpace(source, newName) {
const isUrl = /^https?:\/\//.test(source) || source.endsWith('.git') || /@[^@]+:/.test(source);
if (!newName) {
if (!isUrl) throw new Error('A new space name is required when cloning an existing space');
newName = path.basename(source).replace(/\.git$/, '');
}
checkSpaceName(newName);
const dir = path.join(spacesDir, newName);
if (fs.existsSync(dir)) throw new Error(`Space already exists: ${newName}`);
if (isUrl) {
await new Promise((resolve, reject) => {
execFile('git', ['clone', source, dir], (err, _out, stderr) => {
if (err) reject(new Error(`git clone failed: ${stderr.trim() || err.message}`));
else resolve();
});
});
if (!fs.existsSync(path.join(dir, '.remote'))) {
fs.cpSync(path.join(TEMPLATES_DIR, 'space'), path.join(dir, '.remote'), { recursive: true });
}
} else {
const sourceDir = path.join(spacesDir, source);
if (!fs.existsSync(sourceDir)) throw new Error(`Unknown space: ${source}`);
fs.cpSync(sourceDir, dir, { recursive: true });
}
const remote = addSpaceRemote(newName, dir);
if (!(await remote.register())) throw new Error('Failed to register space with the hub');
console.log(`Cloned space '${newName}' from ${source}`);
return { name: newName, dir };
}
const api = {
createSpace,
cloneSpace,
listSpaces: () => [...remotes.keys()].filter((n) => n !== name),
};
center.ctx = { dir: workPath, remoteName: name, api };
function addSpaceRemote(spaceName, dir) {
const remoteName = `${name}/${spaceName}`;
let remote = remotes.get(remoteName);
if (remote) return remote;
remote = new Remote({
horde,
name: remoteName,
functionsDir: path.join(dir, '.remote', 'functions'),
ctx: { dir, remoteName, spaceName, api },
fallback: center.functions, // spaces share the center's functions
});
remotes.set(remoteName, remote);
return remote;
}
// Registers new spaces and disconnects deleted ones. Safe to call repeatedly.
function reconcileSpaces() {
let entries = [];
try {
entries = fs.readdirSync(spacesDir, { withFileTypes: true });
} catch {
// spaces/ does not exist yet
}
const present = new Set();
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dir = path.join(spacesDir, entry.name);
if (!fs.existsSync(path.join(dir, '.remote'))) continue;
present.add(entry.name);
addSpaceRemote(entry.name, dir).register();
}
for (const [remoteName, remote] of remotes) {
if (remoteName === name) continue;
const spaceName = remoteName.slice(name.length + 1);
if (!present.has(spaceName)) {
remotes.delete(remoteName);
remote.disconnect();
console.log(`Space '${spaceName}' deleted; disconnected`);
}
}
}
horde.onAction('remote-call', (packet) => {
const body = packet.body ?? packet;
const remoteName = body.remotename;
const fnName = body.function;
const args = body.args ?? {};
const requestId = body.requestId ?? packet.requestId;
const respond = (fields) => {
try {
horde.send({
action: 'remote-call-result',
nonce: packet.nonce,
body: { requestId, ...fields },
});
} catch (err) {
console.error('Failed to send remote-call-result:', err.message);
}
};
Promise.resolve()
.then(() => {
const remote = remotes.get(remoteName);
if (!remote) throw new Error(`Unknown remote: ${remoteName}`);
return remote.call(fnName, args);
})
.then((result) => respond({ result: result ?? null }))
.catch((err) => respond({ error: String(err?.message || err) }));
});
horde.onConnect(() => {
// (Re)register every remote after each (re)connection
for (const remote of remotes.values()) remote.register();
});
reconcileSpaces();
new Watcher(spacesDir, () => reconcileSpaces()).start();
horde.connect();
process.on('SIGINT', async () => {
console.log('Shutting down...');
for (const remote of remotes.values()) await remote.disconnect();
horde.close();
process.exit(0);
});
}
// --- entry point ---
const [command, ...rest] = process.argv.slice(2);
if (command === 'init') {
init(rest[0]);
} else if (command) {
console.error(`Unknown command: ${command}`);
console.error('Usage: node src/main.js [init [directory]]');
process.exit(1);
} else {
run();
}
+179
View File
@@ -0,0 +1,179 @@
// Remote runtime: loads function modules from directories, watches them for
// changes, and registers remotes on a horde connection.
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const FUNCTION_EXTENSIONS = ['.mjs', '.js'];
// A directory of function modules. Each module exports `spec`
// ({ name, description, parameters }) and a default async function (args, ctx).
export class Functions {
constructor(dir) {
this.dir = dir;
this.map = new Map(); // spec.name -> { spec, fn, file }
}
async scan() {
const map = new Map();
let entries = [];
try {
entries = fs.readdirSync(this.dir, { withFileTypes: true });
} catch {
this.map = map; // no directory yet: no functions
return map;
}
// Load .mjs first so it wins over a same-named .js module
entries.sort((a, b) =>
FUNCTION_EXTENSIONS.indexOf(path.extname(a.name)) -
FUNCTION_EXTENSIONS.indexOf(path.extname(b.name)));
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!FUNCTION_EXTENSIONS.includes(path.extname(entry.name))) continue;
const file = path.join(this.dir, entry.name);
try {
// Cache-busting query: node caches modules by URL
const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
const { spec } = mod;
if (!spec || typeof spec.name !== 'string') {
throw new Error('module must export a `spec` object with a `name` string');
}
if (typeof mod.default !== 'function') {
throw new Error('module must export a default function');
}
if (map.has(spec.name)) continue;
map.set(spec.name, { spec, fn: mod.default, file });
} catch (err) {
console.error(`Failed to load function module ${file}: ${err.message}`);
}
}
this.map = map;
return map;
}
specs() {
return [...this.map.values()].map((f) => f.spec);
}
has(name) {
return this.map.has(name);
}
async call(name, args, ctx) {
const fn = this.map.get(name);
if (!fn) throw new Error(`Unknown function: ${name}`);
return await fn.fn(args, ctx);
}
}
// Watches a directory using fs.watch, with a polling fallback every 5 seconds.
export class Watcher {
constructor(dir, onChange, intervalMs = 5000) {
this.dir = dir;
this.onChange = onChange;
this.intervalMs = intervalMs;
this.fsWatcher = null;
this.timer = null;
this.signature = null;
this.debounce = null;
}
signatureOf() {
try {
return fs.readdirSync(this.dir, { withFileTypes: true })
.filter((e) => e.isFile())
.map((e) => {
const st = fs.statSync(path.join(this.dir, e.name));
return `${e.name}:${st.mtimeMs}`;
})
.sort()
.join('|');
} catch {
return null;
}
}
check() {
const signature = this.signatureOf();
if (signature !== this.signature) {
const changed = this.signature !== null;
this.signature = signature;
if (changed) this.onChange();
}
}
start() {
this.signature = this.signatureOf();
try {
this.fsWatcher = fs.watch(this.dir, () => {
if (this.debounce) clearTimeout(this.debounce);
this.debounce = setTimeout(() => this.check(), 100);
});
} catch (err) {
console.warn(`fs.watch unavailable for ${this.dir}, relying on polling: ${err.message}`);
}
this.timer = setInterval(() => this.check(), this.intervalMs);
}
stop() {
if (this.fsWatcher) this.fsWatcher.close();
if (this.timer) clearInterval(this.timer);
if (this.debounce) clearTimeout(this.debounce);
}
}
// A named collection of functions in a directory, registered on the hub.
export class Remote {
constructor({ horde, name, functionsDir, ctx = null, fallback = null }) {
this.horde = horde;
this.name = name;
this.ctx = ctx;
this.fallback = fallback; // shared Functions instance (center functions)
this.functions = new Functions(functionsDir);
this.watcher = null;
}
// (Re)scans functions and (re)registers the remote. Returns success.
async register() {
await this.functions.scan();
try {
await this.horde.sendAction('remote-connect', {
remotename: this.name,
functions: this.functions.specs(),
});
console.log(`Registered remote '${this.name}' with ${this.functions.map.size} function(s)`);
} catch (err) {
console.error(`Failed to register remote '${this.name}': ${err.message}`);
return false;
}
if (!this.watcher) {
this.watcher = new Watcher(this.functions.dir, () => this.register());
this.watcher.start();
}
return true;
}
async disconnect() {
if (this.watcher) this.watcher.stop();
try {
await this.horde.sendAction('remote-disconnect', { remotename: this.name });
} catch {
// socket may already be gone
}
}
// Handles an incoming remote-call. Falls back to shared center functions.
async call(functionName, args) {
if (this.functions.has(functionName)) {
return await this.functions.call(functionName, args, this.ctx);
}
if (this.fallback) {
return await this.fallback.call(functionName, args, this.ctx);
}
throw new Error(`Unknown function '${functionName}' on remote '${this.name}'`);
}
}
@@ -0,0 +1,16 @@
export const spec = {
name: 'clone_space',
description: 'Clone a git repository or an existing space into a new space.',
parameters: {
type: 'object',
properties: {
source: { type: 'string', description: 'Name of an existing space, or a git URL to clone.' },
name: { type: 'string', description: 'Optional name for the new space (required when cloning an existing space).' },
},
required: ['source'],
},
};
export default async function (args, ctx) {
return await ctx.api.cloneSpace(args.source, args.name);
}
@@ -0,0 +1,15 @@
export const spec = {
name: 'create_space',
description: 'Create a new space: a named sub-remote with its own working directory.',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'Name of the new space.' },
},
required: ['name'],
},
};
export default async function (args, ctx) {
return await ctx.api.createSpace(args.name);
}
@@ -0,0 +1,38 @@
import fs from 'node:fs';
import path from 'node:path';
export const spec = {
name: 'create_function',
description: 'Create a new function on this remote by writing a function module.',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'Function identifier.' },
description: { type: 'string', description: 'Description shown to the AI.' },
parameters: { type: 'object', description: 'OpenAI tool parameter schema for the function arguments.' },
code: { type: 'string', description: 'JavaScript statements forming the function body; `args` and `ctx` are in scope.' },
},
required: ['name', 'description', 'parameters', 'code'],
},
};
export default async function (args, ctx) {
const name = args.name;
if (typeof name !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) {
throw new Error(`Invalid function name: ${JSON.stringify(name)}`);
}
const functionsDir = path.join(ctx.dir, '.remote', 'functions');
fs.mkdirSync(functionsDir, { recursive: true });
const moduleSource = `export const spec = ${JSON.stringify({
name,
description: args.description,
parameters: args.parameters,
}, null, 2)};
export default async function (args, ctx) {
${args.code}
}
`;
fs.writeFileSync(path.join(functionsDir, `${name}.mjs`), moduleSource);
return { created: name };
}
+29
View File
@@ -0,0 +1,29 @@
import fs from 'node:fs';
import path from 'node:path';
export const spec = {
name: 'delete_file',
description: 'Delete a file (or directory) in the space.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'Path relative to the space directory.' },
},
required: ['path'],
},
};
function resolveIn(base, relPath) {
const resolved = path.resolve(base, relPath);
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
throw new Error('Path escapes the space directory');
}
return resolved;
}
export default async function (args, ctx) {
const file = resolveIn(ctx.dir, args.path);
if (!fs.existsSync(file)) throw new Error(`No such file: ${args.path}`);
fs.rmSync(file, { recursive: true });
return { deleted: args.path };
}
@@ -0,0 +1,26 @@
import fs from 'node:fs';
import path from 'node:path';
export const spec = {
name: 'delete_function',
description: 'Delete a function module from this remote.',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'Name of the function to delete.' },
},
required: ['name'],
},
};
export default async function (args, ctx) {
const functionsDir = path.join(ctx.dir, '.remote', 'functions');
for (const ext of ['.mjs', '.js']) {
const file = path.join(functionsDir, `${args.name}${ext}`);
if (fs.existsSync(file)) {
fs.unlinkSync(file);
return { deleted: args.name };
}
}
throw new Error(`No such function: ${args.name}`);
}
+21
View File
@@ -0,0 +1,21 @@
import fs from 'node:fs';
import path from 'node:path';
export const spec = {
name: 'list_files',
description: 'List every file in the space, as paths relative to the space directory.',
parameters: { type: 'object', properties: {} },
};
export default async function (args, ctx) {
const files = [];
const walk = (dir, prefix) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) walk(path.join(dir, entry.name), rel);
else files.push(rel);
}
};
walk(ctx.dir, '');
return { files };
}
+26
View File
@@ -0,0 +1,26 @@
import fs from 'node:fs';
import path from 'node:path';
export const spec = {
name: 'read_file',
description: 'Read the text content of a file in the space.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'File path relative to the space directory.' },
},
required: ['path'],
},
};
function resolveIn(base, relPath) {
const resolved = path.resolve(base, relPath);
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
throw new Error('Path escapes the space directory');
}
return resolved;
}
export default async function (args, ctx) {
return { content: fs.readFileSync(resolveIn(ctx.dir, args.path), 'utf8') };
}
+30
View File
@@ -0,0 +1,30 @@
import fs from 'node:fs';
import path from 'node:path';
export const spec = {
name: 'write_file',
description: 'Write text content to a file in the space, creating parent directories as needed.',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: 'File path relative to the space directory.' },
content: { type: 'string', description: 'Text content to write.' },
},
required: ['path', 'content'],
},
};
function resolveIn(base, relPath) {
const resolved = path.resolve(base, relPath);
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
throw new Error('Path escapes the space directory');
}
return resolved;
}
export default async function (args, ctx) {
const file = resolveIn(ctx.dir, args.path);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, args.content ?? '');
return { written: args.path };
}