@kdonev/termscape 0.1.3 → 0.1.4

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 (102) hide show
  1. package/README.md +32 -3
  2. package/dist/agents/polling.d.ts +31 -0
  3. package/dist/agents/polling.d.ts.map +1 -0
  4. package/dist/agents/polling.js +65 -0
  5. package/dist/agents/polling.js.map +1 -0
  6. package/dist/agents/profiles.d.ts +18 -3
  7. package/dist/agents/profiles.d.ts.map +1 -1
  8. package/dist/agents/profiles.js +86 -0
  9. package/dist/agents/profiles.js.map +1 -1
  10. package/dist/agents/shares.d.ts +32 -0
  11. package/dist/agents/shares.d.ts.map +1 -0
  12. package/dist/agents/shares.js +46 -0
  13. package/dist/agents/shares.js.map +1 -0
  14. package/dist/agents/wiring.d.ts +8 -0
  15. package/dist/agents/wiring.d.ts.map +1 -1
  16. package/dist/agents/wiring.js +61 -6
  17. package/dist/agents/wiring.js.map +1 -1
  18. package/dist/cli-args.d.ts +61 -0
  19. package/dist/cli-args.d.ts.map +1 -0
  20. package/dist/cli-args.js +35 -0
  21. package/dist/cli-args.js.map +1 -0
  22. package/dist/cli.js +10 -17
  23. package/dist/cli.js.map +1 -1
  24. package/dist/db/migrations.d.ts.map +1 -1
  25. package/dist/db/migrations.js +48 -0
  26. package/dist/db/migrations.js.map +1 -1
  27. package/dist/db/store.d.ts +14 -1
  28. package/dist/db/store.d.ts.map +1 -1
  29. package/dist/db/store.js +61 -0
  30. package/dist/db/store.js.map +1 -1
  31. package/dist/debug.d.ts +11 -0
  32. package/dist/debug.d.ts.map +1 -0
  33. package/dist/debug.js +45 -0
  34. package/dist/debug.js.map +1 -0
  35. package/dist/folders.d.ts +33 -0
  36. package/dist/folders.d.ts.map +1 -0
  37. package/dist/folders.js +88 -0
  38. package/dist/folders.js.map +1 -0
  39. package/dist/hub.d.ts +128 -3
  40. package/dist/hub.d.ts.map +1 -1
  41. package/dist/hub.js +495 -41
  42. package/dist/hub.js.map +1 -1
  43. package/dist/hub.tgz +0 -0
  44. package/dist/mcp/server.d.ts +2 -0
  45. package/dist/mcp/server.d.ts.map +1 -1
  46. package/dist/mcp/server.js +38 -5
  47. package/dist/mcp/server.js.map +1 -1
  48. package/dist/protocol/domain.d.ts +74 -0
  49. package/dist/protocol/domain.d.ts.map +1 -1
  50. package/dist/protocol/domain.js +58 -0
  51. package/dist/protocol/domain.js.map +1 -1
  52. package/dist/protocol/index.d.ts +1 -0
  53. package/dist/protocol/index.d.ts.map +1 -1
  54. package/dist/protocol/index.js +1 -0
  55. package/dist/protocol/index.js.map +1 -1
  56. package/dist/protocol/mcp-tools.d.ts +4 -0
  57. package/dist/protocol/mcp-tools.d.ts.map +1 -1
  58. package/dist/protocol/mcp-tools.js +16 -2
  59. package/dist/protocol/mcp-tools.js.map +1 -1
  60. package/dist/protocol/mouse.d.ts +54 -0
  61. package/dist/protocol/mouse.d.ts.map +1 -0
  62. package/dist/protocol/mouse.js +187 -0
  63. package/dist/protocol/mouse.js.map +1 -0
  64. package/dist/protocol/peer.d.ts +42 -1
  65. package/dist/protocol/peer.d.ts.map +1 -1
  66. package/dist/protocol/peer.js +57 -2
  67. package/dist/protocol/peer.js.map +1 -1
  68. package/dist/protocol/ws.d.ts +115 -2
  69. package/dist/protocol/ws.d.ts.map +1 -1
  70. package/dist/protocol/ws.js +71 -1
  71. package/dist/protocol/ws.js.map +1 -1
  72. package/dist/remote/deployer.d.ts.map +1 -1
  73. package/dist/remote/deployer.js +5 -1
  74. package/dist/remote/deployer.js.map +1 -1
  75. package/dist/remote/join-script.d.ts.map +1 -1
  76. package/dist/remote/join-script.js +156 -31
  77. package/dist/remote/join-script.js.map +1 -1
  78. package/dist/remote/peer-serve.d.ts +19 -2
  79. package/dist/remote/peer-serve.d.ts.map +1 -1
  80. package/dist/remote/peer-serve.js +87 -6
  81. package/dist/remote/peer-serve.js.map +1 -1
  82. package/dist/remote/registry.d.ts +10 -0
  83. package/dist/remote/registry.d.ts.map +1 -1
  84. package/dist/remote/registry.js +31 -5
  85. package/dist/remote/registry.js.map +1 -1
  86. package/dist/server.d.ts.map +1 -1
  87. package/dist/server.js +251 -7
  88. package/dist/server.js.map +1 -1
  89. package/dist/session/manager.d.ts +9 -0
  90. package/dist/session/manager.d.ts.map +1 -1
  91. package/dist/session/manager.js +14 -0
  92. package/dist/session/manager.js.map +1 -1
  93. package/dist/session/pty.d.ts +1 -1
  94. package/dist/session/pty.d.ts.map +1 -1
  95. package/dist/session/pty.js +14 -0
  96. package/dist/session/pty.js.map +1 -1
  97. package/package.json +1 -1
  98. package/web/assets/index-Z03Ta4g3.js +111 -0
  99. package/web/assets/index-u_uCiaPD.css +1 -0
  100. package/web/index.html +2 -2
  101. package/web/assets/index-CHlXyCCd.css +0 -1
  102. package/web/assets/index-DTl8am-O.js +0 -111
package/dist/hub.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { randomBytes, randomUUID } from 'node:crypto';
2
2
  import { EventEmitter } from 'node:events';
3
- import { existsSync, readFileSync } from 'node:fs';
4
- import { basename, resolve } from 'node:path';
3
+ import { readFileSync } from 'node:fs';
5
4
  import { encodeInjection, INJECT_SUBMIT, MAX_PENDING_PROPOSALS, parseAddress, slugify, } from './protocol/index.js';
6
5
  import { openDb } from './db/index.js';
7
6
  import { Store } from './db/store.js';
@@ -9,14 +8,18 @@ import { briefMode, ProfileRegistry } from './agents/profiles.js';
9
8
  import { AgentDetector } from './agents/detect.js';
10
9
  import { TemplateRegistry, validate as validateTemplate } from './agents/templates.js';
11
10
  import { TokenRegistry } from './agents/tokens.js';
11
+ import { Shares } from './agents/shares.js';
12
12
  import { MessageRouter } from './agents/router.js';
13
+ import { PollWatch } from './agents/polling.js';
13
14
  import { SessionManager } from './session/manager.js';
14
15
  import { briefFileFor } from './agents/wiring.js';
15
16
  import { PeerRegistry } from './remote/registry.js';
17
+ import { SPAWN_RELAY_TIMEOUT_MS } from './remote/peer-serve.js';
16
18
  import { deploy } from './remote/deployer.js';
17
19
  import { hubTarballPath } from './remote/tarball.js';
18
20
  import { paths } from './paths.js';
19
- export const HUB_VERSION = '0.1.3';
21
+ import { checkFolder, folderName } from './folders.js';
22
+ export const HUB_VERSION = '0.1.4';
20
23
  /**
21
24
  * How long changes are pooled before every attached machine is told the
22
25
  * canvas has moved on. A status change is a session change, and agents change
@@ -34,6 +37,16 @@ const WORKSPACE_COLORS = [
34
37
  ];
35
38
  /** How many agents one workspace may hold, bounding runaway recursive spawns. */
36
39
  export const DEFAULT_SPAWN_CAP = 12;
40
+ /**
41
+ * Floor on a note's size, applied on every write.
42
+ *
43
+ * The resize handle in the browser already clamps to this, but the write
44
+ * still goes through here: a message is a message, not a client's promise to
45
+ * have behaved, and a note collapsed to nothing is a note nobody can ever
46
+ * grab again to resize back.
47
+ */
48
+ const MIN_NOTE_W = 120;
49
+ const MIN_NOTE_H = 80;
37
50
  /**
38
51
  * The hub: owns the database, the sessions, and the routing between agents.
39
52
  * The web server and the MCP endpoint are thin layers over this.
@@ -53,6 +66,8 @@ export class Hub extends EventEmitter {
53
66
  /** What of those profiles this machine actually has installed. */
54
67
  agents;
55
68
  tokens;
69
+ /** Per-session share links (issue 14). Persisted, unlike `tokens`. */
70
+ shares;
56
71
  sessions;
57
72
  router;
58
73
  peers;
@@ -78,6 +93,8 @@ export class Hub extends EventEmitter {
78
93
  * agent honestly about whether anyone is there to decide.
79
94
  */
80
95
  viewers = 0;
96
+ /** Catches an agent watching a screen instead of waiting for a reply. */
97
+ pollWatch = new PollWatch();
81
98
  constructor(opts = {}) {
82
99
  super();
83
100
  this.db = openDb(opts.dbPath ?? paths.db());
@@ -90,6 +107,7 @@ export class Hub extends EventEmitter {
90
107
  // canvas the same way it forwards session changes.
91
108
  this.agents.on('changed', (found) => this.emit('agents', found));
92
109
  this.tokens = new TokenRegistry();
110
+ this.shares = new Shares(this.store);
93
111
  this.spawnCap = opts.spawnCap ?? DEFAULT_SPAWN_CAP;
94
112
  // Origin is corrected once the server binds and knows its port.
95
113
  this.sessions = new SessionManager(this.store, this.profiles, this.tokens, 'http://127.0.0.1:0');
@@ -111,14 +129,22 @@ export class Hub extends EventEmitter {
111
129
  // and for remote sessions the address *is* the id it was given.
112
130
  this.emit('data', address, data);
113
131
  });
114
- // A host asking us to act on an address it cannot resolve itself. We are
115
- // the only hub that knows where every address on this canvas lives, and
116
- // the answer travels back the way the question came.
117
- this.peers.on('relay', (ask, reply) => {
132
+ // A host asking us to act on something it cannot resolve itself. We are
133
+ // the only hub that knows what every machine on this canvas is and where
134
+ // every address on it lives, and the answer travels back the way the
135
+ // question came.
136
+ this.peers.on('relay', (ask, reply, fromHostId) => {
118
137
  const done = (p) => p.then((result) => reply(true, result, null), (err) => reply(false, null, err.message));
119
- if (ask.t === 'deliver')
120
- return void done(this.deliverFrom(ask.from, ask.to, ask.body));
121
- return void done(this.readScreenAt(ask.address, ask.lines));
138
+ switch (ask.t) {
139
+ case 'deliver':
140
+ return void done(this.deliverFrom(ask.from, ask.to, ask.body));
141
+ case 'readScreen':
142
+ return void done(this.readScreenAt(ask.address, ask.lines));
143
+ case 'listHosts':
144
+ return void done(Promise.resolve(this.hostsView(fromHostId)));
145
+ case 'spawn':
146
+ return void done(this.spawnFrom(ask, fromHostId));
147
+ }
122
148
  });
123
149
  // Every attached machine's view of the canvas is whatever we last told it,
124
150
  // so anything that changes who is running has to be followed by telling
@@ -179,17 +205,19 @@ export class Hub extends EventEmitter {
179
205
  }
180
206
  /* ---------------------------------------------------------- workspaces */
181
207
  createWorkspace(name, rootPath, hostId = null) {
182
- const abs = resolve(rootPath);
183
- if (!hostId && !existsSync(abs)) {
184
- throw new Error(`folder does not exist: ${abs}`);
185
- }
208
+ // A remote root belongs to the machine it names, not to this process —
209
+ // resolving and checking it here, in this platform's own flavour, is
210
+ // exactly the bug folders.ts exists to fix. It is stored verbatim
211
+ // (trimmed) and left for that machine to make sense of, the same way
212
+ // peer-serve does when it is asked to start a session there.
213
+ const path = hostId ? rootPath.trim() : checkFolder(rootPath).path;
186
214
  const existing = this.store.listWorkspaces();
187
- const wsName = uniqueWorkspaceName(slugify(name || basename(abs)), existing.map((w) => w.name));
215
+ const wsName = uniqueWorkspaceName(slugify(name || folderName(path)), existing.map((w) => w.name));
188
216
  const ws = {
189
217
  id: randomUUID(),
190
218
  name: wsName,
191
219
  kind: hostId ? 'remote' : 'local',
192
- rootPath: abs,
220
+ rootPath: path,
193
221
  hostId,
194
222
  color: WORKSPACE_COLORS[existing.length % WORKSPACE_COLORS.length],
195
223
  createdAt: Date.now(),
@@ -220,14 +248,12 @@ export class Hub extends EventEmitter {
220
248
  throw new Error(`unknown workspace ${id}`);
221
249
  const next = {};
222
250
  if (patch.rootPath !== undefined) {
223
- const abs = resolve(patch.rootPath);
224
- // Only a local folder can be checked from here; a remote one is the
225
- // other machine's to know about, exactly as it is on create.
226
- if (!ws.hostId && !existsSync(abs)) {
227
- throw new Error(`folder does not exist: ${abs}`);
228
- }
229
- if (abs !== ws.rootPath)
230
- next.rootPath = abs;
251
+ // Only a local folder can be resolved and checked from here; a remote
252
+ // one is the other machine's to know about, exactly as it is on
253
+ // create.
254
+ const path = ws.hostId ? patch.rootPath.trim() : checkFolder(patch.rootPath).path;
255
+ if (path !== ws.rootPath)
256
+ next.rootPath = path;
231
257
  }
232
258
  if (patch.name !== undefined) {
233
259
  const wanted = slugify(patch.name);
@@ -252,6 +278,20 @@ export class Hub extends EventEmitter {
252
278
  this.emit('workspace', updated);
253
279
  return updated;
254
280
  }
281
+ /**
282
+ * Resolve and check a folder on whichever machine would own the workspace,
283
+ * before that workspace exists. The Add/Edit-workspace dialog's pre-flight:
284
+ * without it, a bad remote root was never checked at all (createWorkspace
285
+ * cannot check what it cannot resolve), and a bad local one was only
286
+ * checked after the dialog had already closed. One method either way, so
287
+ * the dialog does not need to know which kind of workspace it is asking
288
+ * about.
289
+ */
290
+ async checkHostFolder(hostId, path) {
291
+ if (!hostId)
292
+ return checkFolder(path);
293
+ return this.peers.checkFolder(hostId, path);
294
+ }
255
295
  async removeWorkspace(id) {
256
296
  const ws = this.store.getWorkspace(id);
257
297
  for (const s of this.sessions.list().filter((s) => s.workspaceId === id)) {
@@ -774,6 +814,77 @@ export class Hub extends EventEmitter {
774
814
  setViewport(v) {
775
815
  this.store.saveViewport(v);
776
816
  }
817
+ /* ----------------------------------------------------------------- notes */
818
+ /**
819
+ * Write a note straight to the db, no debounce.
820
+ *
821
+ * Windows debounce their layout writes (see `session/manager.ts`), and lose
822
+ * whichever drag was in flight if the hub is killed inside that window -
823
+ * `layout.test.ts` covers it, and `notes.test.ts` proves this does not
824
+ * repeat it. `setViewport` above already writes straight through for the
825
+ * same reason.
826
+ *
827
+ * Non-finite numbers are dropped rather than clamped to some default: a NaN
828
+ * or Infinity in x/y/w/h/z means the sender computed garbage, and the
829
+ * previous good rect is a safer answer than inventing one.
830
+ */
831
+ saveNote(note) {
832
+ const finite = [note.x, note.y, note.w, note.h, note.z].every(Number.isFinite);
833
+ if (!finite)
834
+ return null;
835
+ const clamped = {
836
+ ...note,
837
+ w: Math.max(MIN_NOTE_W, note.w),
838
+ h: Math.max(MIN_NOTE_H, note.h),
839
+ };
840
+ this.store.saveNote(clamped);
841
+ return clamped;
842
+ }
843
+ removeNote(id) {
844
+ this.store.removeNote(id);
845
+ }
846
+ /* --------------------------------------------------------------- shares */
847
+ /**
848
+ * Grant a link that opens `sessionId` alone in another browser.
849
+ *
850
+ * Checked against `allSessions()` rather than trusted from the caller,
851
+ * because there is deliberately no foreign key holding the share table to
852
+ * this: a remote window's session lives on a peer, not in this database
853
+ * (see migration 9), so the only way to refuse an id that names nothing is
854
+ * to ask the same place the canvas itself would.
855
+ */
856
+ shareSession(sessionId) {
857
+ if (!this.allSessions().some((s) => s.id === sessionId)) {
858
+ throw new Error(`unknown session ${sessionId}`);
859
+ }
860
+ const token = this.shares.share(sessionId);
861
+ this.emit('shares', this.shares.list());
862
+ return token;
863
+ }
864
+ /**
865
+ * Revoke a share link. `/ws` listens for `shareRevoked` and closes every
866
+ * socket already scoped to this session - without that, "revoked" would be
867
+ * untrue for a reviewer tab that is already open, which is the one case
868
+ * revocation exists for.
869
+ */
870
+ unshareSession(sessionId) {
871
+ this.shares.unshare(sessionId);
872
+ this.emit('shares', this.shares.list());
873
+ this.emit('shareRevoked', sessionId);
874
+ }
875
+ /**
876
+ * Resolve a share token to the session it grants, re-checking against
877
+ * `allSessions()` the same way `shareSession` does at mint time. Not
878
+ * expected to ever disagree - `removeSession` and `removeRemoteWindow` both
879
+ * delete the row when a session goes - but a socket presenting a token is
880
+ * exactly the moment to distrust a row rather than the moment to assume it.
881
+ */
882
+ resolveShareToken(token) {
883
+ const sessionId = this.shares.resolve(token);
884
+ if (!sessionId)
885
+ return null;
886
+ return this.allSessions().some((s) => s.id === sessionId) ? sessionId : null;
887
+ }
777
888
  /* ------------------------------------------------- AgentApi (MCP tools) */
778
889
  requireSession(sessionId) {
779
890
  const s = this.sessions.get(sessionId);
@@ -830,9 +941,243 @@ export class Hub extends EventEmitter {
830
941
  throw new Error(`no template "${id}"`);
831
942
  return one;
832
943
  }
944
+ /**
945
+ * Every machine on the canvas, for `spawn_agent`'s `host` to name and
946
+ * `list_hosts` to report. Relayed rather than answered locally on an
947
+ * attached hub: it holds rows for nothing but itself, so only the canvas
948
+ * can say what else exists, whether it is reachable, or what it has
949
+ * installed.
950
+ */
951
+ async listHosts(sessionId) {
952
+ this.requireSession(sessionId);
953
+ if (this.uplink?.attached)
954
+ return this.uplink.ask({ t: 'listHosts' });
955
+ return this.hostsView(null);
956
+ }
957
+ /**
958
+ * Every machine on the canvas as the canvas itself sees it: the canvas
959
+ * machine (which has no host row of its own — synthesised here with the
960
+ * same empty id the web tree already gives it), then every host row, each
961
+ * with the workspaces it has and the agent CLIs it last reported.
962
+ *
963
+ * `callerHostId` marks `you`: null for the canvas hub's own agents, a host
964
+ * id for a relayed caller elsewhere on the canvas. Only ever called here,
965
+ * on the canvas — an attached hub has nothing to build this from.
966
+ */
967
+ hostsView(callerHostId) {
968
+ const remoteSessions = this.peers.sessions();
969
+ const remoteAgentCount = (workspaceName) => remoteSessions.filter((s) => s.address.startsWith(`${workspaceName}/`)).length;
970
+ const localAgentCount = (workspaceId) => this.sessions.list().filter((s) => s.workspaceId === workspaceId).length;
971
+ const agentInfo = (a) => ({
972
+ id: a.id,
973
+ available: a.available,
974
+ version: a.version,
975
+ detail: a.detail,
976
+ });
977
+ const canvasWorkspaces = this.store
978
+ .listWorkspaces()
979
+ .filter((w) => w.hostId === null)
980
+ .map((w) => ({ name: w.name, rootPath: w.rootPath, agents: localAgentCount(w.id) }));
981
+ const canvasEntry = {
982
+ id: '',
983
+ label: 'canvas',
984
+ kind: 'canvas',
985
+ state: 'connected',
986
+ you: callerHostId === null,
987
+ platform: null,
988
+ error: null,
989
+ workspaces: canvasWorkspaces,
990
+ agents: this.agents.snapshot().map(agentInfo),
991
+ };
992
+ const remoteAgentsByHost = this.peers.agentsByHost();
993
+ const hostRows = this.store.listHosts().map((h) => {
994
+ const rowed = this.store
995
+ .listWorkspaces()
996
+ .filter((w) => w.hostId === h.id)
997
+ .map((w) => ({ name: w.name, rootPath: w.rootPath, agents: remoteAgentCount(w.name) }));
998
+ const rowedNames = new Set(rowed.map((w) => w.name));
999
+ // A workspace on that machine seen only through a running agent's
1000
+ // address, with no row here: the canvas never created one for it — an
1001
+ // older client, say, or a row removed locally while the peer kept
1002
+ // running it. Listed, so an agent can see it exists, but `rootPath`
1003
+ // stays null: spawn_agent has nothing to spawn into.
1004
+ const rowlessNames = new Set(remoteSessions
1005
+ .filter((s) => this.peers.hostIdFor(s.address) === h.id)
1006
+ .map((s) => parseAddress(s.address)?.workspace)
1007
+ .filter((name) => !!name && !rowedNames.has(name)));
1008
+ const rowless = [...rowlessNames].map((name) => ({
1009
+ name,
1010
+ rootPath: null,
1011
+ agents: remoteAgentCount(name),
1012
+ }));
1013
+ return {
1014
+ id: h.id,
1015
+ label: h.label,
1016
+ kind: h.kind,
1017
+ state: h.state,
1018
+ you: callerHostId === h.id,
1019
+ platform: h.platform,
1020
+ error: h.error,
1021
+ workspaces: [...rowed, ...rowless],
1022
+ agents: (remoteAgentsByHost[h.id] ?? []).map(agentInfo),
1023
+ };
1024
+ });
1025
+ return [canvasEntry, ...hostRows];
1026
+ }
1027
+ /**
1028
+ * Whether a spawn_agent `host` names the machine this hub itself runs on:
1029
+ * 'local', 'self', or the label the canvas last told an attached hub it is
1030
+ * called. An attached hub holds no id worth matching for itself — only the
1031
+ * canvas holds a row for it — so these are the only ways it can recognise
1032
+ * itself.
1033
+ */
1034
+ namesThisMachine(host) {
1035
+ const h = host.trim().toLowerCase();
1036
+ if (h === 'local' || h === 'self')
1037
+ return true;
1038
+ const mine = this.uplink?.hostLabel();
1039
+ return !!mine && h === mine.toLowerCase();
1040
+ }
1041
+ /**
1042
+ * Turn a spawn_agent `host` (plus an optional `workspace`) into the
1043
+ * `Workspace` to start into. Shared by a local call naming a `host` and a
1044
+ * relayed one: the two must fail identically, since either can be what a
1045
+ * spawn_agent call elsewhere on the canvas turns into by the time it
1046
+ * reaches here. Only ever called on the canvas — it is the one hub these
1047
+ * rules can be evaluated on.
1048
+ */
1049
+ resolveSpawnTarget(opts) {
1050
+ const hosts = this.hostsView(opts.callerHostId);
1051
+ const wanted = opts.host.trim();
1052
+ const asSelf = wanted.toLowerCase() === 'local' || wanted.toLowerCase() === 'self';
1053
+ // Pick the machine. 'local'/'self' first, then an exact id, and only
1054
+ // then a label matched case-insensitively — in that order, so a label
1055
+ // can never shadow an id and 'local' is never read as someone's actual
1056
+ // label.
1057
+ let matches;
1058
+ if (asSelf) {
1059
+ matches = hosts.filter((h) => h.you);
1060
+ }
1061
+ else {
1062
+ const byId = hosts.filter((h) => h.id === wanted);
1063
+ matches =
1064
+ byId.length > 0 ? byId : hosts.filter((h) => h.label.toLowerCase() === wanted.toLowerCase());
1065
+ }
1066
+ if (matches.length === 0) {
1067
+ throw new Error(`unknown host "${opts.host}"; hosts are: ${hosts.map((h) => h.label).join(', ')} ` +
1068
+ '(call list_hosts)');
1069
+ }
1070
+ if (matches.length > 1) {
1071
+ throw new Error(`host "${opts.host}" names more than one machine; pass the id instead: ` +
1072
+ matches.map((h) => h.id).join(', '));
1073
+ }
1074
+ const target = matches[0];
1075
+ // Reachability. The canvas entry is always reachable; a host row is only
1076
+ // as reachable as its live connection right now.
1077
+ if (target.kind !== 'canvas' && this.peers.peer(target.id)?.connected !== true) {
1078
+ throw new Error(`host "${target.label}" is not connected; nothing can be started there right now`);
1079
+ }
1080
+ // Pick the workspace. Only rows the canvas actually holds can be spawned
1081
+ // into — a rowless one, seen only through a running agent's address,
1082
+ // is something list_hosts can show but not something this can start.
1083
+ const rowed = target.workspaces.filter((w) => w.rootPath !== null);
1084
+ let chosenName;
1085
+ if (opts.workspace) {
1086
+ const here = target.workspaces.find((w) => w.name === opts.workspace);
1087
+ if (here && here.rootPath !== null) {
1088
+ chosenName = here.name;
1089
+ }
1090
+ else if (here) {
1091
+ throw new Error(`workspace "${opts.workspace}" exists on "${target.label}" but the canvas holds no ` +
1092
+ 'folder for it; ask the human to add it from the canvas');
1093
+ }
1094
+ else {
1095
+ const elsewhere = hosts.find((h) => h.id !== target.id && h.workspaces.some((w) => w.name === opts.workspace));
1096
+ if (elsewhere) {
1097
+ throw new Error(`workspace "${opts.workspace}" is on host "${elsewhere.label}", not "${target.label}"`);
1098
+ }
1099
+ throw new Error(`no workspace "${opts.workspace}" on host "${target.label}"` +
1100
+ (rowed.length ? `; workspaces there are: ${rowed.map((w) => w.name).join(', ')}` : ''));
1101
+ }
1102
+ }
1103
+ else {
1104
+ const sameName = opts.callerWorkspaceName
1105
+ ? rowed.find((w) => w.name === opts.callerWorkspaceName)
1106
+ : undefined;
1107
+ if (sameName) {
1108
+ chosenName = sameName.name;
1109
+ }
1110
+ else if (rowed.length === 1) {
1111
+ chosenName = rowed[0].name;
1112
+ }
1113
+ else if (rowed.length === 0) {
1114
+ throw new Error(`host "${target.label}" has no workspace on the canvas; ask the human to add one there first`);
1115
+ }
1116
+ else {
1117
+ throw new Error(`host "${target.label}" has several workspaces (${rowed.map((w) => w.name).join(', ')}); ` +
1118
+ 'pass workspace: one of them');
1119
+ }
1120
+ }
1121
+ /*
1122
+ * The CLI check, last — the one thing that can be refused before an
1123
+ * irreversible spawn. An empty list is not "confirmed zero CLIs":
1124
+ * `AgentDetector.snapshot()` is documented "never async, and never
1125
+ * empty: a profile that has not been probed yet is reported with
1126
+ * `available: null`" (detect.ts:69-71), and `shell` is unconditional in
1127
+ * `BUILTIN_PROFILES` (profiles.ts:470), so a machine that has synced its
1128
+ * CLIs at all reports at least one entry, probed or not. An empty array
1129
+ * here can therefore only be the `?? []` fallback firing because no
1130
+ * `agents` frame has arrived from that host yet — not a machine that has
1131
+ * reported having none.
1132
+ */
1133
+ if (target.agents.length > 0) {
1134
+ const known = target.agents.find((a) => a.id === opts.agent);
1135
+ if (!known || known.available === false) {
1136
+ const has = target.agents.filter((a) => a.available !== false).map((a) => a.id);
1137
+ throw new Error(`host "${target.label}" cannot run "${opts.agent}"` +
1138
+ (known?.detail ? `: ${known.detail}` : '') +
1139
+ (has.length ? `; it has: ${has.join(', ')}` : ''));
1140
+ }
1141
+ }
1142
+ const hostId = target.kind === 'canvas' ? null : target.id;
1143
+ const ws = this.store
1144
+ .listWorkspaces()
1145
+ .find((w) => (w.hostId ?? null) === hostId && w.name === chosenName);
1146
+ if (!ws) {
1147
+ // Cannot happen outside a race: `chosenName` came from `hostsView`,
1148
+ // which read these same store rows a moment ago.
1149
+ throw new Error(`workspace "${chosenName}" on "${target.label}" vanished mid-request`);
1150
+ }
1151
+ return ws;
1152
+ }
1153
+ /**
1154
+ * What this hub's own machine is called: 'canvas' when it owns the canvas,
1155
+ * or the label the canvas gave an attached one — the same name list_hosts
1156
+ * and list_agents both use, so every tool agrees on what a machine is
1157
+ * called. Before the canvas's first `directory` arrives, an attached hub
1158
+ * has not been told yet; 'local' is the fallback list_agents always
1159
+ * answered with.
1160
+ */
1161
+ thisMachineLabel() {
1162
+ return this.uplink?.attached ? (this.uplink.hostLabel() ?? 'local') : 'canvas';
1163
+ }
1164
+ /**
1165
+ * What a workspace's machine is called, for a spawn reply's `host` field.
1166
+ * `ws.hostId` set means a *remote* machine as this hub's own store knows
1167
+ * it — true whether this hub is the canvas resolving a peer, or a peer
1168
+ * itself (which holds no host rows of its own, so this branch never
1169
+ * triggers for it). Null means local to whichever hub is asking, which is
1170
+ * only 'canvas' when that hub is the canvas.
1171
+ */
1172
+ hostLabelForWorkspace(ws) {
1173
+ if (ws.hostId)
1174
+ return this.store.getHost(ws.hostId)?.label ?? 'remote';
1175
+ return this.thisMachineLabel();
1176
+ }
833
1177
  async listAgents(sessionId, workspace) {
834
1178
  const me = this.requireSession(sessionId);
835
1179
  const wsById = new Map(this.store.listWorkspaces().map((w) => [w.id, w]));
1180
+ const myLabel = this.thisMachineLabel();
836
1181
  const local = this.sessions.list().map((s) => ({
837
1182
  address: s.address,
838
1183
  workspace: wsById.get(s.workspaceId)?.name ?? null,
@@ -840,7 +1185,7 @@ export class Hub extends EventEmitter {
840
1185
  state: s.state,
841
1186
  status: s.status,
842
1187
  statusText: s.statusText,
843
- host: 'local',
1188
+ host: myLabel,
844
1189
  isYou: s.id === me.id,
845
1190
  }));
846
1191
  const hostLabels = new Map(this.store.listHosts().map((h) => [h.id, h.label]));
@@ -870,7 +1215,11 @@ export class Hub extends EventEmitter {
870
1215
  const me = this.requireSession(sessionId);
871
1216
  if (me.address === to)
872
1217
  throw new Error('cannot send a message to yourself');
873
- return this.deliverFrom(me.address, to, text);
1218
+ const result = await this.deliverFrom(me.address, to, text);
1219
+ // Only a successful send counts as a question asked - deliverFrom throws
1220
+ // on failure, so a message that never arrived does not excuse polling.
1221
+ this.pollWatch.noteMessage(me.address, to);
1222
+ return result;
874
1223
  }
875
1224
  /**
876
1225
  * Tell every attached machine who is on the canvas.
@@ -973,11 +1322,6 @@ export class Hub extends EventEmitter {
973
1322
  }
974
1323
  async spawnAgent(sessionId, opts) {
975
1324
  const me = this.requireSession(sessionId);
976
- const ws = opts.workspace
977
- ? this.store.getWorkspaceByName(opts.workspace)
978
- : this.store.getWorkspace(me.workspaceId);
979
- if (!ws)
980
- throw new Error(`unknown workspace "${opts.workspace}"`);
981
1325
  // The child starts the way its parent did: from the same template, so the
982
1326
  // model and effort the template chose are not silently dropped in favour
983
1327
  // of the bare agent's defaults. A template that has been deleted or has
@@ -987,16 +1331,63 @@ export class Hub extends EventEmitter {
987
1331
  const inherited = me.template ? this.templates.get(me.template) : null;
988
1332
  const inherit = opts.profile ?? (inherited && !inherited.error ? me.template : me.profile);
989
1333
  /*
990
- * One injection, delivered once, by the hub that owns the child's PTY: the
991
- * template's opening instruction first, then what the spawning agent asked
992
- * for, attributed. Delivering them separately would race two injections on
993
- * the same ready-signal, landing back to back in undefined order, so the
994
- * opening merges here and rides to the child through startSession — which
995
- * also means the remote path needs no delivery of its own.
1334
+ * Resolved, and the opening instruction built, ahead of the workspace: a
1335
+ * `host` needs the resolved agent id before it can even ask whether that
1336
+ * machine can run it, and a relay carries only resolved values, never a
1337
+ * template name the two hubs do not share config. One injection,
1338
+ * delivered once, by whichever hub ends up owning the child's PTY: the
1339
+ * template's opening instruction first, then what the spawning agent
1340
+ * asked for, attributed. Delivering them separately would race two
1341
+ * injections on the same ready-signal, landing back to back in undefined
1342
+ * order, so the opening merges here and rides to the child however it
1343
+ * gets there.
996
1344
  */
997
- const templatePrompt = inherit ? (this.templates.get(inherit)?.prompt ?? null) : null;
1345
+ const picked = this.resolveTemplate({ profile: inherit });
998
1346
  const instruction = opts.prompt ? `[from ${me.address}] ${opts.prompt}` : null;
999
- const opening = [templatePrompt, instruction].filter(Boolean).join('\n\n') || undefined;
1347
+ const opening = [picked.prompt, instruction].filter(Boolean).join('\n\n') || undefined;
1348
+ // A `host` naming something beyond this hub's own machine has to go up
1349
+ // the link: an attached hub holds rows for nothing but itself, and only
1350
+ // the canvas can say whether the machine exists, is reachable, or can
1351
+ // run this agent.
1352
+ if (opts.host !== undefined && this.uplink?.attached && !this.namesThisMachine(opts.host)) {
1353
+ return this.uplink.ask({
1354
+ t: 'spawn',
1355
+ from: me.address,
1356
+ host: opts.host,
1357
+ workspace: opts.workspace,
1358
+ name: opts.name,
1359
+ agent: picked.agent,
1360
+ template: picked.template,
1361
+ model: picked.model,
1362
+ effort: picked.effort,
1363
+ env: picked.env,
1364
+ opening,
1365
+ promptGiven: !!opts.prompt,
1366
+ }, { timeoutMs: SPAWN_RELAY_TIMEOUT_MS });
1367
+ }
1368
+ let ws;
1369
+ if (opts.host !== undefined && !this.uplink?.attached) {
1370
+ // The canvas, resolving a `host` for real — itself included, since it
1371
+ // is the one hub these rules can even be evaluated on.
1372
+ ws = this.resolveSpawnTarget({
1373
+ host: opts.host,
1374
+ workspace: opts.workspace,
1375
+ callerWorkspaceName: this.store.getWorkspace(me.workspaceId)?.name ?? null,
1376
+ callerHostId: null,
1377
+ agent: picked.agent,
1378
+ });
1379
+ }
1380
+ else {
1381
+ // No `host`, unchanged — or an attached hub naming its own machine,
1382
+ // which needs none of resolveSpawnTarget's cross-machine knowledge,
1383
+ // because this hub does not have any.
1384
+ const found = opts.workspace
1385
+ ? this.store.getWorkspaceByName(opts.workspace)
1386
+ : this.store.getWorkspace(me.workspaceId);
1387
+ if (!found)
1388
+ throw new Error(`unknown workspace "${opts.workspace}"`);
1389
+ ws = found;
1390
+ }
1000
1391
  const child = await this.startSession({
1001
1392
  workspaceId: ws.id,
1002
1393
  profile: inherit,
@@ -1008,9 +1399,65 @@ export class Hub extends EventEmitter {
1008
1399
  address: child.address,
1009
1400
  workspace: ws.name,
1010
1401
  profile: child.profile,
1402
+ host: this.hostLabelForWorkspace(ws),
1011
1403
  promptQueued: !!opts.prompt,
1012
1404
  };
1013
1405
  }
1406
+ /**
1407
+ * Execute a spawn_agent relayed from another hub, exactly as a local call
1408
+ * naming the same `host` would: resolve the target with
1409
+ * `resolveSpawnTarget`, then land it directly rather than through
1410
+ * `startSession` — the values already came out of `resolveTemplate` on the
1411
+ * asking hub, and re-resolving `ask.agent` as a template id here could hit
1412
+ * a stored template of the same name that means something else entirely on
1413
+ * this hub. Never re-resolves a template and never re-prefixes the
1414
+ * opening, for the same reason `startSession`'s own peer request does not:
1415
+ * a template is config, and the two hubs do not share it.
1416
+ */
1417
+ async spawnFrom(ask, fromHostId) {
1418
+ const ws = this.resolveSpawnTarget({
1419
+ host: ask.host,
1420
+ workspace: ask.workspace,
1421
+ callerWorkspaceName: ask.from.split('/')[0] ?? null,
1422
+ callerHostId: fromHostId,
1423
+ agent: ask.agent,
1424
+ });
1425
+ const child = ws.hostId
1426
+ ? await this.peers.startSession(ws.hostId, {
1427
+ workspaceName: ws.name,
1428
+ rootPath: ws.rootPath,
1429
+ profile: ask.agent,
1430
+ template: ask.template ?? null,
1431
+ model: ask.model,
1432
+ effort: ask.effort,
1433
+ prompt: ask.opening,
1434
+ env: ask.env,
1435
+ 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,
1441
+ })
1442
+ : await this.startResolved({
1443
+ workspaceId: ws.id,
1444
+ agent: ask.agent,
1445
+ template: ask.template ?? null,
1446
+ model: ask.model,
1447
+ effort: ask.effort,
1448
+ prompt: ask.opening,
1449
+ env: ask.env,
1450
+ name: ask.name,
1451
+ spawnedBy: null,
1452
+ });
1453
+ return {
1454
+ address: child.address,
1455
+ workspace: ws.name,
1456
+ profile: child.profile,
1457
+ host: this.hostLabelForWorkspace(ws),
1458
+ promptQueued: ask.promptGiven,
1459
+ };
1460
+ }
1014
1461
  /**
1015
1462
  * A template's opening instruction, typed in plainly.
1016
1463
  *
@@ -1063,8 +1510,15 @@ export class Hub extends EventEmitter {
1063
1510
  }
1064
1511
  }
1065
1512
  async readScreen(sessionId, address, lines) {
1066
- this.requireSession(sessionId);
1067
- return this.readScreenAt(address, lines);
1513
+ const me = this.requireSession(sessionId);
1514
+ const result = await this.readScreenAt(address, lines);
1515
+ // Decorating after the await, rather than counting the call up front,
1516
+ // means a remote or uplink target gets the note too. Counted here, not in
1517
+ // readScreenAt: that path also serves peers asking on this hub's behalf
1518
+ // for a caller it never authenticated, and the count belongs to the
1519
+ // caller's own hub.
1520
+ const note = this.pollWatch.noteRead(me.address, address);
1521
+ return note ? { ...result, note } : result;
1068
1522
  }
1069
1523
  /**
1070
1524
  * read_screen for a caller this hub has already established — either an