@kdonev/termscape 0.1.6 → 0.1.8

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 (76) hide show
  1. package/dist/agents/profiles.d.ts +7 -0
  2. package/dist/agents/profiles.d.ts.map +1 -1
  3. package/dist/agents/profiles.js +5 -0
  4. package/dist/agents/profiles.js.map +1 -1
  5. package/dist/agents/router.d.ts +12 -6
  6. package/dist/agents/router.d.ts.map +1 -1
  7. package/dist/agents/router.js +12 -6
  8. package/dist/agents/router.js.map +1 -1
  9. package/dist/agents/templates.d.ts +11 -8
  10. package/dist/agents/templates.d.ts.map +1 -1
  11. package/dist/agents/templates.js +12 -9
  12. package/dist/agents/templates.js.map +1 -1
  13. package/dist/agents/visibility.d.ts +22 -0
  14. package/dist/agents/visibility.d.ts.map +1 -0
  15. package/dist/agents/visibility.js +20 -0
  16. package/dist/agents/visibility.js.map +1 -0
  17. package/dist/agents/wiring.d.ts +1 -1
  18. package/dist/agents/wiring.d.ts.map +1 -1
  19. package/dist/agents/wiring.js +12 -10
  20. package/dist/agents/wiring.js.map +1 -1
  21. package/dist/cli.js +25 -7
  22. package/dist/cli.js.map +1 -1
  23. package/dist/db/migrations.d.ts.map +1 -1
  24. package/dist/db/migrations.js +17 -0
  25. package/dist/db/migrations.js.map +1 -1
  26. package/dist/db/store.d.ts +1 -0
  27. package/dist/db/store.d.ts.map +1 -1
  28. package/dist/db/store.js +9 -4
  29. package/dist/db/store.js.map +1 -1
  30. package/dist/hub.d.ts +111 -30
  31. package/dist/hub.d.ts.map +1 -1
  32. package/dist/hub.js +339 -90
  33. package/dist/hub.js.map +1 -1
  34. package/dist/hub.tgz +0 -0
  35. package/dist/mcp/server.d.ts.map +1 -1
  36. package/dist/mcp/server.js +6 -1
  37. package/dist/mcp/server.js.map +1 -1
  38. package/dist/protocol/domain.d.ts +1 -0
  39. package/dist/protocol/domain.d.ts.map +1 -1
  40. package/dist/protocol/domain.js +12 -0
  41. package/dist/protocol/domain.js.map +1 -1
  42. package/dist/protocol/mcp-tools.js +1 -1
  43. package/dist/protocol/mcp-tools.js.map +1 -1
  44. package/dist/protocol/peer.d.ts +41 -1
  45. package/dist/protocol/peer.d.ts.map +1 -1
  46. package/dist/protocol/peer.js +42 -1
  47. package/dist/protocol/peer.js.map +1 -1
  48. package/dist/protocol/ws.d.ts +6 -0
  49. package/dist/protocol/ws.d.ts.map +1 -1
  50. package/dist/protocol/ws.js +2 -0
  51. package/dist/protocol/ws.js.map +1 -1
  52. package/dist/remote/peer-serve.d.ts.map +1 -1
  53. package/dist/remote/peer-serve.js +15 -3
  54. package/dist/remote/peer-serve.js.map +1 -1
  55. package/dist/remote/registry.d.ts +7 -0
  56. package/dist/remote/registry.d.ts.map +1 -1
  57. package/dist/remote/registry.js +15 -0
  58. package/dist/remote/registry.js.map +1 -1
  59. package/dist/server.d.ts.map +1 -1
  60. package/dist/server.js +13 -7
  61. package/dist/server.js.map +1 -1
  62. package/dist/session/manager.d.ts +23 -3
  63. package/dist/session/manager.d.ts.map +1 -1
  64. package/dist/session/manager.js +57 -10
  65. package/dist/session/manager.js.map +1 -1
  66. package/dist/session/pty.d.ts +35 -2
  67. package/dist/session/pty.d.ts.map +1 -1
  68. package/dist/session/pty.js +110 -17
  69. package/dist/session/pty.js.map +1 -1
  70. package/dist/window/app-window.js +20 -0
  71. package/dist/window/app-window.js.map +1 -1
  72. package/package.json +1 -1
  73. package/web/assets/{index-BgOaaAFO.js → index-i7fXLKF0.js} +2 -2
  74. package/web/icon.png +0 -0
  75. package/web/icon.svg +22 -0
  76. package/web/index.html +3 -1
package/dist/hub.js CHANGED
@@ -13,19 +13,37 @@ 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.6';
23
+ export const HUB_VERSION = '0.1.8';
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;
36
+ /**
37
+ * How long a delivery waits for a starting agent before going in regardless.
38
+ *
39
+ * There has to be a bound. A session's readiness gate deliberately outlasts
40
+ * its own cap while a question is on screen - the instruction is still wanted
41
+ * once the human answers - but a `send_message` that blocks its caller until
42
+ * somebody walks over to a window is worse than a message that arrives badly.
43
+ * So the wait is bounded here, and past it delivery is what it always was:
44
+ * immediate, recorded, and the CLI's business.
45
+ */
46
+ const DELIVERY_WAIT_CAP_MS = 20_000;
29
47
  /** Colours cycled through when a workspace is created, for canvas grouping. */
30
48
  const WORKSPACE_COLORS = [
31
49
  '#7c9cf5',
@@ -77,6 +95,16 @@ export class Hub extends EventEmitter {
77
95
  * two roles a null check rather than a flag.
78
96
  */
79
97
  uplink = null;
98
+ /**
99
+ * Per session: resolves once its CLI is reading and its opening instruction,
100
+ * if it had one, has been typed and sent.
101
+ *
102
+ * Every delivery to a local session queues behind this. Without it a message
103
+ * sent to an agent that was still starting - which is exactly what an agent
104
+ * that has just called `spawn_agent` does next - was written into a program
105
+ * not yet reading, and sat in the composer unsent forever (issue 27).
106
+ */
107
+ opened = new Map();
80
108
  announceTimer = null;
81
109
  /** Live SSH tunnels, keyed by host id. Not persisted: they die with the hub. */
82
110
  tunnels = new Map();
@@ -113,12 +141,21 @@ export class Hub extends EventEmitter {
113
141
  this.sessions = new SessionManager(this.store, this.profiles, this.tokens, 'http://127.0.0.1:0');
114
142
  this.router = new MessageRouter(this.store, this.sessions, this.profiles, (m) => this.emit('message', m));
115
143
  this.sessions.on('session', (s) => this.emit('session', s));
116
- this.sessions.on('removed', (id, address) => this.emit('removed', id, address));
144
+ this.sessions.on('removed', (id, address) => {
145
+ // Nothing left to open, and nothing left to hold a delivery for.
146
+ this.opened.delete(id);
147
+ this.emit('removed', id, address);
148
+ });
117
149
  this.sessions.on('data', (id, chunk) => this.emit('data', id, chunk));
118
150
  this.peers = new PeerRegistry(this.store, HUB_VERSION, (address) => this.sessions.getByAddress(address)?.id ?? null);
119
151
  // Peer windows share the canvas, so a new local window must not land on
120
152
  // one. The manager cannot see the registry; this is the view it needs.
121
153
  this.sessions.remoteWindows = () => this.peers.sessions().map((s) => s.window);
154
+ // A brief lists who its agent can see, and that is decided across every
155
+ // machine by lineage - which only the hub has the view for.
156
+ this.sessions.visiblePeers = (s) => this.everyAgent()
157
+ .filter((o) => o.address !== s.address && canSee(s, o))
158
+ .map((o) => o.address);
122
159
  this.peers.on('host', (h) => this.emit('host', h));
123
160
  this.peers.on('peerAgents', (hostId, found) => this.emit('hostAgents', hostId, found));
124
161
  this.peers.on('peerSession', (s) => this.emit('session', s));
@@ -135,11 +172,22 @@ export class Hub extends EventEmitter {
135
172
  // question came.
136
173
  this.peers.on('relay', (ask, reply, fromHostId) => {
137
174
  const done = (p) => p.then((result) => reply(true, result, null), (err) => reply(false, null, err.message));
175
+ /*
176
+ * The canvas is authoritative about who may see whom: the asking hub
177
+ * checked already, but from a directory that may be a moment stale,
178
+ * and a host that did not check at all must not be a way round it.
179
+ */
138
180
  switch (ask.t) {
139
181
  case 'deliver':
140
- return void done(this.deliverFrom(ask.from, ask.to, ask.body));
182
+ return void done((async () => {
183
+ this.requireVisible(this.lineageOf(ask.from), ask.to);
184
+ return this.deliverFrom(ask.from, ask.to, ask.body);
185
+ })());
141
186
  case 'readScreen':
142
- return void done(this.readScreenAt(ask.address, ask.lines));
187
+ return void done((async () => {
188
+ this.requireVisible(this.lineageOf(ask.from), ask.address);
189
+ return this.readScreenAt(ask.from, ask.address, ask.lines);
190
+ })());
143
191
  case 'listHosts':
144
192
  return void done(Promise.resolve(this.hostsView(fromHostId)));
145
193
  case 'listTemplates':
@@ -148,6 +196,16 @@ export class Hub extends EventEmitter {
148
196
  return void done(Promise.resolve(this.pickTemplateFor(ask)));
149
197
  case 'spawn':
150
198
  return void done(this.spawnFrom(ask, fromHostId));
199
+ case 'proposeTemplate': {
200
+ // `from` is only trusted as far as the host that sent it: an
201
+ // agent on one machine must not be able to propose as, and have
202
+ // the answer typed into, an agent on another.
203
+ if (this.peers.hostIdFor(ask.from) !== fromHostId) {
204
+ return void done(Promise.reject(new Error(`no agent at address "${ask.from}"`)));
205
+ }
206
+ const { t: _t, from, ...input } = ask;
207
+ return void done(this.proposeTemplateFrom(from, input));
208
+ }
151
209
  }
152
210
  });
153
211
  // Every attached machine's view of the canvas is whatever we last told it,
@@ -195,6 +253,39 @@ export class Hub extends EventEmitter {
195
253
  return 'remote';
196
254
  return this.uplink?.agents().some((a) => a.address === address) ? 'uplink' : null;
197
255
  }
256
+ /**
257
+ * Every agent this hub knows of, on any machine, with its lineage: its own,
258
+ * those on machines it holds, and those the canvas told it about. The three
259
+ * are exclusive for the reason `locate` gives.
260
+ */
261
+ everyAgent() {
262
+ return [
263
+ ...this.sessions.list(),
264
+ ...this.peers.sessions(),
265
+ ...(this.uplink?.agents() ?? []),
266
+ ].map((a) => ({ address: a.address, parentAddress: a.parentAddress ?? null }));
267
+ }
268
+ /** One address's lineage, from the same view, or null for one nobody here knows. */
269
+ lineageOf(address) {
270
+ const local = this.sessions.getByAddress(address);
271
+ if (local)
272
+ return { address, parentAddress: local.parentAddress ?? null };
273
+ const found = this.peers.find(address)?.session ??
274
+ this.uplink?.agents().find((a) => a.address === address);
275
+ return found ? { address, parentAddress: found.parentAddress ?? null } : null;
276
+ }
277
+ /**
278
+ * Refuse to act on an address the viewer may not see, in exactly the words
279
+ * used for one that does not exist: a hidden agent must not be discoverable
280
+ * by probing for the difference. An address nobody knows is let through, so
281
+ * the delivery path can record the failed attempt as it always has.
282
+ */
283
+ requireVisible(viewer, address) {
284
+ const target = this.lineageOf(address);
285
+ if (target && (!viewer || !canSee(viewer, target))) {
286
+ throw new Error(`no agent at address "${address}"`);
287
+ }
288
+ }
198
289
  /**
199
290
  * Attach this hub to the canvas reachable over `uplink`.
200
291
  *
@@ -440,7 +531,21 @@ export class Hub extends EventEmitter {
440
531
  */
441
532
  async proposeTemplate(sessionId, input) {
442
533
  const me = this.requireSession(sessionId);
443
- const mine = [...this.proposals.values()].filter((p) => p.fromAddr === me.address);
534
+ // Templates are central (issue 22): the canvas is where they live and
535
+ // where the human who answers is, so an attached hub hands the proposal
536
+ // up. No local fallback when that fails - a proposal kept here would wait
537
+ // for a dialog nobody is ever shown, which is the bug this fixes.
538
+ if (this.uplink?.attached) {
539
+ return this.uplink.ask({ t: 'proposeTemplate', from: me.address, ...input });
540
+ }
541
+ return this.proposeTemplateFrom(me.address, input);
542
+ }
543
+ /**
544
+ * A proposal from an agent already established, here or on an attached
545
+ * machine - the canvas's half of proposeTemplate.
546
+ */
547
+ async proposeTemplateFrom(fromAddr, input) {
548
+ const mine = [...this.proposals.values()].filter((p) => p.fromAddr === fromAddr);
444
549
  if (mine.length >= MAX_PENDING_PROPOSALS) {
445
550
  /*
446
551
  * Name them. The way this limit is actually reached is a human closing
@@ -461,7 +566,7 @@ export class Hub extends EventEmitter {
461
566
  const candidate = this.candidateTemplate(input);
462
567
  const proposal = {
463
568
  id: randomUUID(),
464
- fromAddr: me.address,
569
+ fromAddr,
465
570
  proposedAt: Date.now(),
466
571
  template: {
467
572
  id: candidate.id,
@@ -527,6 +632,21 @@ export class Hub extends EventEmitter {
527
632
  * as an instruction.
528
633
  */
529
634
  tellAgent(address, text) {
635
+ if (!this.sessions.getByAddress(address)) {
636
+ // An agent on an attached machine proposed it, and only that machine
637
+ // owns its terminal. Best effort, like the local case below.
638
+ if (this.peers.hostIdFor(address))
639
+ void this.peers.notify(address, text).catch(() => { });
640
+ return;
641
+ }
642
+ this.notifyAgent(address, text);
643
+ }
644
+ /**
645
+ * tellAgent's local half, and what an attached hub runs when the canvas
646
+ * sends it a notice for one of its agents. The one place the `[termscape]`
647
+ * prefix is applied, so a notice reads the same from any machine.
648
+ */
649
+ notifyAgent(address, text) {
530
650
  const target = this.sessions.getByAddress(address);
531
651
  if (!target)
532
652
  return;
@@ -684,7 +804,10 @@ export class Hub extends EventEmitter {
684
804
  };
685
805
  }
686
806
  async startSession(opts) {
687
- return this.startPicked(opts, this.resolveTemplate(opts));
807
+ const parentAddress = opts.spawnedBy
808
+ ? (this.sessions.get(opts.spawnedBy)?.address ?? null)
809
+ : null;
810
+ return this.startPicked({ ...opts, parentAddress }, this.resolveTemplate(opts));
688
811
  }
689
812
  /**
690
813
  * Start a session from a template already resolved, locally or on a host.
@@ -700,7 +823,6 @@ export class Hub extends EventEmitter {
700
823
  // A workspace that belongs to a host runs its agents there. The peer owns
701
824
  // the PTY; what comes back is a session we show on our own canvas.
702
825
  if (ws.hostId) {
703
- const spawner = opts.spawnedBy ? this.sessions.get(opts.spawnedBy) : null;
704
826
  // Resolved values cross, never the template id: a template is config
705
827
  // and the two machines do not share config, so a name that means "opus,
706
828
  // high effort" here may mean nothing over there. The id travels only so
@@ -718,7 +840,7 @@ export class Hub extends EventEmitter {
718
840
  // here and the far side has never heard of it.
719
841
  env: picked.env,
720
842
  name: opts.name,
721
- spawnedByAddress: spawner?.address ?? null,
843
+ spawnedByAddress: opts.parentAddress ?? null,
722
844
  });
723
845
  }
724
846
  return this.startResolved({ ...opts, ...picked });
@@ -749,6 +871,7 @@ export class Hub extends EventEmitter {
749
871
  name: opts.name ?? opts.template ?? undefined,
750
872
  cwd: opts.cwd,
751
873
  spawnedBy: opts.spawnedBy ?? null,
874
+ parentAddress: opts.parentAddress ?? null,
752
875
  });
753
876
  /*
754
877
  * Typed in once the CLI is up rather than written into argv, where it
@@ -764,35 +887,10 @@ export class Hub extends EventEmitter {
764
887
  const opening = [this.typedBrief(session), opts.prompt]
765
888
  .filter((part) => !!part)
766
889
  .join('\n\n');
767
- if (opening)
768
- void this.deliverOpeningInstruction(session.id, opening);
890
+ this.noteOpening(session.id, opening || null);
769
891
  this.store.setOpeningPrompt(session.id, opts.restorePrompt === undefined ? (opts.prompt ?? null) : opts.restorePrompt);
770
892
  return session;
771
893
  }
772
- /**
773
- * The agent cleared its conversation, so type its opening in again.
774
- *
775
- * Claude Code's `/clear` starts a new conversation in the same process, and
776
- * everything the opening instruction told it - the role a template gave it,
777
- * what the human said it was for - goes with the old one. The brief does
778
- * not need this when it rode in on a flag, since that is system prompt and
779
- * survives; a typed brief is conversation like the instruction, so it goes
780
- * first here exactly as it did at start.
781
- *
782
- * Reported by the agent's own SessionStart hook (see wiring.ts), so this
783
- * fires for a clear and never for a start or a resume, which must not
784
- * repeat the opening.
785
- */
786
- restoreOpeningAfterClear(sessionId) {
787
- const session = this.sessions.get(sessionId);
788
- if (!session)
789
- return;
790
- const opening = [this.typedBrief(session), this.store.getOpeningPrompt(sessionId)]
791
- .filter((part) => !!part)
792
- .join('\n\n');
793
- if (opening)
794
- void this.typeWhenReady(sessionId, opening, { alreadyUp: true });
795
- }
796
894
  /**
797
895
  * The brief for an agent that cannot be handed one, or null.
798
896
  *
@@ -826,9 +924,31 @@ export class Hub extends EventEmitter {
826
924
  */
827
925
  async resumeSession(sessionId) {
828
926
  const session = await this.sessions.resume(sessionId);
829
- const brief = this.typedBrief(session);
830
- if (brief)
831
- void this.deliverOpeningInstruction(session.id, brief);
927
+ // A fresh CLI, so a fresh gate: messages must wait for this one to be
928
+ // reading just as they did when it first started.
929
+ this.noteOpening(session.id, this.typedBrief(session));
930
+ return session;
931
+ }
932
+ /**
933
+ * Start a running session over: a fresh conversation, an empty window, and
934
+ * its opening typed in again - the window's `clear` button.
935
+ *
936
+ * A restart rather than typing `/clear` at the CLI. Only Claude Code could
937
+ * say it had cleared, and only through a hook; a restart is the same for
938
+ * every agent, and the hub knows exactly when the new one is up.
939
+ *
940
+ * What comes back is the opening the session recorded when it started (see
941
+ * startResolved): a template's instruction, or the human's from the dialog -
942
+ * never a spawning peer's task, which would start that work over. A brief
943
+ * that rides in on a flag needs nothing here; a typed one goes first, as it
944
+ * did at start.
945
+ */
946
+ async clearSession(sessionId) {
947
+ const session = await this.sessions.restartFresh(sessionId);
948
+ const opening = [this.typedBrief(session), this.store.getOpeningPrompt(sessionId)]
949
+ .filter((part) => !!part)
950
+ .join('\n\n');
951
+ this.noteOpening(session.id, opening || null);
832
952
  return session;
833
953
  }
834
954
  async resumeWorkspace(workspaceId) {
@@ -939,7 +1059,9 @@ export class Hub extends EventEmitter {
939
1059
  workspace: ws?.name ?? null,
940
1060
  cwd: s.cwd,
941
1061
  profile: s.profile,
942
- spawnedBy: s.spawnedBy ? this.sessions.get(s.spawnedBy)?.address ?? null : null,
1062
+ // By address rather than through the local id, which a parent on
1063
+ // another machine never had.
1064
+ spawnedBy: s.parentAddress ?? null,
943
1065
  };
944
1066
  }
945
1067
  /**
@@ -985,13 +1107,13 @@ export class Hub extends EventEmitter {
985
1107
  }));
986
1108
  }
987
1109
  /**
988
- * An attached hub's list: what the canvas has, over what this hub has.
1110
+ * An attached hub's list: the templates the canvas made, and the bare agents
1111
+ * this machine has (see overlayCanvasTemplates).
989
1112
  *
990
- * Templates are made on the canvas, so an agent over here that could only
991
- * see this hub's own list never saw the ones a human had just added. The
992
- * canvas's derived templates are left out - they describe the canvas
993
- * machine's agents, and this machine's own are already in the local list.
994
- * A link that cannot answer leaves the local list, which is still true.
1113
+ * Templates are central (issue 22), so this hub's own stored or declared
1114
+ * ones are left out: nobody on the canvas can see or edit them, and
1115
+ * spawn_agent refuses them here for the same reason. A link that cannot
1116
+ * answer leaves this machine's bare agents, which are still true.
995
1117
  */
996
1118
  async templateViewsWithCanvas() {
997
1119
  const local = this.templateViews();
@@ -1000,7 +1122,7 @@ export class Hub extends EventEmitter {
1000
1122
  return overlayCanvasTemplates(canvas, local);
1001
1123
  }
1002
1124
  catch {
1003
- return local;
1125
+ return overlayCanvasTemplates([], local);
1004
1126
  }
1005
1127
  }
1006
1128
  /**
@@ -1259,6 +1381,11 @@ export class Hub extends EventEmitter {
1259
1381
  return this.store.getHost(ws.hostId)?.label ?? 'remote';
1260
1382
  return this.thisMachineLabel();
1261
1383
  }
1384
+ /**
1385
+ * Every agent the caller can see, on every machine: lineage decides who
1386
+ * that is (see agents/visibility.ts), not the machine or the workspace.
1387
+ * `workspace` only narrows what is already visible.
1388
+ */
1262
1389
  async listAgents(sessionId, workspace) {
1263
1390
  const me = this.requireSession(sessionId);
1264
1391
  const wsById = new Map(this.store.listWorkspaces().map((w) => [w.id, w]));
@@ -1271,6 +1398,7 @@ export class Hub extends EventEmitter {
1271
1398
  status: s.status,
1272
1399
  statusText: s.statusText,
1273
1400
  host: myLabel,
1401
+ parentAddress: s.parentAddress ?? null,
1274
1402
  isYou: s.id === me.id,
1275
1403
  }));
1276
1404
  const hostLabels = new Map(this.store.listHosts().map((h) => [h.id, h.label]));
@@ -1287,6 +1415,7 @@ export class Hub extends EventEmitter {
1287
1415
  status: s.status,
1288
1416
  statusText: s.statusText,
1289
1417
  host: (hostId ? hostLabels.get(hostId) : undefined) ?? 'remote',
1418
+ parentAddress: s.parentAddress ?? null,
1290
1419
  isYou: false,
1291
1420
  };
1292
1421
  });
@@ -1294,12 +1423,13 @@ export class Hub extends EventEmitter {
1294
1423
  // is the only complete one, so this is where an agent on an attached
1295
1424
  // machine learns that anything exists beyond this machine.
1296
1425
  const fromCanvas = (this.uplink?.agents() ?? []).map((a) => ({ ...a, isYou: false }));
1297
- return [...local, ...remote, ...fromCanvas].filter((a) => !workspace || a.workspace === workspace);
1426
+ return [...local, ...remote, ...fromCanvas].filter((a) => (a.isYou || canSee(me, a)) && (!workspace || a.workspace === workspace));
1298
1427
  }
1299
1428
  async sendMessage(sessionId, to, text) {
1300
1429
  const me = this.requireSession(sessionId);
1301
1430
  if (me.address === to)
1302
1431
  throw new Error('cannot send a message to yourself');
1432
+ this.requireVisible(me, to);
1303
1433
  const result = await this.deliverFrom(me.address, to, text);
1304
1434
  // Only a successful send counts as a question asked - deliverFrom throws
1305
1435
  // on failure, so a message that never arrived does not excuse polling.
@@ -1341,6 +1471,7 @@ export class Hub extends EventEmitter {
1341
1471
  statusText: s.statusText,
1342
1472
  // What the machine that owns the canvas is called, from anywhere else.
1343
1473
  host: 'canvas',
1474
+ parentAddress: s.parentAddress ?? null,
1344
1475
  }));
1345
1476
  const elsewhere = this.peers
1346
1477
  .sessions()
@@ -1353,6 +1484,7 @@ export class Hub extends EventEmitter {
1353
1484
  status: s.status,
1354
1485
  statusText: s.statusText,
1355
1486
  host: hostLabels.get(this.peers.hostIdFor(s.address) ?? '') ?? 'remote',
1487
+ parentAddress: s.parentAddress ?? null,
1356
1488
  }));
1357
1489
  return [...here, ...elsewhere];
1358
1490
  }
@@ -1369,6 +1501,13 @@ export class Hub extends EventEmitter {
1369
1501
  // records the failed attempt with its reason, which is what keeps the
1370
1502
  // promise that no message is ever dropped silently.
1371
1503
  if (where === 'local' || where === null) {
1504
+ // Behind the target's opening, if it is still starting. This is the one
1505
+ // place delivery is not immediate, and the exception earns itself: the
1506
+ // alternative is writing into a CLI that is not reading, which is not a
1507
+ // delivery at all.
1508
+ const target = this.sessions.getByAddress(to);
1509
+ if (target)
1510
+ await this.awaitOpened(target.id);
1372
1511
  const r = this.router.send(fromAddr, to, text);
1373
1512
  if (!r.delivered)
1374
1513
  throw new Error(r.error ?? 'delivery failed');
@@ -1417,8 +1556,10 @@ export class Hub extends EventEmitter {
1417
1556
  *
1418
1557
  * On an attached hub the canvas is asked first, because that is where
1419
1558
  * templates are made (see templateViewsWithCanvas). Only a template the
1420
- * canvas actually holds comes back from there; anything else, and any link
1421
- * that does not answer, is resolved against this hub's own config.
1559
+ * canvas actually holds comes back from there. Anything else, and any link
1560
+ * that does not answer, is resolved against this hub's own config - but
1561
+ * only as a bare agent or its derived template: templates are central, and
1562
+ * one stored or declared only on this machine is not one.
1422
1563
  */
1423
1564
  async pickSpawnTemplate(me, profile) {
1424
1565
  if (this.uplink?.attached) {
@@ -1438,6 +1579,16 @@ export class Hub extends EventEmitter {
1438
1579
  throw new Error(answer.error);
1439
1580
  if (answer?.picked)
1440
1581
  return answer.picked;
1582
+ const own = (id) => {
1583
+ const t = this.templates.get(id);
1584
+ return !t || t.source === 'derived';
1585
+ };
1586
+ if (profile !== undefined && !own(profile))
1587
+ throw new Error(`no template "${profile}"`);
1588
+ // The caller's own template came from the canvas, which has just said
1589
+ // it no longer holds it; its agent is what is left.
1590
+ const inherit = profile ?? (me.template && own(me.template) ? me.template : me.profile);
1591
+ return this.resolveTemplate({ profile: inherit });
1441
1592
  }
1442
1593
  const inherited = me.template ? this.templates.get(me.template) : null;
1443
1594
  const inherit = profile ?? (inherited && !inherited.error ? me.template : me.profile);
@@ -1504,7 +1655,7 @@ export class Hub extends EventEmitter {
1504
1655
  throw new Error(`unknown workspace "${opts.workspace}"`);
1505
1656
  ws = found;
1506
1657
  }
1507
- const child = await this.startPicked({ workspaceId: ws.id, name: opts.name, spawnedBy: me.id }, { ...picked, prompt: opening, restorePrompt: picked.prompt ?? null });
1658
+ const child = await this.startPicked({ workspaceId: ws.id, name: opts.name, spawnedBy: me.id, parentAddress: me.address }, { ...picked, prompt: opening, restorePrompt: picked.prompt ?? null });
1508
1659
  return {
1509
1660
  address: child.address,
1510
1661
  workspace: ws.name,
@@ -1544,11 +1695,9 @@ export class Hub extends EventEmitter {
1544
1695
  restorePrompt: ask.restore ?? null,
1545
1696
  env: ask.env,
1546
1697
  name: ask.name,
1547
- // See the known gap noted on spawn_agent's `host`: the spawner
1548
- // lives on a hub this canvas has no local id for, and the
1549
- // canvas-local branch below cannot take one either — the session
1550
- // table's foreign key would reject it.
1551
- spawnedByAddress: null,
1698
+ // By address, the one id every hub agrees on: the spawner lives on
1699
+ // a hub this canvas has no local id for.
1700
+ spawnedByAddress: ask.from,
1552
1701
  })
1553
1702
  : await this.startResolved({
1554
1703
  workspaceId: ws.id,
@@ -1560,7 +1709,11 @@ export class Hub extends EventEmitter {
1560
1709
  restorePrompt: ask.restore ?? null,
1561
1710
  env: ask.env,
1562
1711
  name: ask.name,
1712
+ // No local id to give - the session table's foreign key would
1713
+ // reject one from another machine - but the address still says
1714
+ // whose child this is, which is what visibility is decided from.
1563
1715
  spawnedBy: null,
1716
+ parentAddress: ask.from,
1564
1717
  });
1565
1718
  return {
1566
1719
  address: child.address,
@@ -1582,56 +1735,145 @@ export class Hub extends EventEmitter {
1582
1735
  async deliverOpeningInstruction(sessionId, prompt) {
1583
1736
  await this.typeWhenReady(sessionId, prompt);
1584
1737
  }
1738
+ /**
1739
+ * Record how a session opens, and hold every later delivery behind it.
1740
+ *
1741
+ * Registered whether or not there is anything to type: an agent started with
1742
+ * no instruction at all still has a CLI that is not reading yet, and that is
1743
+ * the case issue 27 was reported for. Not awaited by the caller - the window
1744
+ * appears now - but stored, so the first message to arrive waits for the
1745
+ * same moment the instruction would have.
1746
+ */
1747
+ noteOpening(sessionId, opening) {
1748
+ const done = opening
1749
+ ? this.deliverOpeningInstruction(sessionId, opening)
1750
+ : this.awaitReady(sessionId).then(() => { });
1751
+ // Failures are the gate opening, not the gate jamming: a session that died
1752
+ // on the way up must not hold its own message log hostage.
1753
+ this.opened.set(sessionId, done.catch(() => { }));
1754
+ }
1755
+ /**
1756
+ * Wait for a local session to be ready to be typed at, but not forever.
1757
+ *
1758
+ * Once resolved the promise stays resolved, so this costs a steady-state
1759
+ * delivery a microtask and nothing else.
1760
+ */
1761
+ async awaitOpened(sessionId) {
1762
+ const gate = this.opened.get(sessionId);
1763
+ if (!gate)
1764
+ return;
1765
+ let timer;
1766
+ try {
1767
+ await Promise.race([
1768
+ gate,
1769
+ new Promise((res) => {
1770
+ timer = setTimeout(res, DELIVERY_WAIT_CAP_MS);
1771
+ timer.unref?.();
1772
+ }),
1773
+ ]);
1774
+ }
1775
+ finally {
1776
+ if (timer)
1777
+ clearTimeout(timer);
1778
+ }
1779
+ }
1585
1780
  /**
1586
1781
  * Wait for the CLI to be up, then type. Written straight away the text lands
1587
1782
  * before the program is reading it; the wait is for output to arrive and
1588
- * then pause, which is as close to "it has drawn its prompt" as this gets
1783
+ * then stop, which is as close to "it has drawn its prompt" as this gets
1589
1784
  * without knowing the CLI.
1590
1785
  *
1591
- * `alreadyUp` is for a CLI that has been running all along and is only
1592
- * redrawing, as after a `/clear`. It may already have finished drawing by
1593
- * the time this is called, and an idle one then prints nothing at all - so
1594
- * the pause is counted from now as well as from its next output, and
1595
- * running out of patience still types rather than giving up.
1786
+ * Quiet measured from the last output, not a fixed delay from the first.
1787
+ * The first output is not the CLI at all on Windows - ConPTY draws its own
1788
+ * the moment the process exists - so a delay counted from there was a guess
1789
+ * at how long the CLI takes to load, and a slower machine lost it: the start
1790
+ * of the instruction went into a program not yet reading, the rest landed
1791
+ * in its composer, and the Enter went nowhere. A CLI that never stops
1792
+ * drawing still gets its instruction, once the cap is up.
1793
+ *
1794
+ * Nor into a question. A CLI that opens by asking the human something -
1795
+ * Claude Code in a folder it has not been trusted in - is quiet too, and
1796
+ * the instruction typed at it was spent on the question: gone, or partly
1797
+ * taken as answers to it. Such a screen is waited out until it changes.
1596
1798
  */
1597
- async typeWhenReady(sessionId, text, opts = {}) {
1799
+ async typeWhenReady(sessionId, text) {
1800
+ const ready = await this.awaitReady(sessionId);
1598
1801
  const pty = this.sessions.pty(sessionId);
1599
- if (!pty)
1600
- return;
1601
- const ready = await new Promise((res) => {
1602
- let settled = false;
1603
- const done = (v) => {
1604
- if (settled)
1605
- return;
1606
- settled = true;
1607
- clearTimeout(timer);
1608
- pty.off('data', onData);
1609
- res(v);
1610
- };
1611
- const onData = () => setTimeout(() => done(true), 1200);
1612
- const timer = setTimeout(() => done(!!opts.alreadyUp), 20_000);
1613
- timer.unref?.();
1614
- pty.on('data', onData);
1615
- if (opts.alreadyUp)
1616
- onData();
1617
- });
1618
- if (!ready || !pty.running)
1802
+ if (!ready || !pty?.running)
1619
1803
  return;
1620
1804
  const session = this.sessions.get(sessionId);
1621
1805
  if (!session)
1622
1806
  return;
1623
1807
  const mode = this.profiles.get(session.profile)?.inject ?? 'bracketed';
1624
1808
  try {
1625
- this.sessions.inject(sessionId, encodeInjection(text, mode), INJECT_SUBMIT);
1809
+ // Awaited, so that a caller holding this session's readiness gate is not
1810
+ // released until the Enter is in: a message injected between the paste
1811
+ // and its Enter is folded into the same prompt.
1812
+ await this.sessions.inject(sessionId, encodeInjection(text, mode), INJECT_SUBMIT);
1626
1813
  }
1627
1814
  catch {
1628
1815
  // The agent died between the readiness check and the write; the message
1629
1816
  // log already reflects that it never started.
1630
1817
  }
1631
1818
  }
1819
+ /**
1820
+ * Wait until the CLI in a session is up and reading, or give up.
1821
+ *
1822
+ * Split out of `typeWhenReady` because typing is not the only thing that has
1823
+ * to wait for this. A message delivered to an agent whose CLI has not
1824
+ * started reading is written into a program that is not listening: the paste
1825
+ * lands in a composer that is not there yet and the Enter goes nowhere, and
1826
+ * what the sender sees is a delivery that was recorded and never acted on.
1827
+ * See `deliverFrom`.
1828
+ */
1829
+ async awaitReady(sessionId) {
1830
+ const pty = this.sessions.pty(sessionId);
1831
+ if (!pty)
1832
+ return false;
1833
+ const profile = this.profiles.get(this.sessions.get(sessionId)?.profile ?? '');
1834
+ const hint = profile?.askingHint ? new RegExp(profile.askingHint, 'i') : null;
1835
+ // A question on screen outlasts any cap: the human answers it when they
1836
+ // get to the window, and the instruction is still wanted after that.
1837
+ const asking = () => !!hint && hint.test(pty.tailLines(pty.rows));
1838
+ return new Promise((res) => {
1839
+ let settled = false;
1840
+ let seen = false;
1841
+ let quiet = null;
1842
+ const done = (v) => {
1843
+ if (settled)
1844
+ return;
1845
+ settled = true;
1846
+ clearTimeout(cap);
1847
+ if (quiet)
1848
+ clearTimeout(quiet);
1849
+ pty.off('data', onData);
1850
+ pty.off('exit', onExit);
1851
+ res(v);
1852
+ };
1853
+ const onData = () => {
1854
+ seen = true;
1855
+ if (quiet)
1856
+ clearTimeout(quiet);
1857
+ quiet = setTimeout(() => {
1858
+ quiet = null;
1859
+ if (!asking())
1860
+ done(true);
1861
+ }, OPENING_QUIET_MS);
1862
+ };
1863
+ const onExit = () => done(false);
1864
+ const cap = setTimeout(() => {
1865
+ if (!asking())
1866
+ done(seen);
1867
+ }, OPENING_READY_CAP_MS);
1868
+ cap.unref?.();
1869
+ pty.on('data', onData);
1870
+ pty.on('exit', onExit);
1871
+ });
1872
+ }
1632
1873
  async readScreen(sessionId, address, lines) {
1633
1874
  const me = this.requireSession(sessionId);
1634
- const result = await this.readScreenAt(address, lines);
1875
+ this.requireVisible(me, address);
1876
+ const result = await this.readScreenAt(me.address, address, lines);
1635
1877
  // Decorating after the await, rather than counting the call up front,
1636
1878
  // means a remote or uplink target gets the note too. Counted here, not in
1637
1879
  // readScreenAt: that path also serves peers asking on this hub's behalf
@@ -1645,13 +1887,15 @@ export class Hub extends EventEmitter {
1645
1887
  * agent of its own, or a peer that authenticated one and is asking on its
1646
1888
  * behalf. Resolves the address the same three ways a delivery does.
1647
1889
  */
1648
- async readScreenAt(address, lines) {
1890
+ async readScreenAt(from, address, lines) {
1649
1891
  const where = this.locate(address);
1650
1892
  if (where === 'remote')
1651
1893
  return this.peers.readScreen(address, lines);
1652
1894
  if (where === 'uplink') {
1653
1895
  return this.uplink.ask({
1654
1896
  t: 'readScreen',
1897
+ // The canvas decides again whether the caller may look.
1898
+ from,
1655
1899
  address,
1656
1900
  lines,
1657
1901
  });
@@ -1677,9 +1921,13 @@ export class Hub extends EventEmitter {
1677
1921
  const me = this.requireSession(sessionId);
1678
1922
  const target = this.sessions.getByAddress(address);
1679
1923
  if (!target) {
1680
- // Spawn lineage lives on the hub that owns the session, so the
1681
- // spawned-by rule below is enforced over there, by that hub.
1682
- if (this.locate(address) === 'remote') {
1924
+ // A remote session reports who spawned it by address, so the rule
1925
+ // below can be judged here before the host is asked to stop it.
1926
+ const remote = this.peers.find(address)?.session;
1927
+ if (remote) {
1928
+ if (remote.parentAddress !== me.address) {
1929
+ throw new Error(`"${address}" was not spawned by you; only its spawner may stop it`);
1930
+ }
1683
1931
  await this.peers.stopSession(address);
1684
1932
  return { stopped: address };
1685
1933
  }
@@ -1688,8 +1936,9 @@ export class Hub extends EventEmitter {
1688
1936
  if (target.id === me.id)
1689
1937
  throw new Error('use your own exit command to stop yourself');
1690
1938
  // An agent may only stop what it created. Otherwise a single confused
1691
- // agent could take down the whole canvas.
1692
- if (target.spawnedBy !== me.id) {
1939
+ // agent could take down the whole canvas. Judged by address, so a child
1940
+ // whose spawner is on another machine is judged the same as a local one.
1941
+ if (target.parentAddress !== me.address) {
1693
1942
  throw new Error(`"${address}" was not spawned by you; only its spawner may stop it`);
1694
1943
  }
1695
1944
  this.sessions.stop(target.id);