@kdonev/termscape 0.1.0 → 0.1.2
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 +211 -79
- package/dist/agents/detect.d.ts +27 -0
- package/dist/agents/detect.d.ts.map +1 -0
- package/dist/agents/detect.js +210 -0
- package/dist/agents/detect.js.map +1 -0
- package/dist/agents/profiles.d.ts +119 -2
- package/dist/agents/profiles.d.ts.map +1 -1
- package/dist/agents/profiles.js +224 -4
- package/dist/agents/profiles.js.map +1 -1
- package/dist/agents/templates.d.ts +100 -0
- package/dist/agents/templates.d.ts.map +1 -0
- package/dist/agents/templates.js +165 -0
- package/dist/agents/templates.js.map +1 -0
- package/dist/agents/wiring.d.ts +30 -1
- package/dist/agents/wiring.d.ts.map +1 -1
- package/dist/agents/wiring.js +182 -10
- package/dist/agents/wiring.js.map +1 -1
- package/dist/cli.js +51 -14
- package/dist/cli.js.map +1 -1
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/migrations.js +73 -0
- package/dist/db/migrations.js.map +1 -1
- package/dist/db/store.d.ts +53 -1
- package/dist/db/store.d.ts.map +1 -1
- package/dist/db/store.js +118 -5
- package/dist/db/store.js.map +1 -1
- package/dist/hub.d.ts +264 -6
- package/dist/hub.d.ts.map +1 -1
- package/dist/hub.js +549 -33
- package/dist/hub.js.map +1 -1
- package/dist/hub.tgz +0 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +27 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/protocol/domain.d.ts +88 -0
- package/dist/protocol/domain.d.ts.map +1 -1
- package/dist/protocol/domain.js +114 -0
- package/dist/protocol/domain.js.map +1 -1
- package/dist/protocol/mcp-tools.d.ts +43 -0
- package/dist/protocol/mcp-tools.d.ts.map +1 -1
- package/dist/protocol/mcp-tools.js +65 -0
- package/dist/protocol/mcp-tools.js.map +1 -1
- package/dist/protocol/peer.d.ts +38 -1
- package/dist/protocol/peer.d.ts.map +1 -1
- package/dist/protocol/peer.js +41 -2
- package/dist/protocol/peer.js.map +1 -1
- package/dist/protocol/ws.d.ts +259 -0
- package/dist/protocol/ws.d.ts.map +1 -1
- package/dist/protocol/ws.js +169 -5
- package/dist/protocol/ws.js.map +1 -1
- package/dist/remote/deployer.d.ts.map +1 -1
- package/dist/remote/deployer.js +15 -2
- package/dist/remote/deployer.js.map +1 -1
- package/dist/remote/join-script.d.ts.map +1 -1
- package/dist/remote/join-script.js +34 -2
- package/dist/remote/join-script.js.map +1 -1
- package/dist/remote/lan.d.ts +73 -5
- package/dist/remote/lan.d.ts.map +1 -1
- package/dist/remote/lan.js +119 -10
- package/dist/remote/lan.js.map +1 -1
- package/dist/remote/peer-serve.d.ts.map +1 -1
- package/dist/remote/peer-serve.js +32 -3
- package/dist/remote/peer-serve.js.map +1 -1
- package/dist/remote/peer.d.ts +7 -0
- package/dist/remote/peer.d.ts.map +1 -1
- package/dist/remote/peer.js +22 -0
- package/dist/remote/peer.js.map +1 -1
- package/dist/remote/registry.d.ts +70 -2
- package/dist/remote/registry.d.ts.map +1 -1
- package/dist/remote/registry.js +134 -10
- package/dist/remote/registry.js.map +1 -1
- package/dist/server.d.ts +22 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +239 -117
- package/dist/server.js.map +1 -1
- package/dist/session/manager.d.ts +60 -4
- package/dist/session/manager.d.ts.map +1 -1
- package/dist/session/manager.js +198 -41
- package/dist/session/manager.js.map +1 -1
- package/dist/session/pty.d.ts +19 -0
- package/dist/session/pty.d.ts.map +1 -1
- package/dist/session/pty.js +84 -1
- package/dist/session/pty.js.map +1 -1
- package/package.json +1 -1
- package/web/assets/{index-BOqZOoep.js → index-9Z_8xZqk.js} +10 -6
- package/web/assets/index-DheUm5dl.css +1 -0
- package/web/index.html +2 -2
- package/web/assets/index-B2JtCtHa.css +0 -1
package/dist/hub.js
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { basename, resolve } from 'node:path';
|
|
5
|
-
import { encodeInjection, parseAddress, slugify, } from './protocol/index.js';
|
|
5
|
+
import { encodeInjection, MAX_PENDING_PROPOSALS, parseAddress, slugify, } from './protocol/index.js';
|
|
6
6
|
import { openDb } from './db/index.js';
|
|
7
7
|
import { Store } from './db/store.js';
|
|
8
|
-
import { ProfileRegistry } from './agents/profiles.js';
|
|
8
|
+
import { briefMode, ProfileRegistry } from './agents/profiles.js';
|
|
9
|
+
import { AgentDetector } from './agents/detect.js';
|
|
10
|
+
import { TemplateRegistry, validate as validateTemplate } from './agents/templates.js';
|
|
9
11
|
import { TokenRegistry } from './agents/tokens.js';
|
|
10
12
|
import { MessageRouter } from './agents/router.js';
|
|
11
13
|
import { SessionManager } from './session/manager.js';
|
|
14
|
+
import { briefFileFor } from './agents/wiring.js';
|
|
12
15
|
import { PeerRegistry } from './remote/registry.js';
|
|
13
16
|
import { deploy } from './remote/deployer.js';
|
|
14
17
|
import { hubTarballPath } from './remote/tarball.js';
|
|
15
18
|
import { paths } from './paths.js';
|
|
16
|
-
export const HUB_VERSION = '0.1.
|
|
19
|
+
export const HUB_VERSION = '0.1.2';
|
|
17
20
|
/**
|
|
18
21
|
* How long changes are pooled before every attached machine is told the
|
|
19
22
|
* canvas has moved on. A status change is a session change, and agents change
|
|
@@ -39,6 +42,16 @@ export class Hub extends EventEmitter {
|
|
|
39
42
|
db;
|
|
40
43
|
store;
|
|
41
44
|
profiles;
|
|
45
|
+
/**
|
|
46
|
+
* What the picker offers: an agent plus a model, an effort and a first task.
|
|
47
|
+
*
|
|
48
|
+
* Reassigned rather than mutated when a template is saved or removed: the
|
|
49
|
+
* list is a merge of three sources and one write can change a row nobody
|
|
50
|
+
* touched, so the answer is a fresh load rather than a patched one.
|
|
51
|
+
*/
|
|
52
|
+
templates;
|
|
53
|
+
/** What of those profiles this machine actually has installed. */
|
|
54
|
+
agents;
|
|
42
55
|
tokens;
|
|
43
56
|
sessions;
|
|
44
57
|
router;
|
|
@@ -53,11 +66,29 @@ export class Hub extends EventEmitter {
|
|
|
53
66
|
/** Live SSH tunnels, keyed by host id. Not persisted: they die with the hub. */
|
|
54
67
|
tunnels = new Map();
|
|
55
68
|
spawnCap;
|
|
69
|
+
/**
|
|
70
|
+
* Proposals waiting on a human, newest last. In memory on purpose: the agent
|
|
71
|
+
* waiting for the answer dies with the hub, so a proposal that outlived both
|
|
72
|
+
* would be a dialog nobody could be told the outcome of.
|
|
73
|
+
*/
|
|
74
|
+
proposals = new Map();
|
|
75
|
+
/**
|
|
76
|
+
* How many browsers are looking. The hub cannot see its own sockets - the
|
|
77
|
+
* server owns those - so it is told, and it only uses this to answer an
|
|
78
|
+
* agent honestly about whether anyone is there to decide.
|
|
79
|
+
*/
|
|
80
|
+
viewers = 0;
|
|
56
81
|
constructor(opts = {}) {
|
|
57
82
|
super();
|
|
58
83
|
this.db = openDb(opts.dbPath ?? paths.db());
|
|
59
84
|
this.store = new Store(this.db);
|
|
60
85
|
this.profiles = ProfileRegistry.load();
|
|
86
|
+
this.templates = TemplateRegistry.load(this.profiles, this.store);
|
|
87
|
+
this.agents = new AgentDetector(this.profiles);
|
|
88
|
+
// A joined hub is the only thing that knows its own PATH, so it says so
|
|
89
|
+
// rather than waiting to be asked again; peer-serve forwards this to the
|
|
90
|
+
// canvas the same way it forwards session changes.
|
|
91
|
+
this.agents.on('changed', (found) => this.emit('agents', found));
|
|
61
92
|
this.tokens = new TokenRegistry();
|
|
62
93
|
this.spawnCap = opts.spawnCap ?? DEFAULT_SPAWN_CAP;
|
|
63
94
|
// Origin is corrected once the server binds and knows its port.
|
|
@@ -66,8 +97,12 @@ export class Hub extends EventEmitter {
|
|
|
66
97
|
this.sessions.on('session', (s) => this.emit('session', s));
|
|
67
98
|
this.sessions.on('removed', (id, address) => this.emit('removed', id, address));
|
|
68
99
|
this.sessions.on('data', (id, chunk) => this.emit('data', id, chunk));
|
|
69
|
-
this.peers = new PeerRegistry(this.store, HUB_VERSION);
|
|
100
|
+
this.peers = new PeerRegistry(this.store, HUB_VERSION, (address) => this.sessions.getByAddress(address)?.id ?? null);
|
|
101
|
+
// Peer windows share the canvas, so a new local window must not land on
|
|
102
|
+
// one. The manager cannot see the registry; this is the view it needs.
|
|
103
|
+
this.sessions.remoteWindows = () => this.peers.sessions().map((s) => s.window);
|
|
70
104
|
this.peers.on('host', (h) => this.emit('host', h));
|
|
105
|
+
this.peers.on('peerAgents', (hostId, found) => this.emit('hostAgents', hostId, found));
|
|
71
106
|
this.peers.on('peerSession', (s) => this.emit('session', s));
|
|
72
107
|
this.peers.on('peerSessionRemoved', (addr) => this.emit('removed', addr, addr));
|
|
73
108
|
this.peers.on('peerSessionsChanged', () => this.emit('peersChanged'));
|
|
@@ -164,6 +199,59 @@ export class Hub extends EventEmitter {
|
|
|
164
199
|
this.emit('workspace', ws);
|
|
165
200
|
return ws;
|
|
166
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Rename a workspace, or point it at a different folder.
|
|
204
|
+
*
|
|
205
|
+
* The rename is the fussy half. A workspace's name is the first segment of
|
|
206
|
+
* every agent address in it (`workspace/agent`), and those addresses are
|
|
207
|
+
* stored on the session rows, written into each agent's brief, and held by
|
|
208
|
+
* peers on other machines. Rewriting all of that under running agents would
|
|
209
|
+
* change what they had already been told they were called, so a rename is
|
|
210
|
+
* refused while anything is running here. Stopping an agent is a cheap thing
|
|
211
|
+
* to ask and an honest one; silently breaking message delivery is not.
|
|
212
|
+
*
|
|
213
|
+
* Repointing the folder is not fussy at all: a session records its own cwd
|
|
214
|
+
* at launch and resume rebuilds from that, so only agents started afterwards
|
|
215
|
+
* see the new path.
|
|
216
|
+
*/
|
|
217
|
+
updateWorkspace(id, patch) {
|
|
218
|
+
const ws = this.store.getWorkspace(id);
|
|
219
|
+
if (!ws)
|
|
220
|
+
throw new Error(`unknown workspace ${id}`);
|
|
221
|
+
const next = {};
|
|
222
|
+
if (patch.rootPath !== undefined) {
|
|
223
|
+
const abs = resolve(patch.rootPath);
|
|
224
|
+
// Only a local folder can be checked from here; a remote one is the
|
|
225
|
+
// other machine's to know about, exactly as it is on create.
|
|
226
|
+
if (!ws.hostId && !existsSync(abs)) {
|
|
227
|
+
throw new Error(`folder does not exist: ${abs}`);
|
|
228
|
+
}
|
|
229
|
+
if (abs !== ws.rootPath)
|
|
230
|
+
next.rootPath = abs;
|
|
231
|
+
}
|
|
232
|
+
if (patch.name !== undefined) {
|
|
233
|
+
const wanted = slugify(patch.name);
|
|
234
|
+
if (!wanted)
|
|
235
|
+
throw new Error('a workspace needs a name');
|
|
236
|
+
if (wanted !== ws.name) {
|
|
237
|
+
const running = this.sessions.list().filter((s) => s.workspaceId === id);
|
|
238
|
+
if (running.length > 0) {
|
|
239
|
+
throw new Error(`cannot rename while ${running.length} agent${running.length === 1 ? '' : 's'} ` +
|
|
240
|
+
'here still exist: the name is part of their addresses. Remove them first.');
|
|
241
|
+
}
|
|
242
|
+
if (this.store.getWorkspaceByName(wanted)) {
|
|
243
|
+
throw new Error(`a workspace called "${wanted}" already exists`);
|
|
244
|
+
}
|
|
245
|
+
next.name = wanted;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (next.name === undefined && next.rootPath === undefined)
|
|
249
|
+
return ws;
|
|
250
|
+
this.store.updateWorkspace(id, next);
|
|
251
|
+
const updated = this.store.getWorkspace(id);
|
|
252
|
+
this.emit('workspace', updated);
|
|
253
|
+
return updated;
|
|
254
|
+
}
|
|
167
255
|
async removeWorkspace(id) {
|
|
168
256
|
const ws = this.store.getWorkspace(id);
|
|
169
257
|
for (const s of this.sessions.list().filter((s) => s.workspaceId === id)) {
|
|
@@ -184,6 +272,228 @@ export class Hub extends EventEmitter {
|
|
|
184
272
|
this.store.removeWorkspace(id);
|
|
185
273
|
this.emit('workspaceRemoved', id);
|
|
186
274
|
}
|
|
275
|
+
/* ----------------------------------------------------------- templates */
|
|
276
|
+
/**
|
|
277
|
+
* Create a template or replace one, from the panel.
|
|
278
|
+
*
|
|
279
|
+
* Refusals happen here, on the values, before anything is written - which is
|
|
280
|
+
* the whole reason this carries a requestId. The rules are the loader's own,
|
|
281
|
+
* called rather than restated: an agent that declares no way to spell a
|
|
282
|
+
* model or an effort cannot be given one.
|
|
283
|
+
*
|
|
284
|
+
* An id `agents.toml` has claimed is refused outright instead of being
|
|
285
|
+
* stored and then silently losing to the file at load. Storing it would
|
|
286
|
+
* leave a row in the database that never appears in the list, which is a
|
|
287
|
+
* worse thing to explain than a dialog that says no.
|
|
288
|
+
*/
|
|
289
|
+
saveTemplate(input) {
|
|
290
|
+
const candidate = this.candidateTemplate(input);
|
|
291
|
+
this.store.upsertTemplate({
|
|
292
|
+
id: candidate.id,
|
|
293
|
+
description: candidate.description,
|
|
294
|
+
agent: candidate.agent,
|
|
295
|
+
model: candidate.model ?? null,
|
|
296
|
+
effort: candidate.effort ?? null,
|
|
297
|
+
prompt: candidate.prompt ?? null,
|
|
298
|
+
env: candidate.env,
|
|
299
|
+
});
|
|
300
|
+
this.reloadTemplates();
|
|
301
|
+
return this.templates.info().find((t) => t.id === candidate.id);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Everything that can say no about a template, in one place.
|
|
305
|
+
*
|
|
306
|
+
* Shared by the panel and by an agent's proposal so the two cannot drift:
|
|
307
|
+
* an agent should be refused by exactly the rule a human would be, and a
|
|
308
|
+
* proposal that would fail on accept must fail while the agent is still
|
|
309
|
+
* there to be told why.
|
|
310
|
+
*/
|
|
311
|
+
candidateTemplate(input) {
|
|
312
|
+
const id = slugify(input.id);
|
|
313
|
+
if (!id)
|
|
314
|
+
throw new Error('a template needs a name');
|
|
315
|
+
// `[template.reviewer]` parses as one table called `template`, which is
|
|
316
|
+
// why that word cannot be a template id any more than it can be an agent.
|
|
317
|
+
if (id === 'template')
|
|
318
|
+
throw new Error('"template" is not usable as a name');
|
|
319
|
+
const existing = this.templates.get(id);
|
|
320
|
+
if (existing?.source === 'file') {
|
|
321
|
+
throw new Error(`"${id}" is declared in agents.toml; edit it there, or pick another name`);
|
|
322
|
+
}
|
|
323
|
+
const blank = (v) => {
|
|
324
|
+
const t = v?.trim();
|
|
325
|
+
return t ? t : undefined;
|
|
326
|
+
};
|
|
327
|
+
const candidate = {
|
|
328
|
+
id,
|
|
329
|
+
description: blank(input.description) ?? this.profiles.get(input.agent)?.description ?? id,
|
|
330
|
+
agent: input.agent,
|
|
331
|
+
model: blank(input.model),
|
|
332
|
+
effort: blank(input.effort),
|
|
333
|
+
prompt: blank(input.prompt),
|
|
334
|
+
// A name with nothing behind it is dropped rather than set to empty:
|
|
335
|
+
// an exported-but-empty variable reads as set to every program that
|
|
336
|
+
// checks, which is the opposite of leaving it out.
|
|
337
|
+
env: Object.fromEntries(Object.entries(input.env ?? {})
|
|
338
|
+
.map(([k, v]) => [k.trim(), v])
|
|
339
|
+
.filter(([k, v]) => k.length > 0 && v.length > 0)),
|
|
340
|
+
source: 'stored',
|
|
341
|
+
};
|
|
342
|
+
const problem = validateTemplate(candidate, this.profiles);
|
|
343
|
+
if (problem)
|
|
344
|
+
throw new Error(problem);
|
|
345
|
+
return candidate;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Remove a stored template.
|
|
349
|
+
*
|
|
350
|
+
* Nothing running is disturbed, and nothing pretends otherwise: a session
|
|
351
|
+
* records what its template resolved to precisely so resume cannot drift, so
|
|
352
|
+
* the agents this one started keep their model, their effort and their
|
|
353
|
+
* ability to come back. Removing one that shadowed an agent's bare template
|
|
354
|
+
* reveals that bare one again rather than emptying a row.
|
|
355
|
+
*/
|
|
356
|
+
removeTemplate(id) {
|
|
357
|
+
const t = this.templates.get(id);
|
|
358
|
+
if (!t)
|
|
359
|
+
throw new Error(`unknown template "${id}"`);
|
|
360
|
+
if (t.source === 'file') {
|
|
361
|
+
throw new Error(`"${id}" is declared in agents.toml; remove it there`);
|
|
362
|
+
}
|
|
363
|
+
if (t.source === 'derived') {
|
|
364
|
+
throw new Error(`"${id}" is ${t.agent}'s own template and is not stored`);
|
|
365
|
+
}
|
|
366
|
+
this.store.removeTemplate(id);
|
|
367
|
+
this.reloadTemplates();
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Rebuild the merged list and tell everyone.
|
|
371
|
+
*
|
|
372
|
+
* Rebuilt rather than patched because the merge is what changed: one write
|
|
373
|
+
* can alter a row nobody touched - removing a stored template reveals the
|
|
374
|
+
* derived one under it - and only a fresh load knows that.
|
|
375
|
+
*/
|
|
376
|
+
reloadTemplates() {
|
|
377
|
+
this.templates = TemplateRegistry.load(this.profiles, this.store);
|
|
378
|
+
this.emit('templates', this.templates.info());
|
|
379
|
+
}
|
|
380
|
+
/* -------------------------------------------------- template proposals */
|
|
381
|
+
/** Told by the server, which owns the sockets. */
|
|
382
|
+
setViewers(n) {
|
|
383
|
+
this.viewers = n;
|
|
384
|
+
}
|
|
385
|
+
pendingProposals() {
|
|
386
|
+
return [...this.proposals.values()];
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* An agent asking for a template.
|
|
390
|
+
*
|
|
391
|
+
* Everything that can be refused is refused here, before a human is shown
|
|
392
|
+
* anything: nobody should be asked to approve a template that cannot load.
|
|
393
|
+
* What survives is put in front of whoever is watching, and the tool returns
|
|
394
|
+
* at once - a call that blocked until somebody wandered back to the canvas
|
|
395
|
+
* would be a stalled agent, and MCP clients time out.
|
|
396
|
+
*/
|
|
397
|
+
async proposeTemplate(sessionId, input) {
|
|
398
|
+
const me = this.requireSession(sessionId);
|
|
399
|
+
const mine = [...this.proposals.values()].filter((p) => p.fromAddr === me.address);
|
|
400
|
+
if (mine.length >= MAX_PENDING_PROPOSALS) {
|
|
401
|
+
/*
|
|
402
|
+
* Name them. The way this limit is actually reached is a human closing
|
|
403
|
+
* the dialog without deciding, which leaves the proposal waiting and the
|
|
404
|
+
* agent blocked - and an agent that can say *which* templates are stuck
|
|
405
|
+
* lets them go and answer those, rather than reporting a number nobody
|
|
406
|
+
* can act on.
|
|
407
|
+
*/
|
|
408
|
+
throw new Error(`you have ${mine.length} template proposals still waiting on a human: ` +
|
|
409
|
+
mine.map((p) => `"${p.template.id}"`).join(', ') +
|
|
410
|
+
'. Tell them those are waiting in the panel, under templates, and that each ' +
|
|
411
|
+
'can be added or declined there; you cannot propose another until one is ' +
|
|
412
|
+
'answered.');
|
|
413
|
+
}
|
|
414
|
+
// The same checks saveTemplate makes, run now rather than at accept time,
|
|
415
|
+
// so a refusal reaches the agent that can do something about it instead of
|
|
416
|
+
// the human who cannot.
|
|
417
|
+
const candidate = this.candidateTemplate(input);
|
|
418
|
+
const proposal = {
|
|
419
|
+
id: randomUUID(),
|
|
420
|
+
fromAddr: me.address,
|
|
421
|
+
proposedAt: Date.now(),
|
|
422
|
+
template: {
|
|
423
|
+
id: candidate.id,
|
|
424
|
+
agent: candidate.agent,
|
|
425
|
+
description: candidate.description ?? null,
|
|
426
|
+
model: candidate.model ?? null,
|
|
427
|
+
effort: candidate.effort ?? null,
|
|
428
|
+
prompt: candidate.prompt ?? null,
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
this.proposals.set(proposal.id, proposal);
|
|
432
|
+
this.emit('templateProposed', proposal);
|
|
433
|
+
return {
|
|
434
|
+
proposalId: proposal.id,
|
|
435
|
+
status: 'awaiting review',
|
|
436
|
+
anyoneWatching: this.viewers > 0,
|
|
437
|
+
note: (this.viewers > 0
|
|
438
|
+
? 'A human has been shown this and can accept, edit or reject it.'
|
|
439
|
+
: 'Nobody has the canvas open right now, so nobody has seen it yet; ' +
|
|
440
|
+
'it will be shown when someone opens it.') +
|
|
441
|
+
` The template "${candidate.id}" does not exist until it is accepted, so do not` +
|
|
442
|
+
' try to start an agent from it yet. The answer will be typed into your terminal.',
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* A human's answer.
|
|
447
|
+
*
|
|
448
|
+
* What is accepted is what the dialog showed rather than what the agent
|
|
449
|
+
* asked for, because this is a proposal and editing it is the likely path -
|
|
450
|
+
* so the fields come back with the answer.
|
|
451
|
+
*/
|
|
452
|
+
resolveTemplateProposal(proposalId, accept, edits = {}) {
|
|
453
|
+
const proposal = this.proposals.get(proposalId);
|
|
454
|
+
if (!proposal)
|
|
455
|
+
throw new Error('that proposal is no longer waiting');
|
|
456
|
+
if (accept) {
|
|
457
|
+
// Before removing it: a refusal here - a name the file claimed since,
|
|
458
|
+
// say - should leave the dialog open with the proposal still live.
|
|
459
|
+
const saved = this.saveTemplate({ ...proposal.template, ...edits });
|
|
460
|
+
this.proposals.delete(proposalId);
|
|
461
|
+
this.emit('templateProposalResolved', proposalId);
|
|
462
|
+
this.tellAgent(proposal.fromAddr, `Your proposed template was accepted and saved as "${saved.id}"` +
|
|
463
|
+
(saved.id === proposal.template.id ? '' : ` (you asked for "${proposal.template.id}")`) +
|
|
464
|
+
`. It launches ${saved.agent}` +
|
|
465
|
+
[saved.model && `on ${saved.model}`, saved.effort && `at ${saved.effort} effort`]
|
|
466
|
+
.filter(Boolean)
|
|
467
|
+
.join(' ')
|
|
468
|
+
.replace(/^(.)/, ' $1') +
|
|
469
|
+
'. You can start an agent from it now.');
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
this.proposals.delete(proposalId);
|
|
473
|
+
this.emit('templateProposalResolved', proposalId);
|
|
474
|
+
this.tellAgent(proposal.fromAddr, `Your proposed template "${proposal.template.id}" was declined. Do not` +
|
|
475
|
+
' propose it again unless you are asked to; carry on with what you were doing.');
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* A notice from the hub itself, typed into an agent's terminal.
|
|
479
|
+
*
|
|
480
|
+
* Its own prefix, deliberately neither `[from <address>]` nor bare text: it
|
|
481
|
+
* is not a peer talking and it is not the human either, and an agent that
|
|
482
|
+
* mistook it for one of those would either reply into the void or treat it
|
|
483
|
+
* as an instruction.
|
|
484
|
+
*/
|
|
485
|
+
tellAgent(address, text) {
|
|
486
|
+
const target = this.sessions.getByAddress(address);
|
|
487
|
+
if (!target)
|
|
488
|
+
return;
|
|
489
|
+
const mode = this.profiles.get(target.profile)?.inject ?? 'bracketed';
|
|
490
|
+
try {
|
|
491
|
+
this.sessions.write(target.id, encodeInjection(`[termscape] ${text}`, mode));
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
// The agent is gone. Nothing to tell and nothing to fix.
|
|
495
|
+
}
|
|
496
|
+
}
|
|
187
497
|
/* --------------------------------------------------------------- hosts */
|
|
188
498
|
addHost(input) {
|
|
189
499
|
const host = {
|
|
@@ -203,6 +513,44 @@ export class Hub extends EventEmitter {
|
|
|
203
513
|
this.emit('host', host);
|
|
204
514
|
return host;
|
|
205
515
|
}
|
|
516
|
+
/**
|
|
517
|
+
* Correct a host's details after the fact.
|
|
518
|
+
*
|
|
519
|
+
* Nothing here reconnects: an ssh detail that was wrong is usually being
|
|
520
|
+
* fixed while the host sits in `error`, and the reconnect is the next thing
|
|
521
|
+
* the operator does on purpose. Saving and dialling in one step would also
|
|
522
|
+
* mean a typo in the label re-runs a deploy.
|
|
523
|
+
*/
|
|
524
|
+
updateHost(hostId, patch) {
|
|
525
|
+
const host = this.store.getHost(hostId);
|
|
526
|
+
if (!host)
|
|
527
|
+
throw new Error(`unknown host ${hostId}`);
|
|
528
|
+
const ssh = ['sshHost', 'sshUser', 'sshPort', 'privateKeyPath'];
|
|
529
|
+
if (host.kind !== 'ssh' && ssh.some((k) => patch[k] !== undefined)) {
|
|
530
|
+
// An enrolled host dialled us and is reached over the socket it opened.
|
|
531
|
+
// Storing ssh details for it would be storing something nothing reads.
|
|
532
|
+
throw new Error(`${host.label} joined by itself; it has no ssh details to edit`);
|
|
533
|
+
}
|
|
534
|
+
const label = patch.label?.trim();
|
|
535
|
+
const next = {
|
|
536
|
+
...host,
|
|
537
|
+
label: label || host.label,
|
|
538
|
+
sshHost: patch.sshHost?.trim() ?? host.sshHost,
|
|
539
|
+
sshUser: patch.sshUser?.trim() ?? host.sshUser,
|
|
540
|
+
sshPort: patch.sshPort ?? host.sshPort,
|
|
541
|
+
};
|
|
542
|
+
if (next.kind === 'ssh' && (!next.sshHost || !next.sshUser)) {
|
|
543
|
+
throw new Error('an ssh host needs a user and a host');
|
|
544
|
+
}
|
|
545
|
+
this.store.upsertHost({
|
|
546
|
+
...next,
|
|
547
|
+
// upsertHost COALESCEs key_ref, so undefined keeps whatever is stored
|
|
548
|
+
// and an empty string is the only way to say "back to the ssh agent".
|
|
549
|
+
keyRef: patch.privateKeyPath === undefined ? null : patch.privateKeyPath.trim(),
|
|
550
|
+
});
|
|
551
|
+
this.emit('host', next);
|
|
552
|
+
return next;
|
|
553
|
+
}
|
|
206
554
|
/**
|
|
207
555
|
* Drop a host from the canvas, and stop the hub running on it.
|
|
208
556
|
*
|
|
@@ -257,33 +605,152 @@ export class Hub extends EventEmitter {
|
|
|
257
605
|
}
|
|
258
606
|
}
|
|
259
607
|
/* ------------------------------------------------------------ sessions */
|
|
608
|
+
/**
|
|
609
|
+
* Turn what the picker sent into an agent, a model and an effort.
|
|
610
|
+
*
|
|
611
|
+
* `profile` on the wire is a *template* id, and it is still called profile
|
|
612
|
+
* because every agent has a bare template under its own name - so the value
|
|
613
|
+
* a client sent before templates existed still resolves, to the same thing
|
|
614
|
+
* it always did. Anything the caller passed explicitly beats the template,
|
|
615
|
+
* which is what makes the dialog's model dropdown an override rather than a
|
|
616
|
+
* second source of truth.
|
|
617
|
+
*/
|
|
618
|
+
resolveTemplate(opts) {
|
|
619
|
+
const t = this.templates.get(opts.profile);
|
|
620
|
+
if (!t) {
|
|
621
|
+
// Not a template: an agent id straight from spawn_agent or an older
|
|
622
|
+
// client. Nothing has been chosen for it, which is today's behaviour.
|
|
623
|
+
return {
|
|
624
|
+
agent: opts.profile,
|
|
625
|
+
template: null,
|
|
626
|
+
model: opts.model,
|
|
627
|
+
effort: opts.effort,
|
|
628
|
+
prompt: opts.prompt,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
if (t.error)
|
|
632
|
+
throw new Error(`template "${t.id}": ${t.error}`);
|
|
633
|
+
return {
|
|
634
|
+
agent: t.agent,
|
|
635
|
+
template: t.id,
|
|
636
|
+
model: opts.model ?? t.model,
|
|
637
|
+
effort: opts.effort ?? t.effort,
|
|
638
|
+
prompt: opts.prompt ?? t.prompt,
|
|
639
|
+
env: t.env,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
260
642
|
async startSession(opts) {
|
|
261
643
|
const ws = this.store.getWorkspace(opts.workspaceId);
|
|
262
644
|
if (!ws)
|
|
263
645
|
throw new Error(`unknown workspace ${opts.workspaceId}`);
|
|
646
|
+
const picked = this.resolveTemplate(opts);
|
|
264
647
|
// A workspace that belongs to a host runs its agents there. The peer owns
|
|
265
648
|
// the PTY; what comes back is a session we show on our own canvas.
|
|
266
649
|
if (ws.hostId) {
|
|
267
650
|
const spawner = opts.spawnedBy ? this.sessions.get(opts.spawnedBy) : null;
|
|
651
|
+
// Resolved values cross, never the template id: a template is config
|
|
652
|
+
// and the two machines do not share config, so a name that means "opus,
|
|
653
|
+
// high effort" here may mean nothing over there. The id travels only so
|
|
654
|
+
// the window can say which one was picked.
|
|
268
655
|
return this.peers.startSession(ws.hostId, {
|
|
269
656
|
workspaceName: ws.name,
|
|
270
657
|
rootPath: ws.rootPath,
|
|
271
|
-
profile:
|
|
658
|
+
profile: picked.agent,
|
|
659
|
+
template: picked.template,
|
|
660
|
+
model: picked.model,
|
|
661
|
+
effort: picked.effort,
|
|
662
|
+
prompt: picked.prompt,
|
|
663
|
+
// Values, like the model and the effort beside it: the template lives
|
|
664
|
+
// here and the far side has never heard of it.
|
|
665
|
+
env: picked.env,
|
|
272
666
|
name: opts.name,
|
|
273
667
|
spawnedByAddress: spawner?.address ?? null,
|
|
274
668
|
});
|
|
275
669
|
}
|
|
670
|
+
return this.startResolved({ ...opts, ...picked });
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Start an agent whose template has already been resolved to values.
|
|
674
|
+
*
|
|
675
|
+
* Its own entry point because the peer path must not resolve again. A
|
|
676
|
+
* template is config and the two machines do not share config: if this hub
|
|
677
|
+
* happened to declare `[template.claude]` with a model, re-resolving an
|
|
678
|
+
* agent id the canvas sent would apply a choice the canvas never made.
|
|
679
|
+
*/
|
|
680
|
+
async startResolved(opts) {
|
|
276
681
|
const count = this.sessions.list().filter((s) => s.workspaceId === opts.workspaceId).length;
|
|
277
682
|
if (count >= this.spawnCap) {
|
|
278
683
|
throw new Error(`workspace already has ${count} agents (cap ${this.spawnCap}); stop one first`);
|
|
279
684
|
}
|
|
280
|
-
|
|
685
|
+
const session = await this.sessions.start({
|
|
281
686
|
workspaceId: opts.workspaceId,
|
|
282
|
-
profileId: opts.
|
|
283
|
-
|
|
687
|
+
profileId: opts.agent,
|
|
688
|
+
id: opts.id,
|
|
689
|
+
template: opts.template ?? null,
|
|
690
|
+
model: opts.model,
|
|
691
|
+
effort: opts.effort,
|
|
692
|
+
env: opts.env,
|
|
693
|
+
// A window called "reviewer" says more than one called "claude", so the
|
|
694
|
+
// template names the agent when nothing else did.
|
|
695
|
+
name: opts.name ?? opts.template ?? undefined,
|
|
284
696
|
cwd: opts.cwd,
|
|
285
697
|
spawnedBy: opts.spawnedBy ?? null,
|
|
286
698
|
});
|
|
699
|
+
/*
|
|
700
|
+
* Typed in once the CLI is up rather than written into argv, where it
|
|
701
|
+
* would be a different thing entirely - and not immediately, because it
|
|
702
|
+
* would land before the program is reading. Not awaited: the window
|
|
703
|
+
* should appear now, and the instruction arrives when the agent is ready.
|
|
704
|
+
*
|
|
705
|
+
* For an agent whose brief has no flag to ride in on, the brief goes
|
|
706
|
+
* first, in the same injection rather than a separate one. Two would be
|
|
707
|
+
* two turns - the agent would answer the brief before being told what to
|
|
708
|
+
* do, and the second would have to wait out the first.
|
|
709
|
+
*/
|
|
710
|
+
const opening = [this.typedBrief(session), opts.prompt]
|
|
711
|
+
.filter((part) => !!part)
|
|
712
|
+
.join('\n\n');
|
|
713
|
+
if (opening)
|
|
714
|
+
void this.deliverOpeningInstruction(session.id, opening);
|
|
715
|
+
return session;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* The brief for an agent that cannot be handed one, or null.
|
|
719
|
+
*
|
|
720
|
+
* Read back off disk rather than re-rendered: the wiring wrote exactly this
|
|
721
|
+
* text a moment ago, and rendering it twice is two chances to render it
|
|
722
|
+
* differently. A profile with `brief: 'flag'` returns null here because its
|
|
723
|
+
* argv already carries the path.
|
|
724
|
+
*/
|
|
725
|
+
typedBrief(session) {
|
|
726
|
+
const profile = this.profiles.get(session.profile);
|
|
727
|
+
if (!profile || briefMode(profile) !== 'typed')
|
|
728
|
+
return null;
|
|
729
|
+
try {
|
|
730
|
+
return readFileSync(briefFileFor(session.id), 'utf8');
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
// The brief is context, not the task. An agent that came up without it
|
|
734
|
+
// is worse off, not broken, and refusing to start it would be worse.
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Bring a stopped session back, and re-brief it if its brief was typed.
|
|
740
|
+
*
|
|
741
|
+
* The re-brief is why this exists rather than callers reaching for
|
|
742
|
+
* `sessions.resume` directly. A CLI that had to be told where it is by
|
|
743
|
+
* having text typed at it remembers none of that across a restart, and
|
|
744
|
+
* neither of the two in that position is resumable in the first place - so
|
|
745
|
+
* what comes back is a fresh conversation that has never been told it has an
|
|
746
|
+
* address or any peers.
|
|
747
|
+
*/
|
|
748
|
+
async resumeSession(sessionId) {
|
|
749
|
+
const session = await this.sessions.resume(sessionId);
|
|
750
|
+
const brief = this.typedBrief(session);
|
|
751
|
+
if (brief)
|
|
752
|
+
void this.deliverOpeningInstruction(session.id, brief);
|
|
753
|
+
return session;
|
|
287
754
|
}
|
|
288
755
|
async resumeWorkspace(workspaceId) {
|
|
289
756
|
const out = [];
|
|
@@ -293,7 +760,7 @@ export class Hub extends EventEmitter {
|
|
|
293
760
|
if (s.state === 'running' || s.state === 'starting')
|
|
294
761
|
continue;
|
|
295
762
|
try {
|
|
296
|
-
out.push(await this.
|
|
763
|
+
out.push(await this.resumeSession(s.id));
|
|
297
764
|
}
|
|
298
765
|
catch (err) {
|
|
299
766
|
this.emit('error', new Error(`resume ${s.address}: ${err.message}`));
|
|
@@ -330,6 +797,39 @@ export class Hub extends EventEmitter {
|
|
|
330
797
|
* care whether a peer is on this machine or a remote one; the address is
|
|
331
798
|
* the whole interface.
|
|
332
799
|
*/
|
|
800
|
+
/**
|
|
801
|
+
* The templates an agent can start something from.
|
|
802
|
+
*
|
|
803
|
+
* Exposed because spawn_agent takes a template id and there was no way to
|
|
804
|
+
* find out what the ids were: an agent could only pass its own profile, or
|
|
805
|
+
* guess. The environment a template sets is deliberately *not* returned —
|
|
806
|
+
* knowing that a template exists is what an agent needs to use it, and the
|
|
807
|
+
* values in there are credentials more often than not.
|
|
808
|
+
*/
|
|
809
|
+
async listTemplates(sessionId, id) {
|
|
810
|
+
this.requireSession(sessionId);
|
|
811
|
+
const shown = this.templates.info().map((t) => ({
|
|
812
|
+
id: t.id,
|
|
813
|
+
description: t.description,
|
|
814
|
+
agent: t.agent,
|
|
815
|
+
model: t.model,
|
|
816
|
+
effort: t.effort,
|
|
817
|
+
prompt: t.prompt,
|
|
818
|
+
/** Names only, so a template's own config is described but not leaked. */
|
|
819
|
+
envNames: Object.keys(t.env).sort(),
|
|
820
|
+
// A template that cannot be used stays in the list saying why, exactly
|
|
821
|
+
// as it does in the picker: silently omitting it invites an agent to
|
|
822
|
+
// conclude the name was never configured and propose it again.
|
|
823
|
+
error: t.error,
|
|
824
|
+
source: t.source,
|
|
825
|
+
}));
|
|
826
|
+
if (id === undefined)
|
|
827
|
+
return { templates: shown };
|
|
828
|
+
const one = shown.find((t) => t.id === id);
|
|
829
|
+
if (!one)
|
|
830
|
+
throw new Error(`no template "${id}"`);
|
|
831
|
+
return one;
|
|
832
|
+
}
|
|
333
833
|
async listAgents(sessionId, workspace) {
|
|
334
834
|
const me = this.requireSession(sessionId);
|
|
335
835
|
const wsById = new Map(this.store.listWorkspaces().map((w) => [w.id, w]));
|
|
@@ -478,29 +978,32 @@ export class Hub extends EventEmitter {
|
|
|
478
978
|
: this.store.getWorkspace(me.workspaceId);
|
|
479
979
|
if (!ws)
|
|
480
980
|
throw new Error(`unknown workspace "${opts.workspace}"`);
|
|
981
|
+
// The child starts the way its parent did: from the same template, so the
|
|
982
|
+
// model and effort the template chose are not silently dropped in favour
|
|
983
|
+
// of the bare agent's defaults. A template that has been deleted or has
|
|
984
|
+
// since failed to load falls back to the agent itself — a spawn must not
|
|
985
|
+
// die because config changed under a running parent; a profile the
|
|
986
|
+
// spawning agent named explicitly always wins.
|
|
987
|
+
const inherited = me.template ? this.templates.get(me.template) : null;
|
|
988
|
+
const inherit = opts.profile ?? (inherited && !inherited.error ? me.template : me.profile);
|
|
989
|
+
/*
|
|
990
|
+
* One injection, delivered once, by the hub that owns the child's PTY: the
|
|
991
|
+
* template's opening instruction first, then what the spawning agent asked
|
|
992
|
+
* for, attributed. Delivering them separately would race two injections on
|
|
993
|
+
* the same ready-signal, landing back to back in undefined order, so the
|
|
994
|
+
* opening merges here and rides to the child through startSession — which
|
|
995
|
+
* also means the remote path needs no delivery of its own.
|
|
996
|
+
*/
|
|
997
|
+
const templatePrompt = inherit ? (this.templates.get(inherit)?.prompt ?? null) : null;
|
|
998
|
+
const instruction = opts.prompt ? `[from ${me.address}] ${opts.prompt}` : null;
|
|
999
|
+
const opening = [templatePrompt, instruction].filter(Boolean).join('\n\n') || undefined;
|
|
481
1000
|
const child = await this.startSession({
|
|
482
1001
|
workspaceId: ws.id,
|
|
483
|
-
profile:
|
|
1002
|
+
profile: inherit,
|
|
484
1003
|
name: opts.name,
|
|
485
1004
|
spawnedBy: me.id,
|
|
1005
|
+
prompt: opening,
|
|
486
1006
|
});
|
|
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
1007
|
return {
|
|
505
1008
|
address: child.address,
|
|
506
1009
|
workspace: ws.name,
|
|
@@ -509,11 +1012,24 @@ export class Hub extends EventEmitter {
|
|
|
509
1012
|
};
|
|
510
1013
|
}
|
|
511
1014
|
/**
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
1015
|
+
* A template's opening instruction, typed in plainly.
|
|
1016
|
+
*
|
|
1017
|
+
* Deliberately without the `[from <address>]` prefix: that is right for a
|
|
1018
|
+
* message from a peer and wrong for an instruction from the human sitting in
|
|
1019
|
+
* front of the canvas. The agent's brief tells it that a `[from ...]` line is
|
|
1020
|
+
* a colleague rather than the human, so wearing that prefix here would be a
|
|
1021
|
+
* lie about who is asking.
|
|
1022
|
+
*/
|
|
1023
|
+
async deliverOpeningInstruction(sessionId, prompt) {
|
|
1024
|
+
await this.typeWhenReady(sessionId, prompt);
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Wait for the CLI to be up, then type. Written straight away the text lands
|
|
1028
|
+
* before the program is reading it; the wait is for output to arrive and
|
|
1029
|
+
* then pause, which is as close to "it has drawn its prompt" as this gets
|
|
1030
|
+
* without knowing the CLI.
|
|
515
1031
|
*/
|
|
516
|
-
async
|
|
1032
|
+
async typeWhenReady(sessionId, text) {
|
|
517
1033
|
const pty = this.sessions.pty(sessionId);
|
|
518
1034
|
if (!pty)
|
|
519
1035
|
return;
|
|
@@ -539,7 +1055,7 @@ export class Hub extends EventEmitter {
|
|
|
539
1055
|
return;
|
|
540
1056
|
const mode = this.profiles.get(session.profile)?.inject ?? 'bracketed';
|
|
541
1057
|
try {
|
|
542
|
-
this.sessions.write(sessionId, encodeInjection(
|
|
1058
|
+
this.sessions.write(sessionId, encodeInjection(text, mode));
|
|
543
1059
|
}
|
|
544
1060
|
catch {
|
|
545
1061
|
// The agent died between the readiness check and the write; the message
|