@kdonev/termscape 0.1.2 → 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.
- package/README.md +35 -4
- package/dist/agents/polling.d.ts +31 -0
- package/dist/agents/polling.d.ts.map +1 -0
- package/dist/agents/polling.js +65 -0
- package/dist/agents/polling.js.map +1 -0
- package/dist/agents/profiles.d.ts +21 -6
- package/dist/agents/profiles.d.ts.map +1 -1
- package/dist/agents/profiles.js +121 -3
- package/dist/agents/profiles.js.map +1 -1
- package/dist/agents/router.d.ts.map +1 -1
- package/dist/agents/router.js +2 -2
- package/dist/agents/router.js.map +1 -1
- package/dist/agents/shares.d.ts +32 -0
- package/dist/agents/shares.d.ts.map +1 -0
- package/dist/agents/shares.js +46 -0
- package/dist/agents/shares.js.map +1 -0
- package/dist/agents/wiring.d.ts +8 -0
- package/dist/agents/wiring.d.ts.map +1 -1
- package/dist/agents/wiring.js +72 -7
- package/dist/agents/wiring.js.map +1 -1
- package/dist/cli-args.d.ts +61 -0
- package/dist/cli-args.d.ts.map +1 -0
- package/dist/cli-args.js +35 -0
- package/dist/cli-args.js.map +1 -0
- package/dist/cli.js +10 -17
- package/dist/cli.js.map +1 -1
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/migrations.js +69 -0
- package/dist/db/migrations.js.map +1 -1
- package/dist/db/store.d.ts +21 -1
- package/dist/db/store.d.ts.map +1 -1
- package/dist/db/store.js +79 -3
- package/dist/db/store.js.map +1 -1
- package/dist/debug.d.ts +11 -0
- package/dist/debug.d.ts.map +1 -0
- package/dist/debug.js +45 -0
- package/dist/debug.js.map +1 -0
- package/dist/folders.d.ts +33 -0
- package/dist/folders.d.ts.map +1 -0
- package/dist/folders.js +88 -0
- package/dist/folders.js.map +1 -0
- package/dist/hub.d.ts +128 -3
- package/dist/hub.d.ts.map +1 -1
- package/dist/hub.js +498 -44
- package/dist/hub.js.map +1 -1
- package/dist/hub.tgz +0 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +38 -5
- package/dist/mcp/server.js.map +1 -1
- package/dist/paths.d.ts +9 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +9 -0
- package/dist/paths.js.map +1 -1
- package/dist/protocol/domain.d.ts +74 -0
- package/dist/protocol/domain.d.ts.map +1 -1
- package/dist/protocol/domain.js +58 -0
- package/dist/protocol/domain.js.map +1 -1
- package/dist/protocol/index.d.ts +1 -0
- package/dist/protocol/index.d.ts.map +1 -1
- package/dist/protocol/index.js +1 -0
- package/dist/protocol/index.js.map +1 -1
- package/dist/protocol/inject.d.ts +22 -2
- package/dist/protocol/inject.d.ts.map +1 -1
- package/dist/protocol/inject.js +24 -4
- package/dist/protocol/inject.js.map +1 -1
- package/dist/protocol/mcp-tools.d.ts +4 -0
- package/dist/protocol/mcp-tools.d.ts.map +1 -1
- package/dist/protocol/mcp-tools.js +16 -2
- package/dist/protocol/mcp-tools.js.map +1 -1
- package/dist/protocol/mouse.d.ts +54 -0
- package/dist/protocol/mouse.d.ts.map +1 -0
- package/dist/protocol/mouse.js +187 -0
- package/dist/protocol/mouse.js.map +1 -0
- package/dist/protocol/peer.d.ts +53 -1
- package/dist/protocol/peer.d.ts.map +1 -1
- package/dist/protocol/peer.js +96 -3
- package/dist/protocol/peer.js.map +1 -1
- package/dist/protocol/ws.d.ts +127 -2
- package/dist/protocol/ws.d.ts.map +1 -1
- package/dist/protocol/ws.js +83 -1
- package/dist/protocol/ws.js.map +1 -1
- package/dist/remote/deployer.d.ts.map +1 -1
- package/dist/remote/deployer.js +5 -1
- package/dist/remote/deployer.js.map +1 -1
- package/dist/remote/enroll.d.ts.map +1 -1
- package/dist/remote/enroll.js +25 -11
- package/dist/remote/enroll.js.map +1 -1
- package/dist/remote/join-script.d.ts.map +1 -1
- package/dist/remote/join-script.js +156 -31
- package/dist/remote/join-script.js.map +1 -1
- package/dist/remote/join.d.ts +2 -0
- package/dist/remote/join.d.ts.map +1 -1
- package/dist/remote/join.js +40 -1
- package/dist/remote/join.js.map +1 -1
- package/dist/remote/peer-serve.d.ts +19 -2
- package/dist/remote/peer-serve.d.ts.map +1 -1
- package/dist/remote/peer-serve.js +95 -7
- package/dist/remote/peer-serve.js.map +1 -1
- package/dist/remote/registry.d.ts +10 -0
- package/dist/remote/registry.d.ts.map +1 -1
- package/dist/remote/registry.js +31 -5
- package/dist/remote/registry.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +269 -10
- package/dist/server.js.map +1 -1
- package/dist/session/manager.d.ts +25 -0
- package/dist/session/manager.d.ts.map +1 -1
- package/dist/session/manager.js +42 -0
- package/dist/session/manager.js.map +1 -1
- package/dist/session/pty.d.ts +30 -1
- package/dist/session/pty.d.ts.map +1 -1
- package/dist/session/pty.js +98 -1
- package/dist/session/pty.js.map +1 -1
- package/package.json +1 -1
- package/web/assets/index-Z03Ta4g3.js +111 -0
- package/web/assets/index-u_uCiaPD.css +1 -0
- package/web/index.html +2 -2
- package/web/assets/index-9Z_8xZqk.js +0 -111
- package/web/assets/index-DheUm5dl.css +0 -1
package/dist/hub.js
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { encodeInjection, MAX_PENDING_PROPOSALS, parseAddress, slugify, } from './protocol/index.js';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
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';
|
|
8
7
|
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
|
-
|
|
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
|
|
115
|
-
// the only hub that knows
|
|
116
|
-
// the answer travels back the way the
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 ||
|
|
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:
|
|
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
|
-
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
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)) {
|
|
@@ -488,7 +528,7 @@ export class Hub extends EventEmitter {
|
|
|
488
528
|
return;
|
|
489
529
|
const mode = this.profiles.get(target.profile)?.inject ?? 'bracketed';
|
|
490
530
|
try {
|
|
491
|
-
this.sessions.
|
|
531
|
+
this.sessions.inject(target.id, encodeInjection(`[termscape] ${text}`, mode), INJECT_SUBMIT);
|
|
492
532
|
}
|
|
493
533
|
catch {
|
|
494
534
|
// The agent is gone. Nothing to tell and nothing to fix.
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
*
|
|
991
|
-
*
|
|
992
|
-
*
|
|
993
|
-
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
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
|
|
1345
|
+
const picked = this.resolveTemplate({ profile: inherit });
|
|
998
1346
|
const instruction = opts.prompt ? `[from ${me.address}] ${opts.prompt}` : null;
|
|
999
|
-
const opening = [
|
|
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
|
*
|
|
@@ -1055,7 +1502,7 @@ export class Hub extends EventEmitter {
|
|
|
1055
1502
|
return;
|
|
1056
1503
|
const mode = this.profiles.get(session.profile)?.inject ?? 'bracketed';
|
|
1057
1504
|
try {
|
|
1058
|
-
this.sessions.
|
|
1505
|
+
this.sessions.inject(sessionId, encodeInjection(text, mode), INJECT_SUBMIT);
|
|
1059
1506
|
}
|
|
1060
1507
|
catch {
|
|
1061
1508
|
// The agent died between the readiness check and the write; the message
|
|
@@ -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
|
-
|
|
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
|