@kdonev/termscape 0.1.6 → 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 (72) 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/templates.d.ts +11 -8
  6. package/dist/agents/templates.d.ts.map +1 -1
  7. package/dist/agents/templates.js +12 -9
  8. package/dist/agents/templates.js.map +1 -1
  9. package/dist/agents/visibility.d.ts +22 -0
  10. package/dist/agents/visibility.d.ts.map +1 -0
  11. package/dist/agents/visibility.js +20 -0
  12. package/dist/agents/visibility.js.map +1 -0
  13. package/dist/agents/wiring.d.ts +1 -1
  14. package/dist/agents/wiring.d.ts.map +1 -1
  15. package/dist/agents/wiring.js +12 -10
  16. package/dist/agents/wiring.js.map +1 -1
  17. package/dist/cli.js +25 -7
  18. package/dist/cli.js.map +1 -1
  19. package/dist/db/migrations.d.ts.map +1 -1
  20. package/dist/db/migrations.js +17 -0
  21. package/dist/db/migrations.js.map +1 -1
  22. package/dist/db/store.d.ts +1 -0
  23. package/dist/db/store.d.ts.map +1 -1
  24. package/dist/db/store.js +9 -4
  25. package/dist/db/store.js.map +1 -1
  26. package/dist/hub.d.ts +73 -30
  27. package/dist/hub.d.ts.map +1 -1
  28. package/dist/hub.js +229 -69
  29. package/dist/hub.js.map +1 -1
  30. package/dist/hub.tgz +0 -0
  31. package/dist/mcp/server.d.ts.map +1 -1
  32. package/dist/mcp/server.js +6 -1
  33. package/dist/mcp/server.js.map +1 -1
  34. package/dist/protocol/domain.d.ts +1 -0
  35. package/dist/protocol/domain.d.ts.map +1 -1
  36. package/dist/protocol/domain.js +12 -0
  37. package/dist/protocol/domain.js.map +1 -1
  38. package/dist/protocol/mcp-tools.js +1 -1
  39. package/dist/protocol/mcp-tools.js.map +1 -1
  40. package/dist/protocol/peer.d.ts +41 -1
  41. package/dist/protocol/peer.d.ts.map +1 -1
  42. package/dist/protocol/peer.js +42 -1
  43. package/dist/protocol/peer.js.map +1 -1
  44. package/dist/protocol/ws.d.ts +6 -0
  45. package/dist/protocol/ws.d.ts.map +1 -1
  46. package/dist/protocol/ws.js +2 -0
  47. package/dist/protocol/ws.js.map +1 -1
  48. package/dist/remote/peer-serve.d.ts.map +1 -1
  49. package/dist/remote/peer-serve.js +15 -3
  50. package/dist/remote/peer-serve.js.map +1 -1
  51. package/dist/remote/registry.d.ts +7 -0
  52. package/dist/remote/registry.d.ts.map +1 -1
  53. package/dist/remote/registry.js +15 -0
  54. package/dist/remote/registry.js.map +1 -1
  55. package/dist/server.d.ts.map +1 -1
  56. package/dist/server.js +13 -7
  57. package/dist/server.js.map +1 -1
  58. package/dist/session/manager.d.ts +22 -2
  59. package/dist/session/manager.d.ts.map +1 -1
  60. package/dist/session/manager.js +52 -9
  61. package/dist/session/manager.js.map +1 -1
  62. package/dist/session/pty.d.ts +30 -1
  63. package/dist/session/pty.d.ts.map +1 -1
  64. package/dist/session/pty.js +77 -1
  65. package/dist/session/pty.js.map +1 -1
  66. package/dist/window/app-window.js +20 -0
  67. package/dist/window/app-window.js.map +1 -1
  68. package/package.json +1 -1
  69. package/web/assets/{index-BgOaaAFO.js → index-i7fXLKF0.js} +2 -2
  70. package/web/icon.png +0 -0
  71. package/web/icon.svg +22 -0
  72. package/web/index.html +3 -1
package/dist/hub.js CHANGED
@@ -13,19 +13,26 @@ 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.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,11 +147,22 @@ 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)));
145
168
  case 'listTemplates':
@@ -148,6 +171,16 @@ export class Hub extends EventEmitter {
148
171
  return void done(Promise.resolve(this.pickTemplateFor(ask)));
149
172
  case 'spawn':
150
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
+ }
151
184
  }
152
185
  });
153
186
  // Every attached machine's view of the canvas is whatever we last told it,
@@ -195,6 +228,39 @@ export class Hub extends EventEmitter {
195
228
  return 'remote';
196
229
  return this.uplink?.agents().some((a) => a.address === address) ? 'uplink' : null;
197
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
+ }
198
264
  /**
199
265
  * Attach this hub to the canvas reachable over `uplink`.
200
266
  *
@@ -440,7 +506,21 @@ export class Hub extends EventEmitter {
440
506
  */
441
507
  async proposeTemplate(sessionId, input) {
442
508
  const me = this.requireSession(sessionId);
443
- 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);
444
524
  if (mine.length >= MAX_PENDING_PROPOSALS) {
445
525
  /*
446
526
  * Name them. The way this limit is actually reached is a human closing
@@ -461,7 +541,7 @@ export class Hub extends EventEmitter {
461
541
  const candidate = this.candidateTemplate(input);
462
542
  const proposal = {
463
543
  id: randomUUID(),
464
- fromAddr: me.address,
544
+ fromAddr,
465
545
  proposedAt: Date.now(),
466
546
  template: {
467
547
  id: candidate.id,
@@ -527,6 +607,21 @@ export class Hub extends EventEmitter {
527
607
  * as an instruction.
528
608
  */
529
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) {
530
625
  const target = this.sessions.getByAddress(address);
531
626
  if (!target)
532
627
  return;
@@ -684,7 +779,10 @@ export class Hub extends EventEmitter {
684
779
  };
685
780
  }
686
781
  async startSession(opts) {
687
- return this.startPicked(opts, this.resolveTemplate(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));
688
786
  }
689
787
  /**
690
788
  * Start a session from a template already resolved, locally or on a host.
@@ -700,7 +798,6 @@ export class Hub extends EventEmitter {
700
798
  // A workspace that belongs to a host runs its agents there. The peer owns
701
799
  // the PTY; what comes back is a session we show on our own canvas.
702
800
  if (ws.hostId) {
703
- const spawner = opts.spawnedBy ? this.sessions.get(opts.spawnedBy) : null;
704
801
  // Resolved values cross, never the template id: a template is config
705
802
  // and the two machines do not share config, so a name that means "opus,
706
803
  // high effort" here may mean nothing over there. The id travels only so
@@ -718,7 +815,7 @@ export class Hub extends EventEmitter {
718
815
  // here and the far side has never heard of it.
719
816
  env: picked.env,
720
817
  name: opts.name,
721
- spawnedByAddress: spawner?.address ?? null,
818
+ spawnedByAddress: opts.parentAddress ?? null,
722
819
  });
723
820
  }
724
821
  return this.startResolved({ ...opts, ...picked });
@@ -749,6 +846,7 @@ export class Hub extends EventEmitter {
749
846
  name: opts.name ?? opts.template ?? undefined,
750
847
  cwd: opts.cwd,
751
848
  spawnedBy: opts.spawnedBy ?? null,
849
+ parentAddress: opts.parentAddress ?? null,
752
850
  });
753
851
  /*
754
852
  * Typed in once the CLI is up rather than written into argv, where it
@@ -769,30 +867,6 @@ export class Hub extends EventEmitter {
769
867
  this.store.setOpeningPrompt(session.id, opts.restorePrompt === undefined ? (opts.prompt ?? null) : opts.restorePrompt);
770
868
  return session;
771
869
  }
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
870
  /**
797
871
  * The brief for an agent that cannot be handed one, or null.
798
872
  *
@@ -831,6 +905,29 @@ export class Hub extends EventEmitter {
831
905
  void this.deliverOpeningInstruction(session.id, brief);
832
906
  return session;
833
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
+ }
834
931
  async resumeWorkspace(workspaceId) {
835
932
  const out = [];
836
933
  for (const s of this.sessions.list()) {
@@ -939,7 +1036,9 @@ export class Hub extends EventEmitter {
939
1036
  workspace: ws?.name ?? null,
940
1037
  cwd: s.cwd,
941
1038
  profile: s.profile,
942
- 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,
943
1042
  };
944
1043
  }
945
1044
  /**
@@ -985,13 +1084,13 @@ export class Hub extends EventEmitter {
985
1084
  }));
986
1085
  }
987
1086
  /**
988
- * An attached hub's list: what the canvas has, over what this hub has.
1087
+ * An attached hub's list: the templates the canvas made, and the bare agents
1088
+ * this machine has (see overlayCanvasTemplates).
989
1089
  *
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.
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.
995
1094
  */
996
1095
  async templateViewsWithCanvas() {
997
1096
  const local = this.templateViews();
@@ -1000,7 +1099,7 @@ export class Hub extends EventEmitter {
1000
1099
  return overlayCanvasTemplates(canvas, local);
1001
1100
  }
1002
1101
  catch {
1003
- return local;
1102
+ return overlayCanvasTemplates([], local);
1004
1103
  }
1005
1104
  }
1006
1105
  /**
@@ -1259,6 +1358,11 @@ export class Hub extends EventEmitter {
1259
1358
  return this.store.getHost(ws.hostId)?.label ?? 'remote';
1260
1359
  return this.thisMachineLabel();
1261
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
+ */
1262
1366
  async listAgents(sessionId, workspace) {
1263
1367
  const me = this.requireSession(sessionId);
1264
1368
  const wsById = new Map(this.store.listWorkspaces().map((w) => [w.id, w]));
@@ -1271,6 +1375,7 @@ export class Hub extends EventEmitter {
1271
1375
  status: s.status,
1272
1376
  statusText: s.statusText,
1273
1377
  host: myLabel,
1378
+ parentAddress: s.parentAddress ?? null,
1274
1379
  isYou: s.id === me.id,
1275
1380
  }));
1276
1381
  const hostLabels = new Map(this.store.listHosts().map((h) => [h.id, h.label]));
@@ -1287,6 +1392,7 @@ export class Hub extends EventEmitter {
1287
1392
  status: s.status,
1288
1393
  statusText: s.statusText,
1289
1394
  host: (hostId ? hostLabels.get(hostId) : undefined) ?? 'remote',
1395
+ parentAddress: s.parentAddress ?? null,
1290
1396
  isYou: false,
1291
1397
  };
1292
1398
  });
@@ -1294,12 +1400,13 @@ export class Hub extends EventEmitter {
1294
1400
  // is the only complete one, so this is where an agent on an attached
1295
1401
  // machine learns that anything exists beyond this machine.
1296
1402
  const fromCanvas = (this.uplink?.agents() ?? []).map((a) => ({ ...a, isYou: false }));
1297
- 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));
1298
1404
  }
1299
1405
  async sendMessage(sessionId, to, text) {
1300
1406
  const me = this.requireSession(sessionId);
1301
1407
  if (me.address === to)
1302
1408
  throw new Error('cannot send a message to yourself');
1409
+ this.requireVisible(me, to);
1303
1410
  const result = await this.deliverFrom(me.address, to, text);
1304
1411
  // Only a successful send counts as a question asked - deliverFrom throws
1305
1412
  // on failure, so a message that never arrived does not excuse polling.
@@ -1341,6 +1448,7 @@ export class Hub extends EventEmitter {
1341
1448
  statusText: s.statusText,
1342
1449
  // What the machine that owns the canvas is called, from anywhere else.
1343
1450
  host: 'canvas',
1451
+ parentAddress: s.parentAddress ?? null,
1344
1452
  }));
1345
1453
  const elsewhere = this.peers
1346
1454
  .sessions()
@@ -1353,6 +1461,7 @@ export class Hub extends EventEmitter {
1353
1461
  status: s.status,
1354
1462
  statusText: s.statusText,
1355
1463
  host: hostLabels.get(this.peers.hostIdFor(s.address) ?? '') ?? 'remote',
1464
+ parentAddress: s.parentAddress ?? null,
1356
1465
  }));
1357
1466
  return [...here, ...elsewhere];
1358
1467
  }
@@ -1417,8 +1526,10 @@ export class Hub extends EventEmitter {
1417
1526
  *
1418
1527
  * On an attached hub the canvas is asked first, because that is where
1419
1528
  * 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.
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.
1422
1533
  */
1423
1534
  async pickSpawnTemplate(me, profile) {
1424
1535
  if (this.uplink?.attached) {
@@ -1438,6 +1549,16 @@ export class Hub extends EventEmitter {
1438
1549
  throw new Error(answer.error);
1439
1550
  if (answer?.picked)
1440
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 });
1441
1562
  }
1442
1563
  const inherited = me.template ? this.templates.get(me.template) : null;
1443
1564
  const inherit = profile ?? (inherited && !inherited.error ? me.template : me.profile);
@@ -1504,7 +1625,7 @@ export class Hub extends EventEmitter {
1504
1625
  throw new Error(`unknown workspace "${opts.workspace}"`);
1505
1626
  ws = found;
1506
1627
  }
1507
- const child = await this.startPicked({ workspaceId: ws.id, name: opts.name, spawnedBy: me.id }, { ...picked, prompt: opening, restorePrompt: picked.prompt ?? null });
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 });
1508
1629
  return {
1509
1630
  address: child.address,
1510
1631
  workspace: ws.name,
@@ -1544,11 +1665,9 @@ export class Hub extends EventEmitter {
1544
1665
  restorePrompt: ask.restore ?? null,
1545
1666
  env: ask.env,
1546
1667
  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,
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,
1552
1671
  })
1553
1672
  : await this.startResolved({
1554
1673
  workspaceId: ws.id,
@@ -1560,7 +1679,11 @@ export class Hub extends EventEmitter {
1560
1679
  restorePrompt: ask.restore ?? null,
1561
1680
  env: ask.env,
1562
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.
1563
1685
  spawnedBy: null,
1686
+ parentAddress: ask.from,
1564
1687
  });
1565
1688
  return {
1566
1689
  address: child.address,
@@ -1585,35 +1708,64 @@ export class Hub extends EventEmitter {
1585
1708
  /**
1586
1709
  * Wait for the CLI to be up, then type. Written straight away the text lands
1587
1710
  * 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
1711
+ * then stop, which is as close to "it has drawn its prompt" as this gets
1589
1712
  * without knowing the CLI.
1590
1713
  *
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.
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.
1596
1726
  */
1597
- async typeWhenReady(sessionId, text, opts = {}) {
1727
+ async typeWhenReady(sessionId, text) {
1598
1728
  const pty = this.sessions.pty(sessionId);
1599
1729
  if (!pty)
1600
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));
1601
1736
  const ready = await new Promise((res) => {
1602
1737
  let settled = false;
1738
+ let seen = false;
1739
+ let quiet = null;
1603
1740
  const done = (v) => {
1604
1741
  if (settled)
1605
1742
  return;
1606
1743
  settled = true;
1607
- clearTimeout(timer);
1744
+ clearTimeout(cap);
1745
+ if (quiet)
1746
+ clearTimeout(quiet);
1608
1747
  pty.off('data', onData);
1748
+ pty.off('exit', onExit);
1609
1749
  res(v);
1610
1750
  };
1611
- const onData = () => setTimeout(() => done(true), 1200);
1612
- const timer = setTimeout(() => done(!!opts.alreadyUp), 20_000);
1613
- 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?.();
1614
1767
  pty.on('data', onData);
1615
- if (opts.alreadyUp)
1616
- onData();
1768
+ pty.on('exit', onExit);
1617
1769
  });
1618
1770
  if (!ready || !pty.running)
1619
1771
  return;
@@ -1631,7 +1783,8 @@ export class Hub extends EventEmitter {
1631
1783
  }
1632
1784
  async readScreen(sessionId, address, lines) {
1633
1785
  const me = this.requireSession(sessionId);
1634
- const result = await this.readScreenAt(address, lines);
1786
+ this.requireVisible(me, address);
1787
+ const result = await this.readScreenAt(me.address, address, lines);
1635
1788
  // Decorating after the await, rather than counting the call up front,
1636
1789
  // means a remote or uplink target gets the note too. Counted here, not in
1637
1790
  // readScreenAt: that path also serves peers asking on this hub's behalf
@@ -1645,13 +1798,15 @@ export class Hub extends EventEmitter {
1645
1798
  * agent of its own, or a peer that authenticated one and is asking on its
1646
1799
  * behalf. Resolves the address the same three ways a delivery does.
1647
1800
  */
1648
- async readScreenAt(address, lines) {
1801
+ async readScreenAt(from, address, lines) {
1649
1802
  const where = this.locate(address);
1650
1803
  if (where === 'remote')
1651
1804
  return this.peers.readScreen(address, lines);
1652
1805
  if (where === 'uplink') {
1653
1806
  return this.uplink.ask({
1654
1807
  t: 'readScreen',
1808
+ // The canvas decides again whether the caller may look.
1809
+ from,
1655
1810
  address,
1656
1811
  lines,
1657
1812
  });
@@ -1677,9 +1832,13 @@ export class Hub extends EventEmitter {
1677
1832
  const me = this.requireSession(sessionId);
1678
1833
  const target = this.sessions.getByAddress(address);
1679
1834
  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') {
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
+ }
1683
1842
  await this.peers.stopSession(address);
1684
1843
  return { stopped: address };
1685
1844
  }
@@ -1688,8 +1847,9 @@ export class Hub extends EventEmitter {
1688
1847
  if (target.id === me.id)
1689
1848
  throw new Error('use your own exit command to stop yourself');
1690
1849
  // 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) {
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) {
1693
1853
  throw new Error(`"${address}" was not spawned by you; only its spawner may stop it`);
1694
1854
  }
1695
1855
  this.sessions.stop(target.id);