@kdonev/termscape 0.1.5 → 0.1.7

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 (86) hide show
  1. package/README.md +17 -7
  2. package/dist/agents/profiles.d.ts +23 -0
  3. package/dist/agents/profiles.d.ts.map +1 -1
  4. package/dist/agents/profiles.js +48 -5
  5. package/dist/agents/profiles.js.map +1 -1
  6. package/dist/agents/templates.d.ts +18 -0
  7. package/dist/agents/templates.d.ts.map +1 -1
  8. package/dist/agents/templates.js +19 -0
  9. package/dist/agents/templates.js.map +1 -1
  10. package/dist/agents/visibility.d.ts +22 -0
  11. package/dist/agents/visibility.d.ts.map +1 -0
  12. package/dist/agents/visibility.js +20 -0
  13. package/dist/agents/visibility.js.map +1 -0
  14. package/dist/agents/wiring.d.ts +1 -1
  15. package/dist/agents/wiring.d.ts.map +1 -1
  16. package/dist/agents/wiring.js +12 -3
  17. package/dist/agents/wiring.js.map +1 -1
  18. package/dist/cli-args.d.ts +4 -0
  19. package/dist/cli-args.d.ts.map +1 -1
  20. package/dist/cli-args.js +2 -0
  21. package/dist/cli-args.js.map +1 -1
  22. package/dist/cli.js +63 -23
  23. package/dist/cli.js.map +1 -1
  24. package/dist/db/migrations.d.ts.map +1 -1
  25. package/dist/db/migrations.js +29 -0
  26. package/dist/db/migrations.js.map +1 -1
  27. package/dist/db/store.d.ts +8 -0
  28. package/dist/db/store.d.ts.map +1 -1
  29. package/dist/db/store.js +25 -4
  30. package/dist/db/store.js.map +1 -1
  31. package/dist/hub.d.ts +129 -26
  32. package/dist/hub.d.ts.map +1 -1
  33. package/dist/hub.js +331 -51
  34. package/dist/hub.js.map +1 -1
  35. package/dist/hub.tgz +0 -0
  36. package/dist/mcp/server.d.ts.map +1 -1
  37. package/dist/mcp/server.js +6 -1
  38. package/dist/mcp/server.js.map +1 -1
  39. package/dist/protocol/domain.d.ts +1 -0
  40. package/dist/protocol/domain.d.ts.map +1 -1
  41. package/dist/protocol/domain.js +12 -0
  42. package/dist/protocol/domain.js.map +1 -1
  43. package/dist/protocol/mcp-tools.js +1 -1
  44. package/dist/protocol/mcp-tools.js.map +1 -1
  45. package/dist/protocol/peer.d.ts +58 -1
  46. package/dist/protocol/peer.d.ts.map +1 -1
  47. package/dist/protocol/peer.js +72 -1
  48. package/dist/protocol/peer.js.map +1 -1
  49. package/dist/protocol/ws.d.ts +6 -0
  50. package/dist/protocol/ws.d.ts.map +1 -1
  51. package/dist/protocol/ws.js +2 -0
  52. package/dist/protocol/ws.js.map +1 -1
  53. package/dist/remote/peer-serve.d.ts.map +1 -1
  54. package/dist/remote/peer-serve.js +16 -3
  55. package/dist/remote/peer-serve.js.map +1 -1
  56. package/dist/remote/registry.d.ts +8 -0
  57. package/dist/remote/registry.d.ts.map +1 -1
  58. package/dist/remote/registry.js +15 -0
  59. package/dist/remote/registry.js.map +1 -1
  60. package/dist/server.d.ts.map +1 -1
  61. package/dist/server.js +13 -2
  62. package/dist/server.js.map +1 -1
  63. package/dist/session/manager.d.ts +22 -2
  64. package/dist/session/manager.d.ts.map +1 -1
  65. package/dist/session/manager.js +52 -9
  66. package/dist/session/manager.js.map +1 -1
  67. package/dist/session/pty.d.ts +30 -1
  68. package/dist/session/pty.d.ts.map +1 -1
  69. package/dist/session/pty.js +77 -1
  70. package/dist/session/pty.js.map +1 -1
  71. package/dist/window/app-window.d.ts +2 -0
  72. package/dist/window/app-window.d.ts.map +1 -0
  73. package/dist/window/app-window.js +137 -0
  74. package/dist/window/app-window.js.map +1 -0
  75. package/dist/window/open-window.d.ts +38 -0
  76. package/dist/window/open-window.d.ts.map +1 -0
  77. package/dist/window/open-window.js +76 -0
  78. package/dist/window/open-window.js.map +1 -0
  79. package/package.json +5 -2
  80. package/web/assets/index-gfbbNQK4.css +1 -0
  81. package/web/assets/index-i7fXLKF0.js +111 -0
  82. package/web/icon.png +0 -0
  83. package/web/icon.svg +22 -0
  84. package/web/index.html +4 -2
  85. package/web/assets/index-Z03Ta4g3.js +0 -111
  86. package/web/assets/index-u_uCiaPD.css +0 -1
package/dist/hub.js CHANGED
@@ -6,26 +6,33 @@ import { openDb } from './db/index.js';
6
6
  import { Store } from './db/store.js';
7
7
  import { briefMode, ProfileRegistry } from './agents/profiles.js';
8
8
  import { AgentDetector } from './agents/detect.js';
9
- import { TemplateRegistry, validate as validateTemplate } from './agents/templates.js';
9
+ import { TemplateRegistry, overlayCanvasTemplates, validate as validateTemplate, } from './agents/templates.js';
10
10
  import { TokenRegistry } from './agents/tokens.js';
11
11
  import { Shares } from './agents/shares.js';
12
12
  import { MessageRouter } from './agents/router.js';
13
13
  import { PollWatch } from './agents/polling.js';
14
14
  import { SessionManager } from './session/manager.js';
15
15
  import { briefFileFor } from './agents/wiring.js';
16
+ import { canSee } from './agents/visibility.js';
16
17
  import { PeerRegistry } from './remote/registry.js';
17
18
  import { SPAWN_RELAY_TIMEOUT_MS } from './remote/peer-serve.js';
18
19
  import { deploy } from './remote/deployer.js';
19
20
  import { hubTarballPath } from './remote/tarball.js';
20
21
  import { paths } from './paths.js';
21
22
  import { checkFolder, folderName } from './folders.js';
22
- export const HUB_VERSION = '0.1.5';
23
+ export const HUB_VERSION = '0.1.7';
23
24
  /**
24
25
  * How long changes are pooled before every attached machine is told the
25
26
  * canvas has moved on. A status change is a session change, and agents change
26
27
  * status several times a turn.
27
28
  */
28
29
  const ANNOUNCE_COALESCE_MS = 300;
30
+ /**
31
+ * How long a new agent's output has to stop before its opening instruction is
32
+ * typed in, and the longest that is waited for at all. See typeWhenReady.
33
+ */
34
+ const OPENING_QUIET_MS = 1500;
35
+ const OPENING_READY_CAP_MS = 20_000;
29
36
  /** Colours cycled through when a workspace is created, for canvas grouping. */
30
37
  const WORKSPACE_COLORS = [
31
38
  '#7c9cf5',
@@ -119,6 +126,11 @@ export class Hub extends EventEmitter {
119
126
  // Peer windows share the canvas, so a new local window must not land on
120
127
  // one. The manager cannot see the registry; this is the view it needs.
121
128
  this.sessions.remoteWindows = () => this.peers.sessions().map((s) => s.window);
129
+ // A brief lists who its agent can see, and that is decided across every
130
+ // machine by lineage - which only the hub has the view for.
131
+ this.sessions.visiblePeers = (s) => this.everyAgent()
132
+ .filter((o) => o.address !== s.address && canSee(s, o))
133
+ .map((o) => o.address);
122
134
  this.peers.on('host', (h) => this.emit('host', h));
123
135
  this.peers.on('peerAgents', (hostId, found) => this.emit('hostAgents', hostId, found));
124
136
  this.peers.on('peerSession', (s) => this.emit('session', s));
@@ -135,15 +147,40 @@ export class Hub extends EventEmitter {
135
147
  // question came.
136
148
  this.peers.on('relay', (ask, reply, fromHostId) => {
137
149
  const done = (p) => p.then((result) => reply(true, result, null), (err) => reply(false, null, err.message));
150
+ /*
151
+ * The canvas is authoritative about who may see whom: the asking hub
152
+ * checked already, but from a directory that may be a moment stale,
153
+ * and a host that did not check at all must not be a way round it.
154
+ */
138
155
  switch (ask.t) {
139
156
  case 'deliver':
140
- return void done(this.deliverFrom(ask.from, ask.to, ask.body));
157
+ return void done((async () => {
158
+ this.requireVisible(this.lineageOf(ask.from), ask.to);
159
+ return this.deliverFrom(ask.from, ask.to, ask.body);
160
+ })());
141
161
  case 'readScreen':
142
- return void done(this.readScreenAt(ask.address, ask.lines));
162
+ return void done((async () => {
163
+ this.requireVisible(this.lineageOf(ask.from), ask.address);
164
+ return this.readScreenAt(ask.from, ask.address, ask.lines);
165
+ })());
143
166
  case 'listHosts':
144
167
  return void done(Promise.resolve(this.hostsView(fromHostId)));
168
+ case 'listTemplates':
169
+ return void done(Promise.resolve(this.templateViews()));
170
+ case 'pickTemplate':
171
+ return void done(Promise.resolve(this.pickTemplateFor(ask)));
145
172
  case 'spawn':
146
173
  return void done(this.spawnFrom(ask, fromHostId));
174
+ case 'proposeTemplate': {
175
+ // `from` is only trusted as far as the host that sent it: an
176
+ // agent on one machine must not be able to propose as, and have
177
+ // the answer typed into, an agent on another.
178
+ if (this.peers.hostIdFor(ask.from) !== fromHostId) {
179
+ return void done(Promise.reject(new Error(`no agent at address "${ask.from}"`)));
180
+ }
181
+ const { t: _t, from, ...input } = ask;
182
+ return void done(this.proposeTemplateFrom(from, input));
183
+ }
147
184
  }
148
185
  });
149
186
  // Every attached machine's view of the canvas is whatever we last told it,
@@ -191,6 +228,39 @@ export class Hub extends EventEmitter {
191
228
  return 'remote';
192
229
  return this.uplink?.agents().some((a) => a.address === address) ? 'uplink' : null;
193
230
  }
231
+ /**
232
+ * Every agent this hub knows of, on any machine, with its lineage: its own,
233
+ * those on machines it holds, and those the canvas told it about. The three
234
+ * are exclusive for the reason `locate` gives.
235
+ */
236
+ everyAgent() {
237
+ return [
238
+ ...this.sessions.list(),
239
+ ...this.peers.sessions(),
240
+ ...(this.uplink?.agents() ?? []),
241
+ ].map((a) => ({ address: a.address, parentAddress: a.parentAddress ?? null }));
242
+ }
243
+ /** One address's lineage, from the same view, or null for one nobody here knows. */
244
+ lineageOf(address) {
245
+ const local = this.sessions.getByAddress(address);
246
+ if (local)
247
+ return { address, parentAddress: local.parentAddress ?? null };
248
+ const found = this.peers.find(address)?.session ??
249
+ this.uplink?.agents().find((a) => a.address === address);
250
+ return found ? { address, parentAddress: found.parentAddress ?? null } : null;
251
+ }
252
+ /**
253
+ * Refuse to act on an address the viewer may not see, in exactly the words
254
+ * used for one that does not exist: a hidden agent must not be discoverable
255
+ * by probing for the difference. An address nobody knows is let through, so
256
+ * the delivery path can record the failed attempt as it always has.
257
+ */
258
+ requireVisible(viewer, address) {
259
+ const target = this.lineageOf(address);
260
+ if (target && (!viewer || !canSee(viewer, target))) {
261
+ throw new Error(`no agent at address "${address}"`);
262
+ }
263
+ }
194
264
  /**
195
265
  * Attach this hub to the canvas reachable over `uplink`.
196
266
  *
@@ -436,7 +506,21 @@ export class Hub extends EventEmitter {
436
506
  */
437
507
  async proposeTemplate(sessionId, input) {
438
508
  const me = this.requireSession(sessionId);
439
- const mine = [...this.proposals.values()].filter((p) => p.fromAddr === me.address);
509
+ // Templates are central (issue 22): the canvas is where they live and
510
+ // where the human who answers is, so an attached hub hands the proposal
511
+ // up. No local fallback when that fails - a proposal kept here would wait
512
+ // for a dialog nobody is ever shown, which is the bug this fixes.
513
+ if (this.uplink?.attached) {
514
+ return this.uplink.ask({ t: 'proposeTemplate', from: me.address, ...input });
515
+ }
516
+ return this.proposeTemplateFrom(me.address, input);
517
+ }
518
+ /**
519
+ * A proposal from an agent already established, here or on an attached
520
+ * machine - the canvas's half of proposeTemplate.
521
+ */
522
+ async proposeTemplateFrom(fromAddr, input) {
523
+ const mine = [...this.proposals.values()].filter((p) => p.fromAddr === fromAddr);
440
524
  if (mine.length >= MAX_PENDING_PROPOSALS) {
441
525
  /*
442
526
  * Name them. The way this limit is actually reached is a human closing
@@ -457,7 +541,7 @@ export class Hub extends EventEmitter {
457
541
  const candidate = this.candidateTemplate(input);
458
542
  const proposal = {
459
543
  id: randomUUID(),
460
- fromAddr: me.address,
544
+ fromAddr,
461
545
  proposedAt: Date.now(),
462
546
  template: {
463
547
  id: candidate.id,
@@ -523,6 +607,21 @@ export class Hub extends EventEmitter {
523
607
  * as an instruction.
524
608
  */
525
609
  tellAgent(address, text) {
610
+ if (!this.sessions.getByAddress(address)) {
611
+ // An agent on an attached machine proposed it, and only that machine
612
+ // owns its terminal. Best effort, like the local case below.
613
+ if (this.peers.hostIdFor(address))
614
+ void this.peers.notify(address, text).catch(() => { });
615
+ return;
616
+ }
617
+ this.notifyAgent(address, text);
618
+ }
619
+ /**
620
+ * tellAgent's local half, and what an attached hub runs when the canvas
621
+ * sends it a notice for one of its agents. The one place the `[termscape]`
622
+ * prefix is applied, so a notice reads the same from any machine.
623
+ */
624
+ notifyAgent(address, text) {
526
625
  const target = this.sessions.getByAddress(address);
527
626
  if (!target)
528
627
  return;
@@ -680,14 +779,25 @@ export class Hub extends EventEmitter {
680
779
  };
681
780
  }
682
781
  async startSession(opts) {
782
+ const parentAddress = opts.spawnedBy
783
+ ? (this.sessions.get(opts.spawnedBy)?.address ?? null)
784
+ : null;
785
+ return this.startPicked({ ...opts, parentAddress }, this.resolveTemplate(opts));
786
+ }
787
+ /**
788
+ * Start a session from a template already resolved, locally or on a host.
789
+ *
790
+ * Split from startSession for spawn_agent on an attached hub, whose
791
+ * template may have been resolved by the canvas: resolving its id again
792
+ * here would look it up in a registry that has never heard of it.
793
+ */
794
+ async startPicked(opts, picked) {
683
795
  const ws = this.store.getWorkspace(opts.workspaceId);
684
796
  if (!ws)
685
797
  throw new Error(`unknown workspace ${opts.workspaceId}`);
686
- const picked = this.resolveTemplate(opts);
687
798
  // A workspace that belongs to a host runs its agents there. The peer owns
688
799
  // the PTY; what comes back is a session we show on our own canvas.
689
800
  if (ws.hostId) {
690
- const spawner = opts.spawnedBy ? this.sessions.get(opts.spawnedBy) : null;
691
801
  // Resolved values cross, never the template id: a template is config
692
802
  // and the two machines do not share config, so a name that means "opus,
693
803
  // high effort" here may mean nothing over there. The id travels only so
@@ -700,11 +810,12 @@ export class Hub extends EventEmitter {
700
810
  model: picked.model,
701
811
  effort: picked.effort,
702
812
  prompt: picked.prompt,
813
+ restorePrompt: picked.restorePrompt,
703
814
  // Values, like the model and the effort beside it: the template lives
704
815
  // here and the far side has never heard of it.
705
816
  env: picked.env,
706
817
  name: opts.name,
707
- spawnedByAddress: spawner?.address ?? null,
818
+ spawnedByAddress: opts.parentAddress ?? null,
708
819
  });
709
820
  }
710
821
  return this.startResolved({ ...opts, ...picked });
@@ -735,6 +846,7 @@ export class Hub extends EventEmitter {
735
846
  name: opts.name ?? opts.template ?? undefined,
736
847
  cwd: opts.cwd,
737
848
  spawnedBy: opts.spawnedBy ?? null,
849
+ parentAddress: opts.parentAddress ?? null,
738
850
  });
739
851
  /*
740
852
  * Typed in once the CLI is up rather than written into argv, where it
@@ -752,6 +864,7 @@ export class Hub extends EventEmitter {
752
864
  .join('\n\n');
753
865
  if (opening)
754
866
  void this.deliverOpeningInstruction(session.id, opening);
867
+ this.store.setOpeningPrompt(session.id, opts.restorePrompt === undefined ? (opts.prompt ?? null) : opts.restorePrompt);
755
868
  return session;
756
869
  }
757
870
  /**
@@ -792,6 +905,29 @@ export class Hub extends EventEmitter {
792
905
  void this.deliverOpeningInstruction(session.id, brief);
793
906
  return session;
794
907
  }
908
+ /**
909
+ * Start a running session over: a fresh conversation, an empty window, and
910
+ * its opening typed in again - the window's `clear` button.
911
+ *
912
+ * A restart rather than typing `/clear` at the CLI. Only Claude Code could
913
+ * say it had cleared, and only through a hook; a restart is the same for
914
+ * every agent, and the hub knows exactly when the new one is up.
915
+ *
916
+ * What comes back is the opening the session recorded when it started (see
917
+ * startResolved): a template's instruction, or the human's from the dialog -
918
+ * never a spawning peer's task, which would start that work over. A brief
919
+ * that rides in on a flag needs nothing here; a typed one goes first, as it
920
+ * did at start.
921
+ */
922
+ async clearSession(sessionId) {
923
+ const session = await this.sessions.restartFresh(sessionId);
924
+ const opening = [this.typedBrief(session), this.store.getOpeningPrompt(sessionId)]
925
+ .filter((part) => !!part)
926
+ .join('\n\n');
927
+ if (opening)
928
+ void this.deliverOpeningInstruction(session.id, opening);
929
+ return session;
930
+ }
795
931
  async resumeWorkspace(workspaceId) {
796
932
  const out = [];
797
933
  for (const s of this.sessions.list()) {
@@ -900,7 +1036,9 @@ export class Hub extends EventEmitter {
900
1036
  workspace: ws?.name ?? null,
901
1037
  cwd: s.cwd,
902
1038
  profile: s.profile,
903
- spawnedBy: s.spawnedBy ? this.sessions.get(s.spawnedBy)?.address ?? null : null,
1039
+ // By address rather than through the local id, which a parent on
1040
+ // another machine never had.
1041
+ spawnedBy: s.parentAddress ?? null,
904
1042
  };
905
1043
  }
906
1044
  /**
@@ -919,7 +1057,17 @@ export class Hub extends EventEmitter {
919
1057
  */
920
1058
  async listTemplates(sessionId, id) {
921
1059
  this.requireSession(sessionId);
922
- const shown = this.templates.info().map((t) => ({
1060
+ const shown = this.uplink?.attached ? await this.templateViewsWithCanvas() : this.templateViews();
1061
+ if (id === undefined)
1062
+ return { templates: shown };
1063
+ const one = shown.find((t) => t.id === id);
1064
+ if (!one)
1065
+ throw new Error(`no template "${id}"`);
1066
+ return one;
1067
+ }
1068
+ /** This hub's own templates, in the shape list_templates answers with. */
1069
+ templateViews() {
1070
+ return this.templates.info().map((t) => ({
923
1071
  id: t.id,
924
1072
  description: t.description,
925
1073
  agent: t.agent,
@@ -934,12 +1082,48 @@ export class Hub extends EventEmitter {
934
1082
  error: t.error,
935
1083
  source: t.source,
936
1084
  }));
937
- if (id === undefined)
938
- return { templates: shown };
939
- const one = shown.find((t) => t.id === id);
940
- if (!one)
941
- throw new Error(`no template "${id}"`);
942
- return one;
1085
+ }
1086
+ /**
1087
+ * An attached hub's list: the templates the canvas made, and the bare agents
1088
+ * this machine has (see overlayCanvasTemplates).
1089
+ *
1090
+ * Templates are central (issue 22), so this hub's own stored or declared
1091
+ * ones are left out: nobody on the canvas can see or edit them, and
1092
+ * spawn_agent refuses them here for the same reason. A link that cannot
1093
+ * answer leaves this machine's bare agents, which are still true.
1094
+ */
1095
+ async templateViewsWithCanvas() {
1096
+ const local = this.templateViews();
1097
+ try {
1098
+ const canvas = await this.uplink.ask({ t: 'listTemplates' });
1099
+ return overlayCanvasTemplates(canvas, local);
1100
+ }
1101
+ catch {
1102
+ return overlayCanvasTemplates([], local);
1103
+ }
1104
+ }
1105
+ /**
1106
+ * The canvas answering an attached hub's spawn_agent: what `profile` means
1107
+ * here, with the caller's own template standing in when it named none.
1108
+ *
1109
+ * `picked: null` for anything the canvas did not make - a bare agent id, or
1110
+ * a template derived from one - which the asking hub then resolves against
1111
+ * its own config, since that is what it will actually launch. A template
1112
+ * that exists but cannot be used is an answer rather than a failed relay, so
1113
+ * the asking hub can tell it apart from a link that did not answer.
1114
+ */
1115
+ pickTemplateFor(ask) {
1116
+ const inherited = ask.parentTemplate ? this.templates.get(ask.parentTemplate) : null;
1117
+ const id = ask.profile ?? (inherited && !inherited.error ? ask.parentTemplate : ask.parentProfile);
1118
+ const t = this.templates.get(id);
1119
+ if (!t || t.source === 'derived')
1120
+ return { picked: null };
1121
+ try {
1122
+ return { picked: this.resolveTemplate({ profile: id }) };
1123
+ }
1124
+ catch (err) {
1125
+ return { error: err.message };
1126
+ }
943
1127
  }
944
1128
  /**
945
1129
  * Every machine on the canvas, for `spawn_agent`'s `host` to name and
@@ -1174,6 +1358,11 @@ export class Hub extends EventEmitter {
1174
1358
  return this.store.getHost(ws.hostId)?.label ?? 'remote';
1175
1359
  return this.thisMachineLabel();
1176
1360
  }
1361
+ /**
1362
+ * Every agent the caller can see, on every machine: lineage decides who
1363
+ * that is (see agents/visibility.ts), not the machine or the workspace.
1364
+ * `workspace` only narrows what is already visible.
1365
+ */
1177
1366
  async listAgents(sessionId, workspace) {
1178
1367
  const me = this.requireSession(sessionId);
1179
1368
  const wsById = new Map(this.store.listWorkspaces().map((w) => [w.id, w]));
@@ -1186,6 +1375,7 @@ export class Hub extends EventEmitter {
1186
1375
  status: s.status,
1187
1376
  statusText: s.statusText,
1188
1377
  host: myLabel,
1378
+ parentAddress: s.parentAddress ?? null,
1189
1379
  isYou: s.id === me.id,
1190
1380
  }));
1191
1381
  const hostLabels = new Map(this.store.listHosts().map((h) => [h.id, h.label]));
@@ -1202,6 +1392,7 @@ export class Hub extends EventEmitter {
1202
1392
  status: s.status,
1203
1393
  statusText: s.statusText,
1204
1394
  host: (hostId ? hostLabels.get(hostId) : undefined) ?? 'remote',
1395
+ parentAddress: s.parentAddress ?? null,
1205
1396
  isYou: false,
1206
1397
  };
1207
1398
  });
@@ -1209,12 +1400,13 @@ export class Hub extends EventEmitter {
1209
1400
  // is the only complete one, so this is where an agent on an attached
1210
1401
  // machine learns that anything exists beyond this machine.
1211
1402
  const fromCanvas = (this.uplink?.agents() ?? []).map((a) => ({ ...a, isYou: false }));
1212
- return [...local, ...remote, ...fromCanvas].filter((a) => !workspace || a.workspace === workspace);
1403
+ return [...local, ...remote, ...fromCanvas].filter((a) => (a.isYou || canSee(me, a)) && (!workspace || a.workspace === workspace));
1213
1404
  }
1214
1405
  async sendMessage(sessionId, to, text) {
1215
1406
  const me = this.requireSession(sessionId);
1216
1407
  if (me.address === to)
1217
1408
  throw new Error('cannot send a message to yourself');
1409
+ this.requireVisible(me, to);
1218
1410
  const result = await this.deliverFrom(me.address, to, text);
1219
1411
  // Only a successful send counts as a question asked - deliverFrom throws
1220
1412
  // on failure, so a message that never arrived does not excuse polling.
@@ -1256,6 +1448,7 @@ export class Hub extends EventEmitter {
1256
1448
  statusText: s.statusText,
1257
1449
  // What the machine that owns the canvas is called, from anywhere else.
1258
1450
  host: 'canvas',
1451
+ parentAddress: s.parentAddress ?? null,
1259
1452
  }));
1260
1453
  const elsewhere = this.peers
1261
1454
  .sessions()
@@ -1268,6 +1461,7 @@ export class Hub extends EventEmitter {
1268
1461
  status: s.status,
1269
1462
  statusText: s.statusText,
1270
1463
  host: hostLabels.get(this.peers.hostIdFor(s.address) ?? '') ?? 'remote',
1464
+ parentAddress: s.parentAddress ?? null,
1271
1465
  }));
1272
1466
  return [...here, ...elsewhere];
1273
1467
  }
@@ -1320,16 +1514,58 @@ export class Hub extends EventEmitter {
1320
1514
  throw err;
1321
1515
  }
1322
1516
  }
1517
+ /**
1518
+ * What a spawn_agent `profile` resolves to for this caller.
1519
+ *
1520
+ * The child starts the way its parent did: from the same template, so the
1521
+ * model and effort the template chose are not silently dropped in favour
1522
+ * of the bare agent's defaults. A template that has been deleted or has
1523
+ * since failed to load falls back to the agent itself — a spawn must not
1524
+ * die because config changed under a running parent; a profile the
1525
+ * spawning agent named explicitly always wins.
1526
+ *
1527
+ * On an attached hub the canvas is asked first, because that is where
1528
+ * templates are made (see templateViewsWithCanvas). Only a template the
1529
+ * canvas actually holds comes back from there. Anything else, and any link
1530
+ * that does not answer, is resolved against this hub's own config - but
1531
+ * only as a bare agent or its derived template: templates are central, and
1532
+ * one stored or declared only on this machine is not one.
1533
+ */
1534
+ async pickSpawnTemplate(me, profile) {
1535
+ if (this.uplink?.attached) {
1536
+ let answer = null;
1537
+ try {
1538
+ answer = await this.uplink.ask({
1539
+ t: 'pickTemplate',
1540
+ profile,
1541
+ parentTemplate: me.template,
1542
+ parentProfile: me.profile,
1543
+ });
1544
+ }
1545
+ catch {
1546
+ // The link, not the template: resolve here as if there were none.
1547
+ }
1548
+ if (answer && 'error' in answer)
1549
+ throw new Error(answer.error);
1550
+ if (answer?.picked)
1551
+ return answer.picked;
1552
+ const own = (id) => {
1553
+ const t = this.templates.get(id);
1554
+ return !t || t.source === 'derived';
1555
+ };
1556
+ if (profile !== undefined && !own(profile))
1557
+ throw new Error(`no template "${profile}"`);
1558
+ // The caller's own template came from the canvas, which has just said
1559
+ // it no longer holds it; its agent is what is left.
1560
+ const inherit = profile ?? (me.template && own(me.template) ? me.template : me.profile);
1561
+ return this.resolveTemplate({ profile: inherit });
1562
+ }
1563
+ const inherited = me.template ? this.templates.get(me.template) : null;
1564
+ const inherit = profile ?? (inherited && !inherited.error ? me.template : me.profile);
1565
+ return this.resolveTemplate({ profile: inherit });
1566
+ }
1323
1567
  async spawnAgent(sessionId, opts) {
1324
1568
  const me = this.requireSession(sessionId);
1325
- // The child starts the way its parent did: from the same template, so the
1326
- // model and effort the template chose are not silently dropped in favour
1327
- // of the bare agent's defaults. A template that has been deleted or has
1328
- // since failed to load falls back to the agent itself — a spawn must not
1329
- // die because config changed under a running parent; a profile the
1330
- // spawning agent named explicitly always wins.
1331
- const inherited = me.template ? this.templates.get(me.template) : null;
1332
- const inherit = opts.profile ?? (inherited && !inherited.error ? me.template : me.profile);
1333
1569
  /*
1334
1570
  * Resolved, and the opening instruction built, ahead of the workspace: a
1335
1571
  * `host` needs the resolved agent id before it can even ask whether that
@@ -1342,7 +1578,7 @@ export class Hub extends EventEmitter {
1342
1578
  * order, so the opening merges here and rides to the child however it
1343
1579
  * gets there.
1344
1580
  */
1345
- const picked = this.resolveTemplate({ profile: inherit });
1581
+ const picked = await this.pickSpawnTemplate(me, opts.profile);
1346
1582
  const instruction = opts.prompt ? `[from ${me.address}] ${opts.prompt}` : null;
1347
1583
  const opening = [picked.prompt, instruction].filter(Boolean).join('\n\n') || undefined;
1348
1584
  // A `host` naming something beyond this hub's own machine has to go up
@@ -1362,6 +1598,7 @@ export class Hub extends EventEmitter {
1362
1598
  effort: picked.effort,
1363
1599
  env: picked.env,
1364
1600
  opening,
1601
+ restore: picked.prompt ?? null,
1365
1602
  promptGiven: !!opts.prompt,
1366
1603
  }, { timeoutMs: SPAWN_RELAY_TIMEOUT_MS });
1367
1604
  }
@@ -1388,13 +1625,7 @@ export class Hub extends EventEmitter {
1388
1625
  throw new Error(`unknown workspace "${opts.workspace}"`);
1389
1626
  ws = found;
1390
1627
  }
1391
- const child = await this.startSession({
1392
- workspaceId: ws.id,
1393
- profile: inherit,
1394
- name: opts.name,
1395
- spawnedBy: me.id,
1396
- prompt: opening,
1397
- });
1628
+ const child = await this.startPicked({ workspaceId: ws.id, name: opts.name, spawnedBy: me.id, parentAddress: me.address }, { ...picked, prompt: opening, restorePrompt: picked.prompt ?? null });
1398
1629
  return {
1399
1630
  address: child.address,
1400
1631
  workspace: ws.name,
@@ -1431,13 +1662,12 @@ export class Hub extends EventEmitter {
1431
1662
  model: ask.model,
1432
1663
  effort: ask.effort,
1433
1664
  prompt: ask.opening,
1665
+ restorePrompt: ask.restore ?? null,
1434
1666
  env: ask.env,
1435
1667
  name: ask.name,
1436
- // See the known gap noted on spawn_agent's `host`: the spawner
1437
- // lives on a hub this canvas has no local id for, and the
1438
- // canvas-local branch below cannot take one either — the session
1439
- // table's foreign key would reject it.
1440
- spawnedByAddress: null,
1668
+ // By address, the one id every hub agrees on: the spawner lives on
1669
+ // a hub this canvas has no local id for.
1670
+ spawnedByAddress: ask.from,
1441
1671
  })
1442
1672
  : await this.startResolved({
1443
1673
  workspaceId: ws.id,
@@ -1446,9 +1676,14 @@ export class Hub extends EventEmitter {
1446
1676
  model: ask.model,
1447
1677
  effort: ask.effort,
1448
1678
  prompt: ask.opening,
1679
+ restorePrompt: ask.restore ?? null,
1449
1680
  env: ask.env,
1450
1681
  name: ask.name,
1682
+ // No local id to give - the session table's foreign key would
1683
+ // reject one from another machine - but the address still says
1684
+ // whose child this is, which is what visibility is decided from.
1451
1685
  spawnedBy: null,
1686
+ parentAddress: ask.from,
1452
1687
  });
1453
1688
  return {
1454
1689
  address: child.address,
@@ -1473,27 +1708,64 @@ export class Hub extends EventEmitter {
1473
1708
  /**
1474
1709
  * Wait for the CLI to be up, then type. Written straight away the text lands
1475
1710
  * before the program is reading it; the wait is for output to arrive and
1476
- * then pause, which is as close to "it has drawn its prompt" as this gets
1711
+ * then stop, which is as close to "it has drawn its prompt" as this gets
1477
1712
  * without knowing the CLI.
1713
+ *
1714
+ * Quiet measured from the last output, not a fixed delay from the first.
1715
+ * The first output is not the CLI at all on Windows - ConPTY draws its own
1716
+ * the moment the process exists - so a delay counted from there was a guess
1717
+ * at how long the CLI takes to load, and a slower machine lost it: the start
1718
+ * of the instruction went into a program not yet reading, the rest landed
1719
+ * in its composer, and the Enter went nowhere. A CLI that never stops
1720
+ * drawing still gets its instruction, once the cap is up.
1721
+ *
1722
+ * Nor into a question. A CLI that opens by asking the human something -
1723
+ * Claude Code in a folder it has not been trusted in - is quiet too, and
1724
+ * the instruction typed at it was spent on the question: gone, or partly
1725
+ * taken as answers to it. Such a screen is waited out until it changes.
1478
1726
  */
1479
1727
  async typeWhenReady(sessionId, text) {
1480
1728
  const pty = this.sessions.pty(sessionId);
1481
1729
  if (!pty)
1482
1730
  return;
1731
+ const profile = this.profiles.get(this.sessions.get(sessionId)?.profile ?? '');
1732
+ const hint = profile?.askingHint ? new RegExp(profile.askingHint, 'i') : null;
1733
+ // A question on screen outlasts any cap: the human answers it when they
1734
+ // get to the window, and the instruction is still wanted after that.
1735
+ const asking = () => !!hint && hint.test(pty.tailLines(pty.rows));
1483
1736
  const ready = await new Promise((res) => {
1484
1737
  let settled = false;
1738
+ let seen = false;
1739
+ let quiet = null;
1485
1740
  const done = (v) => {
1486
1741
  if (settled)
1487
1742
  return;
1488
1743
  settled = true;
1489
- clearTimeout(timer);
1744
+ clearTimeout(cap);
1745
+ if (quiet)
1746
+ clearTimeout(quiet);
1490
1747
  pty.off('data', onData);
1748
+ pty.off('exit', onExit);
1491
1749
  res(v);
1492
1750
  };
1493
- const onData = () => setTimeout(() => done(true), 1200);
1494
- const timer = setTimeout(() => done(false), 20_000);
1495
- timer.unref?.();
1751
+ const onData = () => {
1752
+ seen = true;
1753
+ if (quiet)
1754
+ clearTimeout(quiet);
1755
+ quiet = setTimeout(() => {
1756
+ quiet = null;
1757
+ if (!asking())
1758
+ done(true);
1759
+ }, OPENING_QUIET_MS);
1760
+ };
1761
+ const onExit = () => done(false);
1762
+ const cap = setTimeout(() => {
1763
+ if (!asking())
1764
+ done(seen);
1765
+ }, OPENING_READY_CAP_MS);
1766
+ cap.unref?.();
1496
1767
  pty.on('data', onData);
1768
+ pty.on('exit', onExit);
1497
1769
  });
1498
1770
  if (!ready || !pty.running)
1499
1771
  return;
@@ -1511,7 +1783,8 @@ export class Hub extends EventEmitter {
1511
1783
  }
1512
1784
  async readScreen(sessionId, address, lines) {
1513
1785
  const me = this.requireSession(sessionId);
1514
- const result = await this.readScreenAt(address, lines);
1786
+ this.requireVisible(me, address);
1787
+ const result = await this.readScreenAt(me.address, address, lines);
1515
1788
  // Decorating after the await, rather than counting the call up front,
1516
1789
  // means a remote or uplink target gets the note too. Counted here, not in
1517
1790
  // readScreenAt: that path also serves peers asking on this hub's behalf
@@ -1525,13 +1798,15 @@ export class Hub extends EventEmitter {
1525
1798
  * agent of its own, or a peer that authenticated one and is asking on its
1526
1799
  * behalf. Resolves the address the same three ways a delivery does.
1527
1800
  */
1528
- async readScreenAt(address, lines) {
1801
+ async readScreenAt(from, address, lines) {
1529
1802
  const where = this.locate(address);
1530
1803
  if (where === 'remote')
1531
1804
  return this.peers.readScreen(address, lines);
1532
1805
  if (where === 'uplink') {
1533
1806
  return this.uplink.ask({
1534
1807
  t: 'readScreen',
1808
+ // The canvas decides again whether the caller may look.
1809
+ from,
1535
1810
  address,
1536
1811
  lines,
1537
1812
  });
@@ -1557,9 +1832,13 @@ export class Hub extends EventEmitter {
1557
1832
  const me = this.requireSession(sessionId);
1558
1833
  const target = this.sessions.getByAddress(address);
1559
1834
  if (!target) {
1560
- // Spawn lineage lives on the hub that owns the session, so the
1561
- // spawned-by rule below is enforced over there, by that hub.
1562
- if (this.locate(address) === 'remote') {
1835
+ // A remote session reports who spawned it by address, so the rule
1836
+ // below can be judged here before the host is asked to stop it.
1837
+ const remote = this.peers.find(address)?.session;
1838
+ if (remote) {
1839
+ if (remote.parentAddress !== me.address) {
1840
+ throw new Error(`"${address}" was not spawned by you; only its spawner may stop it`);
1841
+ }
1563
1842
  await this.peers.stopSession(address);
1564
1843
  return { stopped: address };
1565
1844
  }
@@ -1568,8 +1847,9 @@ export class Hub extends EventEmitter {
1568
1847
  if (target.id === me.id)
1569
1848
  throw new Error('use your own exit command to stop yourself');
1570
1849
  // An agent may only stop what it created. Otherwise a single confused
1571
- // agent could take down the whole canvas.
1572
- if (target.spawnedBy !== me.id) {
1850
+ // agent could take down the whole canvas. Judged by address, so a child
1851
+ // whose spawner is on another machine is judged the same as a local one.
1852
+ if (target.parentAddress !== me.address) {
1573
1853
  throw new Error(`"${address}" was not spawned by you; only its spawner may stop it`);
1574
1854
  }
1575
1855
  this.sessions.stop(target.id);