@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.
- package/dist/agents/profiles.d.ts +7 -0
- package/dist/agents/profiles.d.ts.map +1 -1
- package/dist/agents/profiles.js +5 -0
- package/dist/agents/profiles.js.map +1 -1
- package/dist/agents/templates.d.ts +11 -8
- package/dist/agents/templates.d.ts.map +1 -1
- package/dist/agents/templates.js +12 -9
- package/dist/agents/templates.js.map +1 -1
- package/dist/agents/visibility.d.ts +22 -0
- package/dist/agents/visibility.d.ts.map +1 -0
- package/dist/agents/visibility.js +20 -0
- package/dist/agents/visibility.js.map +1 -0
- package/dist/agents/wiring.d.ts +1 -1
- package/dist/agents/wiring.d.ts.map +1 -1
- package/dist/agents/wiring.js +12 -10
- package/dist/agents/wiring.js.map +1 -1
- package/dist/cli.js +25 -7
- package/dist/cli.js.map +1 -1
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/migrations.js +17 -0
- package/dist/db/migrations.js.map +1 -1
- package/dist/db/store.d.ts +1 -0
- package/dist/db/store.d.ts.map +1 -1
- package/dist/db/store.js +9 -4
- package/dist/db/store.js.map +1 -1
- package/dist/hub.d.ts +73 -30
- package/dist/hub.d.ts.map +1 -1
- package/dist/hub.js +229 -69
- package/dist/hub.js.map +1 -1
- package/dist/hub.tgz +0 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +6 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/protocol/domain.d.ts +1 -0
- package/dist/protocol/domain.d.ts.map +1 -1
- package/dist/protocol/domain.js +12 -0
- package/dist/protocol/domain.js.map +1 -1
- package/dist/protocol/mcp-tools.js +1 -1
- package/dist/protocol/mcp-tools.js.map +1 -1
- package/dist/protocol/peer.d.ts +41 -1
- package/dist/protocol/peer.d.ts.map +1 -1
- package/dist/protocol/peer.js +42 -1
- package/dist/protocol/peer.js.map +1 -1
- package/dist/protocol/ws.d.ts +6 -0
- package/dist/protocol/ws.d.ts.map +1 -1
- package/dist/protocol/ws.js +2 -0
- package/dist/protocol/ws.js.map +1 -1
- package/dist/remote/peer-serve.d.ts.map +1 -1
- package/dist/remote/peer-serve.js +15 -3
- package/dist/remote/peer-serve.js.map +1 -1
- package/dist/remote/registry.d.ts +7 -0
- package/dist/remote/registry.d.ts.map +1 -1
- package/dist/remote/registry.js +15 -0
- package/dist/remote/registry.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +13 -7
- package/dist/server.js.map +1 -1
- package/dist/session/manager.d.ts +22 -2
- package/dist/session/manager.d.ts.map +1 -1
- package/dist/session/manager.js +52 -9
- 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 +77 -1
- package/dist/session/pty.js.map +1 -1
- package/dist/window/app-window.js +20 -0
- package/dist/window/app-window.js.map +1 -1
- package/package.json +1 -1
- package/web/assets/{index-BgOaaAFO.js → index-i7fXLKF0.js} +2 -2
- package/web/icon.png +0 -0
- package/web/icon.svg +22 -0
- 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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
991
|
-
*
|
|
992
|
-
*
|
|
993
|
-
*
|
|
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
|
|
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
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
|
|
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
|
|
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
|
-
*
|
|
1592
|
-
*
|
|
1593
|
-
* the
|
|
1594
|
-
* the
|
|
1595
|
-
*
|
|
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
|
|
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(
|
|
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 = () =>
|
|
1612
|
-
|
|
1613
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1681
|
-
//
|
|
1682
|
-
|
|
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
|
-
|
|
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);
|