@kdonev/termscape 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +288 -0
- package/dist/agents/env.d.ts +36 -0
- package/dist/agents/env.d.ts.map +1 -0
- package/dist/agents/env.js +61 -0
- package/dist/agents/env.js.map +1 -0
- package/dist/agents/profiles.d.ts +45 -0
- package/dist/agents/profiles.d.ts.map +1 -0
- package/dist/agents/profiles.js +123 -0
- package/dist/agents/profiles.js.map +1 -0
- package/dist/agents/resolve.d.ts +16 -0
- package/dist/agents/resolve.d.ts.map +1 -0
- package/dist/agents/resolve.js +71 -0
- package/dist/agents/resolve.js.map +1 -0
- package/dist/agents/router.d.ts +29 -0
- package/dist/agents/router.d.ts.map +1 -0
- package/dist/agents/router.js +83 -0
- package/dist/agents/router.js.map +1 -0
- package/dist/agents/tokens.d.ts +21 -0
- package/dist/agents/tokens.d.ts.map +1 -0
- package/dist/agents/tokens.js +39 -0
- package/dist/agents/tokens.js.map +1 -0
- package/dist/agents/wiring.d.ts +30 -0
- package/dist/agents/wiring.d.ts.map +1 -0
- package/dist/agents/wiring.js +110 -0
- package/dist/agents/wiring.js.map +1 -0
- package/dist/browser.d.ts +13 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +34 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +200 -0
- package/dist/cli.js.map +1 -0
- package/dist/db/index.d.ts +14 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +24 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migrations.d.ts +15 -0
- package/dist/db/migrations.d.ts.map +1 -0
- package/dist/db/migrations.js +176 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/store.d.ts +71 -0
- package/dist/db/store.d.ts.map +1 -0
- package/dist/db/store.js +341 -0
- package/dist/db/store.js.map +1 -0
- package/dist/hub.d.ts +188 -0
- package/dist/hub.d.ts.map +1 -0
- package/dist/hub.js +636 -0
- package/dist/hub.js.map +1 -0
- package/dist/hub.tgz +0 -0
- package/dist/mcp/server.d.ts +31 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +66 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/paths.d.ts +27 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +31 -0
- package/dist/paths.js.map +1 -0
- package/dist/protocol/address.d.ts +15 -0
- package/dist/protocol/address.d.ts.map +1 -0
- package/dist/protocol/address.js +39 -0
- package/dist/protocol/address.js.map +1 -0
- package/dist/protocol/domain.d.ts +151 -0
- package/dist/protocol/domain.d.ts.map +1 -0
- package/dist/protocol/domain.js +104 -0
- package/dist/protocol/domain.js.map +1 -0
- package/dist/protocol/index.d.ts +7 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +7 -0
- package/dist/protocol/index.js.map +1 -0
- package/dist/protocol/inject.d.ts +29 -0
- package/dist/protocol/inject.d.ts.map +1 -0
- package/dist/protocol/inject.js +40 -0
- package/dist/protocol/inject.js.map +1 -0
- package/dist/protocol/mcp-tools.d.ts +45 -0
- package/dist/protocol/mcp-tools.d.ts.map +1 -0
- package/dist/protocol/mcp-tools.js +46 -0
- package/dist/protocol/mcp-tools.js.map +1 -0
- package/dist/protocol/peer.d.ts +304 -0
- package/dist/protocol/peer.d.ts.map +1 -0
- package/dist/protocol/peer.js +183 -0
- package/dist/protocol/peer.js.map +1 -0
- package/dist/protocol/ws.d.ts +416 -0
- package/dist/protocol/ws.d.ts.map +1 -0
- package/dist/protocol/ws.js +119 -0
- package/dist/protocol/ws.js.map +1 -0
- package/dist/remote/deployer.d.ts +78 -0
- package/dist/remote/deployer.d.ts.map +1 -0
- package/dist/remote/deployer.js +216 -0
- package/dist/remote/deployer.js.map +1 -0
- package/dist/remote/enroll.d.ts +14 -0
- package/dist/remote/enroll.d.ts.map +1 -0
- package/dist/remote/enroll.js +244 -0
- package/dist/remote/enroll.js.map +1 -0
- package/dist/remote/join-script.d.ts +29 -0
- package/dist/remote/join-script.d.ts.map +1 -0
- package/dist/remote/join-script.js +709 -0
- package/dist/remote/join-script.js.map +1 -0
- package/dist/remote/join.d.ts +19 -0
- package/dist/remote/join.d.ts.map +1 -0
- package/dist/remote/join.js +195 -0
- package/dist/remote/join.js.map +1 -0
- package/dist/remote/lan.d.ts +45 -0
- package/dist/remote/lan.d.ts.map +1 -0
- package/dist/remote/lan.js +105 -0
- package/dist/remote/lan.js.map +1 -0
- package/dist/remote/peer-serve.d.ts +27 -0
- package/dist/remote/peer-serve.d.ts.map +1 -0
- package/dist/remote/peer-serve.js +274 -0
- package/dist/remote/peer-serve.js.map +1 -0
- package/dist/remote/peer.d.ts +62 -0
- package/dist/remote/peer.d.ts.map +1 -0
- package/dist/remote/peer.js +250 -0
- package/dist/remote/peer.js.map +1 -0
- package/dist/remote/registry.d.ts +104 -0
- package/dist/remote/registry.d.ts.map +1 -0
- package/dist/remote/registry.js +359 -0
- package/dist/remote/registry.js.map +1 -0
- package/dist/remote/tarball.d.ts +9 -0
- package/dist/remote/tarball.d.ts.map +1 -0
- package/dist/remote/tarball.js +22 -0
- package/dist/remote/tarball.js.map +1 -0
- package/dist/server.d.ts +54 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +438 -0
- package/dist/server.js.map +1 -0
- package/dist/session/manager.d.ts +104 -0
- package/dist/session/manager.d.ts.map +1 -0
- package/dist/session/manager.js +421 -0
- package/dist/session/manager.js.map +1 -0
- package/dist/session/pty.d.ts +114 -0
- package/dist/session/pty.d.ts.map +1 -0
- package/dist/session/pty.js +300 -0
- package/dist/session/pty.js.map +1 -0
- package/package.json +49 -0
- package/web/assets/index-B2JtCtHa.css +1 -0
- package/web/assets/index-BOqZOoep.js +107 -0
- package/web/index.html +13 -0
package/dist/hub.js
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { basename, resolve } from 'node:path';
|
|
5
|
+
import { encodeInjection, parseAddress, slugify, } from './protocol/index.js';
|
|
6
|
+
import { openDb } from './db/index.js';
|
|
7
|
+
import { Store } from './db/store.js';
|
|
8
|
+
import { ProfileRegistry } from './agents/profiles.js';
|
|
9
|
+
import { TokenRegistry } from './agents/tokens.js';
|
|
10
|
+
import { MessageRouter } from './agents/router.js';
|
|
11
|
+
import { SessionManager } from './session/manager.js';
|
|
12
|
+
import { PeerRegistry } from './remote/registry.js';
|
|
13
|
+
import { deploy } from './remote/deployer.js';
|
|
14
|
+
import { hubTarballPath } from './remote/tarball.js';
|
|
15
|
+
import { paths } from './paths.js';
|
|
16
|
+
export const HUB_VERSION = '0.1.0';
|
|
17
|
+
/**
|
|
18
|
+
* How long changes are pooled before every attached machine is told the
|
|
19
|
+
* canvas has moved on. A status change is a session change, and agents change
|
|
20
|
+
* status several times a turn.
|
|
21
|
+
*/
|
|
22
|
+
const ANNOUNCE_COALESCE_MS = 300;
|
|
23
|
+
/** Colours cycled through when a workspace is created, for canvas grouping. */
|
|
24
|
+
const WORKSPACE_COLORS = [
|
|
25
|
+
'#7c9cf5',
|
|
26
|
+
'#68b06e',
|
|
27
|
+
'#d08b5b',
|
|
28
|
+
'#b06ec2',
|
|
29
|
+
'#4ea3b8',
|
|
30
|
+
'#c2607a',
|
|
31
|
+
];
|
|
32
|
+
/** How many agents one workspace may hold, bounding runaway recursive spawns. */
|
|
33
|
+
export const DEFAULT_SPAWN_CAP = 12;
|
|
34
|
+
/**
|
|
35
|
+
* The hub: owns the database, the sessions, and the routing between agents.
|
|
36
|
+
* The web server and the MCP endpoint are thin layers over this.
|
|
37
|
+
*/
|
|
38
|
+
export class Hub extends EventEmitter {
|
|
39
|
+
db;
|
|
40
|
+
store;
|
|
41
|
+
profiles;
|
|
42
|
+
tokens;
|
|
43
|
+
sessions;
|
|
44
|
+
router;
|
|
45
|
+
peers;
|
|
46
|
+
/**
|
|
47
|
+
* The link back to the canvas this hub was attached to, if it was. Null on a
|
|
48
|
+
* hub that owns a canvas — which is what makes the difference between the
|
|
49
|
+
* two roles a null check rather than a flag.
|
|
50
|
+
*/
|
|
51
|
+
uplink = null;
|
|
52
|
+
announceTimer = null;
|
|
53
|
+
/** Live SSH tunnels, keyed by host id. Not persisted: they die with the hub. */
|
|
54
|
+
tunnels = new Map();
|
|
55
|
+
spawnCap;
|
|
56
|
+
constructor(opts = {}) {
|
|
57
|
+
super();
|
|
58
|
+
this.db = openDb(opts.dbPath ?? paths.db());
|
|
59
|
+
this.store = new Store(this.db);
|
|
60
|
+
this.profiles = ProfileRegistry.load();
|
|
61
|
+
this.tokens = new TokenRegistry();
|
|
62
|
+
this.spawnCap = opts.spawnCap ?? DEFAULT_SPAWN_CAP;
|
|
63
|
+
// Origin is corrected once the server binds and knows its port.
|
|
64
|
+
this.sessions = new SessionManager(this.store, this.profiles, this.tokens, 'http://127.0.0.1:0');
|
|
65
|
+
this.router = new MessageRouter(this.store, this.sessions, this.profiles, (m) => this.emit('message', m));
|
|
66
|
+
this.sessions.on('session', (s) => this.emit('session', s));
|
|
67
|
+
this.sessions.on('removed', (id, address) => this.emit('removed', id, address));
|
|
68
|
+
this.sessions.on('data', (id, chunk) => this.emit('data', id, chunk));
|
|
69
|
+
this.peers = new PeerRegistry(this.store, HUB_VERSION);
|
|
70
|
+
this.peers.on('host', (h) => this.emit('host', h));
|
|
71
|
+
this.peers.on('peerSession', (s) => this.emit('session', s));
|
|
72
|
+
this.peers.on('peerSessionRemoved', (addr) => this.emit('removed', addr, addr));
|
|
73
|
+
this.peers.on('peerSessionsChanged', () => this.emit('peersChanged'));
|
|
74
|
+
this.peers.on('output', (address, data) => {
|
|
75
|
+
// Remote output is addressed by name; the browser keys on session id,
|
|
76
|
+
// and for remote sessions the address *is* the id it was given.
|
|
77
|
+
this.emit('data', address, data);
|
|
78
|
+
});
|
|
79
|
+
// A host asking us to act on an address it cannot resolve itself. We are
|
|
80
|
+
// the only hub that knows where every address on this canvas lives, and
|
|
81
|
+
// the answer travels back the way the question came.
|
|
82
|
+
this.peers.on('relay', (ask, reply) => {
|
|
83
|
+
const done = (p) => p.then((result) => reply(true, result, null), (err) => reply(false, null, err.message));
|
|
84
|
+
if (ask.t === 'deliver')
|
|
85
|
+
return void done(this.deliverFrom(ask.from, ask.to, ask.body));
|
|
86
|
+
return void done(this.readScreenAt(ask.address, ask.lines));
|
|
87
|
+
});
|
|
88
|
+
// Every attached machine's view of the canvas is whatever we last told it,
|
|
89
|
+
// so anything that changes who is running has to be followed by telling
|
|
90
|
+
// them again.
|
|
91
|
+
this.peers.on('directoryStale', () => this.scheduleAnnounce());
|
|
92
|
+
this.on('session', () => this.scheduleAnnounce());
|
|
93
|
+
this.on('removed', () => this.scheduleAnnounce());
|
|
94
|
+
this.on('peersChanged', () => this.scheduleAnnounce());
|
|
95
|
+
// Anything the DB still calls running died with the previous hub.
|
|
96
|
+
this.sessions.reconcileOnBoot();
|
|
97
|
+
this.reconcileHostsOnBoot();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A persisted `connected` is a lie the moment this process starts: the
|
|
101
|
+
* tunnels and sockets died with the last one. Mark every host down, then
|
|
102
|
+
* redial the ones we know how to reach. Enrolled hosts are left alone —
|
|
103
|
+
* they dial us, and arrive on their own.
|
|
104
|
+
*/
|
|
105
|
+
reconcileHostsOnBoot() {
|
|
106
|
+
for (const host of this.store.listHosts()) {
|
|
107
|
+
this.store.upsertHost({ ...host, state: 'disconnected', error: null });
|
|
108
|
+
if (host.kind !== 'ssh')
|
|
109
|
+
continue;
|
|
110
|
+
void this.connectHost(host.id).catch((err) => {
|
|
111
|
+
this.emit('hostLog', host.id, `reconnect failed: ${err.message}`);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Local sessions plus every session reported by a connected peer. */
|
|
116
|
+
allSessions() {
|
|
117
|
+
return [...this.sessions.list(), ...this.peers.sessions()];
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Resolve an address to a local session, one on a peer we hold, one the
|
|
121
|
+
* canvas told us about, or nothing.
|
|
122
|
+
*
|
|
123
|
+
* The three are exclusive by construction: a hub either owns the canvas and
|
|
124
|
+
* has peers, or is attached to one and has an uplink. Never both.
|
|
125
|
+
*/
|
|
126
|
+
locate(address) {
|
|
127
|
+
if (this.sessions.getByAddress(address))
|
|
128
|
+
return 'local';
|
|
129
|
+
if (this.peers.find(address))
|
|
130
|
+
return 'remote';
|
|
131
|
+
return this.uplink?.agents().some((a) => a.address === address) ? 'uplink' : null;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Attach this hub to the canvas reachable over `uplink`.
|
|
135
|
+
*
|
|
136
|
+
* Called by whatever owns the socket, once, at wiring time. A hub that owns
|
|
137
|
+
* its own canvas never calls it.
|
|
138
|
+
*/
|
|
139
|
+
setUplink(uplink) {
|
|
140
|
+
this.uplink = uplink;
|
|
141
|
+
}
|
|
142
|
+
setOrigin(origin) {
|
|
143
|
+
this.sessions.setHubOrigin(origin);
|
|
144
|
+
}
|
|
145
|
+
/* ---------------------------------------------------------- workspaces */
|
|
146
|
+
createWorkspace(name, rootPath, hostId = null) {
|
|
147
|
+
const abs = resolve(rootPath);
|
|
148
|
+
if (!hostId && !existsSync(abs)) {
|
|
149
|
+
throw new Error(`folder does not exist: ${abs}`);
|
|
150
|
+
}
|
|
151
|
+
const existing = this.store.listWorkspaces();
|
|
152
|
+
const wsName = uniqueWorkspaceName(slugify(name || basename(abs)), existing.map((w) => w.name));
|
|
153
|
+
const ws = {
|
|
154
|
+
id: randomUUID(),
|
|
155
|
+
name: wsName,
|
|
156
|
+
kind: hostId ? 'remote' : 'local',
|
|
157
|
+
rootPath: abs,
|
|
158
|
+
hostId,
|
|
159
|
+
color: WORKSPACE_COLORS[existing.length % WORKSPACE_COLORS.length],
|
|
160
|
+
createdAt: Date.now(),
|
|
161
|
+
archivedAt: null,
|
|
162
|
+
};
|
|
163
|
+
this.store.insertWorkspace(ws);
|
|
164
|
+
this.emit('workspace', ws);
|
|
165
|
+
return ws;
|
|
166
|
+
}
|
|
167
|
+
async removeWorkspace(id) {
|
|
168
|
+
const ws = this.store.getWorkspace(id);
|
|
169
|
+
for (const s of this.sessions.list().filter((s) => s.workspaceId === id)) {
|
|
170
|
+
this.sessions.remove(s.id);
|
|
171
|
+
}
|
|
172
|
+
// A workspace on a host owns no PTY here: its agents run over there, and
|
|
173
|
+
// the peer has to be told about each one or they come back on the next
|
|
174
|
+
// resync as windows belonging to a workspace that no longer exists. The
|
|
175
|
+
// peer's own workspace id means nothing in this database, so they are
|
|
176
|
+
// found the only way they can be - by the workspace half of the address.
|
|
177
|
+
if (ws?.hostId) {
|
|
178
|
+
for (const s of this.peers.sessions()) {
|
|
179
|
+
if (parseAddress(s.address)?.workspace === ws.name) {
|
|
180
|
+
await this.peers.removeSession(s.address);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
this.store.removeWorkspace(id);
|
|
185
|
+
this.emit('workspaceRemoved', id);
|
|
186
|
+
}
|
|
187
|
+
/* --------------------------------------------------------------- hosts */
|
|
188
|
+
addHost(input) {
|
|
189
|
+
const host = {
|
|
190
|
+
id: randomUUID(),
|
|
191
|
+
label: input.label || `${input.sshUser}@${input.sshHost}`,
|
|
192
|
+
kind: 'ssh',
|
|
193
|
+
sshHost: input.sshHost,
|
|
194
|
+
sshUser: input.sshUser,
|
|
195
|
+
sshPort: input.sshPort,
|
|
196
|
+
platform: null,
|
|
197
|
+
hubVersion: null,
|
|
198
|
+
state: 'disconnected',
|
|
199
|
+
lastSeenAt: null,
|
|
200
|
+
error: null,
|
|
201
|
+
};
|
|
202
|
+
this.store.upsertHost({ ...host, keyRef: input.privateKeyPath ?? null });
|
|
203
|
+
this.emit('host', host);
|
|
204
|
+
return host;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Drop a host from the canvas, and stop the hub running on it.
|
|
208
|
+
*
|
|
209
|
+
* Stopping it matters more than it looks: that hub is a daemon on someone
|
|
210
|
+
* else's machine, and if it keeps running it holds its own install open —
|
|
211
|
+
* on Windows an in-use .node cannot be deleted, so re-joining that machine
|
|
212
|
+
* fails on a permission error rather than replacing the files.
|
|
213
|
+
*/
|
|
214
|
+
async removeHost(hostId) {
|
|
215
|
+
await this.peers.requestShutdown(hostId).catch(() => false);
|
|
216
|
+
this.peers.remove(hostId);
|
|
217
|
+
void this.tunnels.get(hostId)?.dispose().catch(() => { });
|
|
218
|
+
this.tunnels.delete(hostId);
|
|
219
|
+
this.store.removeHost(hostId);
|
|
220
|
+
this.emit('hostRemoved', hostId);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Deploy the hub to a host if needed, tunnel to it, and join it to the
|
|
224
|
+
* directory. The peer token is minted per connection and never written to
|
|
225
|
+
* the local database.
|
|
226
|
+
*/
|
|
227
|
+
async connectHost(hostId) {
|
|
228
|
+
const host = this.store.listHosts().find((h) => h.id === hostId);
|
|
229
|
+
if (!host)
|
|
230
|
+
throw new Error(`unknown host ${hostId}`);
|
|
231
|
+
if (host.kind !== 'ssh' || !host.sshHost || !host.sshUser) {
|
|
232
|
+
throw new Error(`"${host.label}" enrolled itself and dials this hub; there is nothing here to connect to. ` +
|
|
233
|
+
'Re-run the join command on that machine if it has not come back.');
|
|
234
|
+
}
|
|
235
|
+
const token = randomBytes(24).toString('base64url');
|
|
236
|
+
this.store.upsertHost({ ...host, state: 'connecting', error: null });
|
|
237
|
+
this.emit('host', { ...host, state: 'connecting', error: null });
|
|
238
|
+
try {
|
|
239
|
+
const result = await deploy({
|
|
240
|
+
sshHost: host.sshHost,
|
|
241
|
+
sshUser: host.sshUser,
|
|
242
|
+
sshPort: host.sshPort,
|
|
243
|
+
privateKeyPath: this.store.hostKeyRef(hostId) ?? undefined,
|
|
244
|
+
token,
|
|
245
|
+
expectedVersion: HUB_VERSION,
|
|
246
|
+
packagePath: hubTarballPath() ?? undefined,
|
|
247
|
+
log: (line) => this.emit('hostLog', hostId, line),
|
|
248
|
+
});
|
|
249
|
+
this.tunnels.set(hostId, result);
|
|
250
|
+
this.peers.add(host, result.localUrl, token);
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
const message = err.message;
|
|
254
|
+
this.store.upsertHost({ ...host, state: 'error', error: message });
|
|
255
|
+
this.emit('host', { ...host, state: 'error', error: message });
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/* ------------------------------------------------------------ sessions */
|
|
260
|
+
async startSession(opts) {
|
|
261
|
+
const ws = this.store.getWorkspace(opts.workspaceId);
|
|
262
|
+
if (!ws)
|
|
263
|
+
throw new Error(`unknown workspace ${opts.workspaceId}`);
|
|
264
|
+
// A workspace that belongs to a host runs its agents there. The peer owns
|
|
265
|
+
// the PTY; what comes back is a session we show on our own canvas.
|
|
266
|
+
if (ws.hostId) {
|
|
267
|
+
const spawner = opts.spawnedBy ? this.sessions.get(opts.spawnedBy) : null;
|
|
268
|
+
return this.peers.startSession(ws.hostId, {
|
|
269
|
+
workspaceName: ws.name,
|
|
270
|
+
rootPath: ws.rootPath,
|
|
271
|
+
profile: opts.profile,
|
|
272
|
+
name: opts.name,
|
|
273
|
+
spawnedByAddress: spawner?.address ?? null,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const count = this.sessions.list().filter((s) => s.workspaceId === opts.workspaceId).length;
|
|
277
|
+
if (count >= this.spawnCap) {
|
|
278
|
+
throw new Error(`workspace already has ${count} agents (cap ${this.spawnCap}); stop one first`);
|
|
279
|
+
}
|
|
280
|
+
return this.sessions.start({
|
|
281
|
+
workspaceId: opts.workspaceId,
|
|
282
|
+
profileId: opts.profile,
|
|
283
|
+
name: opts.name,
|
|
284
|
+
cwd: opts.cwd,
|
|
285
|
+
spawnedBy: opts.spawnedBy ?? null,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
async resumeWorkspace(workspaceId) {
|
|
289
|
+
const out = [];
|
|
290
|
+
for (const s of this.sessions.list()) {
|
|
291
|
+
if (s.workspaceId !== workspaceId)
|
|
292
|
+
continue;
|
|
293
|
+
if (s.state === 'running' || s.state === 'starting')
|
|
294
|
+
continue;
|
|
295
|
+
try {
|
|
296
|
+
out.push(await this.sessions.resume(s.id));
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
this.emit('error', new Error(`resume ${s.address}: ${err.message}`));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
moveWindow(sessionId, rect) {
|
|
305
|
+
this.sessions.moveWindow(sessionId, rect);
|
|
306
|
+
}
|
|
307
|
+
setViewport(v) {
|
|
308
|
+
this.store.saveViewport(v);
|
|
309
|
+
}
|
|
310
|
+
/* ------------------------------------------------- AgentApi (MCP tools) */
|
|
311
|
+
requireSession(sessionId) {
|
|
312
|
+
const s = this.sessions.get(sessionId);
|
|
313
|
+
if (!s)
|
|
314
|
+
throw new Error('your session is no longer known to the hub');
|
|
315
|
+
return s;
|
|
316
|
+
}
|
|
317
|
+
async whoami(sessionId) {
|
|
318
|
+
const s = this.requireSession(sessionId);
|
|
319
|
+
const ws = this.store.getWorkspace(s.workspaceId);
|
|
320
|
+
return {
|
|
321
|
+
address: s.address,
|
|
322
|
+
workspace: ws?.name ?? null,
|
|
323
|
+
cwd: s.cwd,
|
|
324
|
+
profile: s.profile,
|
|
325
|
+
spawnedBy: s.spawnedBy ? this.sessions.get(s.spawnedBy)?.address ?? null : null,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* One flat directory across every hub. An agent should not have to know or
|
|
330
|
+
* care whether a peer is on this machine or a remote one; the address is
|
|
331
|
+
* the whole interface.
|
|
332
|
+
*/
|
|
333
|
+
async listAgents(sessionId, workspace) {
|
|
334
|
+
const me = this.requireSession(sessionId);
|
|
335
|
+
const wsById = new Map(this.store.listWorkspaces().map((w) => [w.id, w]));
|
|
336
|
+
const local = this.sessions.list().map((s) => ({
|
|
337
|
+
address: s.address,
|
|
338
|
+
workspace: wsById.get(s.workspaceId)?.name ?? null,
|
|
339
|
+
profile: s.profile,
|
|
340
|
+
state: s.state,
|
|
341
|
+
status: s.status,
|
|
342
|
+
statusText: s.statusText,
|
|
343
|
+
host: 'local',
|
|
344
|
+
isYou: s.id === me.id,
|
|
345
|
+
}));
|
|
346
|
+
const hostLabels = new Map(this.store.listHosts().map((h) => [h.id, h.label]));
|
|
347
|
+
const remote = this.peers.sessions().map((s) => {
|
|
348
|
+
// A remote session's workspace row lives on its own hub, not here, so
|
|
349
|
+
// the workspace name comes from the address and the host from the
|
|
350
|
+
// registry rather than from a local join that would always miss.
|
|
351
|
+
const hostId = this.peers.hostIdFor(s.address);
|
|
352
|
+
return {
|
|
353
|
+
address: s.address,
|
|
354
|
+
workspace: s.address.split('/')[0] ?? null,
|
|
355
|
+
profile: s.profile,
|
|
356
|
+
state: s.state,
|
|
357
|
+
status: s.status,
|
|
358
|
+
statusText: s.statusText,
|
|
359
|
+
host: (hostId ? hostLabels.get(hostId) : undefined) ?? 'remote',
|
|
360
|
+
isYou: false,
|
|
361
|
+
};
|
|
362
|
+
});
|
|
363
|
+
// What the canvas told us about, on a hub attached to one. Its own view
|
|
364
|
+
// is the only complete one, so this is where an agent on an attached
|
|
365
|
+
// machine learns that anything exists beyond this machine.
|
|
366
|
+
const fromCanvas = (this.uplink?.agents() ?? []).map((a) => ({ ...a, isYou: false }));
|
|
367
|
+
return [...local, ...remote, ...fromCanvas].filter((a) => !workspace || a.workspace === workspace);
|
|
368
|
+
}
|
|
369
|
+
async sendMessage(sessionId, to, text) {
|
|
370
|
+
const me = this.requireSession(sessionId);
|
|
371
|
+
if (me.address === to)
|
|
372
|
+
throw new Error('cannot send a message to yourself');
|
|
373
|
+
return this.deliverFrom(me.address, to, text);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Tell every attached machine who is on the canvas.
|
|
377
|
+
*
|
|
378
|
+
* Coalesced, because a status change is a session change and agents change
|
|
379
|
+
* status several times a turn; the directory only needs to be right once the
|
|
380
|
+
* dust settles. Unrefed so a pending announcement never holds the hub up.
|
|
381
|
+
*/
|
|
382
|
+
scheduleAnnounce() {
|
|
383
|
+
// Most hubs have nobody to tell: one that owns a canvas with no machines
|
|
384
|
+
// attached, and every hub that is itself an attached machine.
|
|
385
|
+
if (this.announceTimer || !this.peers.any)
|
|
386
|
+
return;
|
|
387
|
+
this.announceTimer = setTimeout(() => {
|
|
388
|
+
this.announceTimer = null;
|
|
389
|
+
this.peers.announce((hostId) => this.directoryFor(hostId));
|
|
390
|
+
}, ANNOUNCE_COALESCE_MS);
|
|
391
|
+
this.announceTimer.unref?.();
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The canvas as one machine on it should see it: everything, minus that
|
|
395
|
+
* machine's own agents. It has those already, and sending them back would
|
|
396
|
+
* have every agent over there listed twice.
|
|
397
|
+
*/
|
|
398
|
+
directoryFor(hostId) {
|
|
399
|
+
const wsById = new Map(this.store.listWorkspaces().map((w) => [w.id, w]));
|
|
400
|
+
const hostLabels = new Map(this.store.listHosts().map((h) => [h.id, h.label]));
|
|
401
|
+
const here = this.sessions.list().map((s) => ({
|
|
402
|
+
address: s.address,
|
|
403
|
+
workspace: wsById.get(s.workspaceId)?.name ?? null,
|
|
404
|
+
profile: s.profile,
|
|
405
|
+
state: s.state,
|
|
406
|
+
status: s.status,
|
|
407
|
+
statusText: s.statusText,
|
|
408
|
+
// What the machine that owns the canvas is called, from anywhere else.
|
|
409
|
+
host: 'canvas',
|
|
410
|
+
}));
|
|
411
|
+
const elsewhere = this.peers
|
|
412
|
+
.sessions()
|
|
413
|
+
.filter((s) => this.peers.hostIdFor(s.address) !== hostId)
|
|
414
|
+
.map((s) => ({
|
|
415
|
+
address: s.address,
|
|
416
|
+
workspace: s.address.split('/')[0] ?? null,
|
|
417
|
+
profile: s.profile,
|
|
418
|
+
state: s.state,
|
|
419
|
+
status: s.status,
|
|
420
|
+
statusText: s.statusText,
|
|
421
|
+
host: hostLabels.get(this.peers.hostIdFor(s.address) ?? '') ?? 'remote',
|
|
422
|
+
}));
|
|
423
|
+
return [...here, ...elsewhere];
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Deliver on behalf of an address this hub has already established.
|
|
427
|
+
*
|
|
428
|
+
* Three ways out, and which one is taken is the only difference between an
|
|
429
|
+
* agent in the next window, one on a machine this hub holds, and one only
|
|
430
|
+
* the canvas can reach. No caller sees which.
|
|
431
|
+
*/
|
|
432
|
+
async deliverFrom(fromAddr, to, text) {
|
|
433
|
+
const where = this.locate(to);
|
|
434
|
+
// An unknown address goes down the local path deliberately: the router
|
|
435
|
+
// records the failed attempt with its reason, which is what keeps the
|
|
436
|
+
// promise that no message is ever dropped silently.
|
|
437
|
+
if (where === 'local' || where === null) {
|
|
438
|
+
const r = this.router.send(fromAddr, to, text);
|
|
439
|
+
if (!r.delivered)
|
|
440
|
+
throw new Error(r.error ?? 'delivery failed');
|
|
441
|
+
return { delivered: true, to, deliveredAt: r.deliveredAt };
|
|
442
|
+
}
|
|
443
|
+
// Off this machine: whoever owns the PTY performs the injection. We still
|
|
444
|
+
// record the attempt locally so the message log and the canvas edge are
|
|
445
|
+
// complete on this side too.
|
|
446
|
+
const id = randomUUID();
|
|
447
|
+
const sentAt = Date.now();
|
|
448
|
+
try {
|
|
449
|
+
if (where === 'remote')
|
|
450
|
+
await this.peers.deliver(fromAddr, to, text);
|
|
451
|
+
// Only the canvas knows where every address lives, so an attached hub
|
|
452
|
+
// hands the message up rather than trying to route it itself.
|
|
453
|
+
else
|
|
454
|
+
await this.uplink.ask({ t: 'deliver', from: fromAddr, to, body: text });
|
|
455
|
+
const m = {
|
|
456
|
+
id, fromAddr, toAddr: to, body: text,
|
|
457
|
+
sentAt, deliveredAt: Date.now(), deliveryState: 'delivered', error: null,
|
|
458
|
+
};
|
|
459
|
+
this.store.insertMessage(m);
|
|
460
|
+
this.emit('message', m);
|
|
461
|
+
return { delivered: true, to, deliveredAt: m.deliveredAt };
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
const m = {
|
|
465
|
+
id, fromAddr, toAddr: to, body: text,
|
|
466
|
+
sentAt, deliveredAt: null, deliveryState: 'failed',
|
|
467
|
+
error: err.message,
|
|
468
|
+
};
|
|
469
|
+
this.store.insertMessage(m);
|
|
470
|
+
this.emit('message', m);
|
|
471
|
+
throw err;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
async spawnAgent(sessionId, opts) {
|
|
475
|
+
const me = this.requireSession(sessionId);
|
|
476
|
+
const ws = opts.workspace
|
|
477
|
+
? this.store.getWorkspaceByName(opts.workspace)
|
|
478
|
+
: this.store.getWorkspace(me.workspaceId);
|
|
479
|
+
if (!ws)
|
|
480
|
+
throw new Error(`unknown workspace "${opts.workspace}"`);
|
|
481
|
+
const child = await this.startSession({
|
|
482
|
+
workspaceId: ws.id,
|
|
483
|
+
profile: opts.profile ?? me.profile,
|
|
484
|
+
name: opts.name,
|
|
485
|
+
spawnedBy: me.id,
|
|
486
|
+
});
|
|
487
|
+
if (opts.prompt) {
|
|
488
|
+
// The child's CLI is not listening yet; wait for it to come up before
|
|
489
|
+
// typing, otherwise the first instruction is written into the void.
|
|
490
|
+
if (ws.hostId) {
|
|
491
|
+
// The child's PTY is on the peer, so the readiness wait belongs there
|
|
492
|
+
// too; a plain delivery is the only thing this side can do.
|
|
493
|
+
void this.peers
|
|
494
|
+
.deliver(me.address, child.address, opts.prompt)
|
|
495
|
+
.catch(() => {
|
|
496
|
+
// Recorded by the message log on the owning hub; a spawn that
|
|
497
|
+
// succeeded should not fail because the greeting did not land.
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
void this.deliverInitialPrompt(child.id, me.address, opts.prompt);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
address: child.address,
|
|
506
|
+
workspace: ws.name,
|
|
507
|
+
profile: child.profile,
|
|
508
|
+
promptQueued: !!opts.prompt,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Wait for a freshly spawned agent to produce output (its prompt) before
|
|
513
|
+
* injecting the first instruction. Bounded so a CLI that never prints
|
|
514
|
+
* cannot leave this hanging.
|
|
515
|
+
*/
|
|
516
|
+
async deliverInitialPrompt(sessionId, fromAddr, prompt) {
|
|
517
|
+
const pty = this.sessions.pty(sessionId);
|
|
518
|
+
if (!pty)
|
|
519
|
+
return;
|
|
520
|
+
const ready = await new Promise((res) => {
|
|
521
|
+
let settled = false;
|
|
522
|
+
const done = (v) => {
|
|
523
|
+
if (settled)
|
|
524
|
+
return;
|
|
525
|
+
settled = true;
|
|
526
|
+
clearTimeout(timer);
|
|
527
|
+
pty.off('data', onData);
|
|
528
|
+
res(v);
|
|
529
|
+
};
|
|
530
|
+
const onData = () => setTimeout(() => done(true), 1200);
|
|
531
|
+
const timer = setTimeout(() => done(false), 20_000);
|
|
532
|
+
timer.unref?.();
|
|
533
|
+
pty.on('data', onData);
|
|
534
|
+
});
|
|
535
|
+
if (!ready || !pty.running)
|
|
536
|
+
return;
|
|
537
|
+
const session = this.sessions.get(sessionId);
|
|
538
|
+
if (!session)
|
|
539
|
+
return;
|
|
540
|
+
const mode = this.profiles.get(session.profile)?.inject ?? 'bracketed';
|
|
541
|
+
try {
|
|
542
|
+
this.sessions.write(sessionId, encodeInjection(`[from ${fromAddr}] ${prompt}`, mode));
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
// The agent died between the readiness check and the write; the message
|
|
546
|
+
// log already reflects that it never started.
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
async readScreen(sessionId, address, lines) {
|
|
550
|
+
this.requireSession(sessionId);
|
|
551
|
+
return this.readScreenAt(address, lines);
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* read_screen for a caller this hub has already established — either an
|
|
555
|
+
* agent of its own, or a peer that authenticated one and is asking on its
|
|
556
|
+
* behalf. Resolves the address the same three ways a delivery does.
|
|
557
|
+
*/
|
|
558
|
+
async readScreenAt(address, lines) {
|
|
559
|
+
const where = this.locate(address);
|
|
560
|
+
if (where === 'remote')
|
|
561
|
+
return this.peers.readScreen(address, lines);
|
|
562
|
+
if (where === 'uplink') {
|
|
563
|
+
return this.uplink.ask({
|
|
564
|
+
t: 'readScreen',
|
|
565
|
+
address,
|
|
566
|
+
lines,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
const target = this.sessions.getByAddress(address);
|
|
570
|
+
if (!target)
|
|
571
|
+
throw new Error(`no agent at address "${address}"`);
|
|
572
|
+
const pty = this.sessions.pty(target.id);
|
|
573
|
+
if (!pty) {
|
|
574
|
+
const snap = this.store.getSnapshot(target.id);
|
|
575
|
+
if (!snap)
|
|
576
|
+
throw new Error(`agent "${address}" has no screen to read`);
|
|
577
|
+
return { address, running: false, screen: '(not running; last saved screen only)' };
|
|
578
|
+
}
|
|
579
|
+
return { address, running: pty.running, screen: pty.tailLines(lines ?? 40) };
|
|
580
|
+
}
|
|
581
|
+
async setStatus(sessionId, text) {
|
|
582
|
+
const s = this.requireSession(sessionId);
|
|
583
|
+
this.sessions.setStatusText(s.id, text);
|
|
584
|
+
return { ok: true };
|
|
585
|
+
}
|
|
586
|
+
async stopAgent(sessionId, address) {
|
|
587
|
+
const me = this.requireSession(sessionId);
|
|
588
|
+
const target = this.sessions.getByAddress(address);
|
|
589
|
+
if (!target) {
|
|
590
|
+
// Spawn lineage lives on the hub that owns the session, so the
|
|
591
|
+
// spawned-by rule below is enforced over there, by that hub.
|
|
592
|
+
if (this.locate(address) === 'remote') {
|
|
593
|
+
await this.peers.stopSession(address);
|
|
594
|
+
return { stopped: address };
|
|
595
|
+
}
|
|
596
|
+
throw new Error(`no agent at address "${address}"`);
|
|
597
|
+
}
|
|
598
|
+
if (target.id === me.id)
|
|
599
|
+
throw new Error('use your own exit command to stop yourself');
|
|
600
|
+
// An agent may only stop what it created. Otherwise a single confused
|
|
601
|
+
// agent could take down the whole canvas.
|
|
602
|
+
if (target.spawnedBy !== me.id) {
|
|
603
|
+
throw new Error(`"${address}" was not spawned by you; only its spawner may stop it`);
|
|
604
|
+
}
|
|
605
|
+
this.sessions.stop(target.id);
|
|
606
|
+
return { stopped: address };
|
|
607
|
+
}
|
|
608
|
+
/* ------------------------------------------------------------ lifecycle */
|
|
609
|
+
messages() {
|
|
610
|
+
return this.store.listMessages();
|
|
611
|
+
}
|
|
612
|
+
shutdown() {
|
|
613
|
+
for (const t of this.tunnels.values())
|
|
614
|
+
void t.dispose().catch(() => { });
|
|
615
|
+
this.tunnels.clear();
|
|
616
|
+
this.peers.closeAll();
|
|
617
|
+
this.sessions.shutdown();
|
|
618
|
+
try {
|
|
619
|
+
this.db.close();
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
// Already closed.
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
function uniqueWorkspaceName(desired, taken) {
|
|
627
|
+
const set = new Set(taken);
|
|
628
|
+
if (!set.has(desired))
|
|
629
|
+
return desired;
|
|
630
|
+
for (let n = 2; n < 1000; n++) {
|
|
631
|
+
if (!set.has(`${desired}-${n}`))
|
|
632
|
+
return `${desired}-${n}`;
|
|
633
|
+
}
|
|
634
|
+
throw new Error(`could not allocate a workspace name for "${desired}"`);
|
|
635
|
+
}
|
|
636
|
+
//# sourceMappingURL=hub.js.map
|