@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.
Files changed (89) hide show
  1. package/README.md +211 -79
  2. package/dist/agents/detect.d.ts +27 -0
  3. package/dist/agents/detect.d.ts.map +1 -0
  4. package/dist/agents/detect.js +210 -0
  5. package/dist/agents/detect.js.map +1 -0
  6. package/dist/agents/profiles.d.ts +119 -2
  7. package/dist/agents/profiles.d.ts.map +1 -1
  8. package/dist/agents/profiles.js +224 -4
  9. package/dist/agents/profiles.js.map +1 -1
  10. package/dist/agents/templates.d.ts +100 -0
  11. package/dist/agents/templates.d.ts.map +1 -0
  12. package/dist/agents/templates.js +165 -0
  13. package/dist/agents/templates.js.map +1 -0
  14. package/dist/agents/wiring.d.ts +30 -1
  15. package/dist/agents/wiring.d.ts.map +1 -1
  16. package/dist/agents/wiring.js +182 -10
  17. package/dist/agents/wiring.js.map +1 -1
  18. package/dist/cli.js +51 -14
  19. package/dist/cli.js.map +1 -1
  20. package/dist/db/migrations.d.ts.map +1 -1
  21. package/dist/db/migrations.js +73 -0
  22. package/dist/db/migrations.js.map +1 -1
  23. package/dist/db/store.d.ts +53 -1
  24. package/dist/db/store.d.ts.map +1 -1
  25. package/dist/db/store.js +118 -5
  26. package/dist/db/store.js.map +1 -1
  27. package/dist/hub.d.ts +264 -6
  28. package/dist/hub.d.ts.map +1 -1
  29. package/dist/hub.js +549 -33
  30. package/dist/hub.js.map +1 -1
  31. package/dist/hub.tgz +0 -0
  32. package/dist/mcp/server.d.ts +3 -0
  33. package/dist/mcp/server.d.ts.map +1 -1
  34. package/dist/mcp/server.js +27 -2
  35. package/dist/mcp/server.js.map +1 -1
  36. package/dist/protocol/domain.d.ts +88 -0
  37. package/dist/protocol/domain.d.ts.map +1 -1
  38. package/dist/protocol/domain.js +114 -0
  39. package/dist/protocol/domain.js.map +1 -1
  40. package/dist/protocol/mcp-tools.d.ts +43 -0
  41. package/dist/protocol/mcp-tools.d.ts.map +1 -1
  42. package/dist/protocol/mcp-tools.js +65 -0
  43. package/dist/protocol/mcp-tools.js.map +1 -1
  44. package/dist/protocol/peer.d.ts +38 -1
  45. package/dist/protocol/peer.d.ts.map +1 -1
  46. package/dist/protocol/peer.js +41 -2
  47. package/dist/protocol/peer.js.map +1 -1
  48. package/dist/protocol/ws.d.ts +259 -0
  49. package/dist/protocol/ws.d.ts.map +1 -1
  50. package/dist/protocol/ws.js +169 -5
  51. package/dist/protocol/ws.js.map +1 -1
  52. package/dist/remote/deployer.d.ts.map +1 -1
  53. package/dist/remote/deployer.js +15 -2
  54. package/dist/remote/deployer.js.map +1 -1
  55. package/dist/remote/join-script.d.ts.map +1 -1
  56. package/dist/remote/join-script.js +34 -2
  57. package/dist/remote/join-script.js.map +1 -1
  58. package/dist/remote/lan.d.ts +73 -5
  59. package/dist/remote/lan.d.ts.map +1 -1
  60. package/dist/remote/lan.js +119 -10
  61. package/dist/remote/lan.js.map +1 -1
  62. package/dist/remote/peer-serve.d.ts.map +1 -1
  63. package/dist/remote/peer-serve.js +32 -3
  64. package/dist/remote/peer-serve.js.map +1 -1
  65. package/dist/remote/peer.d.ts +7 -0
  66. package/dist/remote/peer.d.ts.map +1 -1
  67. package/dist/remote/peer.js +22 -0
  68. package/dist/remote/peer.js.map +1 -1
  69. package/dist/remote/registry.d.ts +70 -2
  70. package/dist/remote/registry.d.ts.map +1 -1
  71. package/dist/remote/registry.js +134 -10
  72. package/dist/remote/registry.js.map +1 -1
  73. package/dist/server.d.ts +22 -3
  74. package/dist/server.d.ts.map +1 -1
  75. package/dist/server.js +239 -117
  76. package/dist/server.js.map +1 -1
  77. package/dist/session/manager.d.ts +60 -4
  78. package/dist/session/manager.d.ts.map +1 -1
  79. package/dist/session/manager.js +198 -41
  80. package/dist/session/manager.js.map +1 -1
  81. package/dist/session/pty.d.ts +19 -0
  82. package/dist/session/pty.d.ts.map +1 -1
  83. package/dist/session/pty.js +84 -1
  84. package/dist/session/pty.js.map +1 -1
  85. package/package.json +1 -1
  86. package/web/assets/{index-BOqZOoep.js → index-9Z_8xZqk.js} +10 -6
  87. package/web/assets/index-DheUm5dl.css +1 -0
  88. package/web/index.html +2 -2
  89. 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.0';
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: opts.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
- return this.sessions.start({
685
+ const session = await this.sessions.start({
281
686
  workspaceId: opts.workspaceId,
282
- profileId: opts.profile,
283
- name: opts.name,
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.sessions.resume(s.id));
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: opts.profile ?? me.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
- * 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.
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 deliverInitialPrompt(sessionId, fromAddr, prompt) {
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(`[from ${fromAddr}] ${prompt}`, mode));
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