@kdonev/termscape 0.1.5 → 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/README.md +17 -7
- package/dist/agents/profiles.d.ts +23 -0
- package/dist/agents/profiles.d.ts.map +1 -1
- package/dist/agents/profiles.js +48 -5
- package/dist/agents/profiles.js.map +1 -1
- package/dist/agents/templates.d.ts +18 -0
- package/dist/agents/templates.d.ts.map +1 -1
- package/dist/agents/templates.js +19 -0
- 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 -3
- package/dist/agents/wiring.js.map +1 -1
- package/dist/cli-args.d.ts +4 -0
- package/dist/cli-args.d.ts.map +1 -1
- package/dist/cli-args.js +2 -0
- package/dist/cli-args.js.map +1 -1
- package/dist/cli.js +63 -23
- package/dist/cli.js.map +1 -1
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/migrations.js +29 -0
- package/dist/db/migrations.js.map +1 -1
- package/dist/db/store.d.ts +8 -0
- package/dist/db/store.d.ts.map +1 -1
- package/dist/db/store.js +25 -4
- package/dist/db/store.js.map +1 -1
- package/dist/hub.d.ts +129 -26
- package/dist/hub.d.ts.map +1 -1
- package/dist/hub.js +331 -51
- 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 +58 -1
- package/dist/protocol/peer.d.ts.map +1 -1
- package/dist/protocol/peer.js +72 -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 +16 -3
- package/dist/remote/peer-serve.js.map +1 -1
- package/dist/remote/registry.d.ts +8 -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 -2
- 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.d.ts +2 -0
- package/dist/window/app-window.d.ts.map +1 -0
- package/dist/window/app-window.js +137 -0
- package/dist/window/app-window.js.map +1 -0
- package/dist/window/open-window.d.ts +38 -0
- package/dist/window/open-window.d.ts.map +1 -0
- package/dist/window/open-window.js +76 -0
- package/dist/window/open-window.js.map +1 -0
- package/package.json +5 -2
- package/web/assets/index-gfbbNQK4.css +1 -0
- package/web/assets/index-i7fXLKF0.js +111 -0
- package/web/icon.png +0 -0
- package/web/icon.svg +22 -0
- package/web/index.html +4 -2
- package/web/assets/index-Z03Ta4g3.js +0 -111
- package/web/assets/index-u_uCiaPD.css +0 -1
package/dist/hub.js
CHANGED
|
@@ -6,26 +6,33 @@ import { openDb } from './db/index.js';
|
|
|
6
6
|
import { Store } from './db/store.js';
|
|
7
7
|
import { briefMode, ProfileRegistry } from './agents/profiles.js';
|
|
8
8
|
import { AgentDetector } from './agents/detect.js';
|
|
9
|
-
import { TemplateRegistry, validate as validateTemplate } from './agents/templates.js';
|
|
9
|
+
import { TemplateRegistry, overlayCanvasTemplates, validate as validateTemplate, } from './agents/templates.js';
|
|
10
10
|
import { TokenRegistry } from './agents/tokens.js';
|
|
11
11
|
import { Shares } from './agents/shares.js';
|
|
12
12
|
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,15 +147,40 @@ 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)));
|
|
168
|
+
case 'listTemplates':
|
|
169
|
+
return void done(Promise.resolve(this.templateViews()));
|
|
170
|
+
case 'pickTemplate':
|
|
171
|
+
return void done(Promise.resolve(this.pickTemplateFor(ask)));
|
|
145
172
|
case 'spawn':
|
|
146
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
|
+
}
|
|
147
184
|
}
|
|
148
185
|
});
|
|
149
186
|
// Every attached machine's view of the canvas is whatever we last told it,
|
|
@@ -191,6 +228,39 @@ export class Hub extends EventEmitter {
|
|
|
191
228
|
return 'remote';
|
|
192
229
|
return this.uplink?.agents().some((a) => a.address === address) ? 'uplink' : null;
|
|
193
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
|
+
}
|
|
194
264
|
/**
|
|
195
265
|
* Attach this hub to the canvas reachable over `uplink`.
|
|
196
266
|
*
|
|
@@ -436,7 +506,21 @@ export class Hub extends EventEmitter {
|
|
|
436
506
|
*/
|
|
437
507
|
async proposeTemplate(sessionId, input) {
|
|
438
508
|
const me = this.requireSession(sessionId);
|
|
439
|
-
|
|
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);
|
|
440
524
|
if (mine.length >= MAX_PENDING_PROPOSALS) {
|
|
441
525
|
/*
|
|
442
526
|
* Name them. The way this limit is actually reached is a human closing
|
|
@@ -457,7 +541,7 @@ export class Hub extends EventEmitter {
|
|
|
457
541
|
const candidate = this.candidateTemplate(input);
|
|
458
542
|
const proposal = {
|
|
459
543
|
id: randomUUID(),
|
|
460
|
-
fromAddr
|
|
544
|
+
fromAddr,
|
|
461
545
|
proposedAt: Date.now(),
|
|
462
546
|
template: {
|
|
463
547
|
id: candidate.id,
|
|
@@ -523,6 +607,21 @@ export class Hub extends EventEmitter {
|
|
|
523
607
|
* as an instruction.
|
|
524
608
|
*/
|
|
525
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) {
|
|
526
625
|
const target = this.sessions.getByAddress(address);
|
|
527
626
|
if (!target)
|
|
528
627
|
return;
|
|
@@ -680,14 +779,25 @@ export class Hub extends EventEmitter {
|
|
|
680
779
|
};
|
|
681
780
|
}
|
|
682
781
|
async startSession(opts) {
|
|
782
|
+
const parentAddress = opts.spawnedBy
|
|
783
|
+
? (this.sessions.get(opts.spawnedBy)?.address ?? null)
|
|
784
|
+
: null;
|
|
785
|
+
return this.startPicked({ ...opts, parentAddress }, this.resolveTemplate(opts));
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Start a session from a template already resolved, locally or on a host.
|
|
789
|
+
*
|
|
790
|
+
* Split from startSession for spawn_agent on an attached hub, whose
|
|
791
|
+
* template may have been resolved by the canvas: resolving its id again
|
|
792
|
+
* here would look it up in a registry that has never heard of it.
|
|
793
|
+
*/
|
|
794
|
+
async startPicked(opts, picked) {
|
|
683
795
|
const ws = this.store.getWorkspace(opts.workspaceId);
|
|
684
796
|
if (!ws)
|
|
685
797
|
throw new Error(`unknown workspace ${opts.workspaceId}`);
|
|
686
|
-
const picked = this.resolveTemplate(opts);
|
|
687
798
|
// A workspace that belongs to a host runs its agents there. The peer owns
|
|
688
799
|
// the PTY; what comes back is a session we show on our own canvas.
|
|
689
800
|
if (ws.hostId) {
|
|
690
|
-
const spawner = opts.spawnedBy ? this.sessions.get(opts.spawnedBy) : null;
|
|
691
801
|
// Resolved values cross, never the template id: a template is config
|
|
692
802
|
// and the two machines do not share config, so a name that means "opus,
|
|
693
803
|
// high effort" here may mean nothing over there. The id travels only so
|
|
@@ -700,11 +810,12 @@ export class Hub extends EventEmitter {
|
|
|
700
810
|
model: picked.model,
|
|
701
811
|
effort: picked.effort,
|
|
702
812
|
prompt: picked.prompt,
|
|
813
|
+
restorePrompt: picked.restorePrompt,
|
|
703
814
|
// Values, like the model and the effort beside it: the template lives
|
|
704
815
|
// here and the far side has never heard of it.
|
|
705
816
|
env: picked.env,
|
|
706
817
|
name: opts.name,
|
|
707
|
-
spawnedByAddress:
|
|
818
|
+
spawnedByAddress: opts.parentAddress ?? null,
|
|
708
819
|
});
|
|
709
820
|
}
|
|
710
821
|
return this.startResolved({ ...opts, ...picked });
|
|
@@ -735,6 +846,7 @@ export class Hub extends EventEmitter {
|
|
|
735
846
|
name: opts.name ?? opts.template ?? undefined,
|
|
736
847
|
cwd: opts.cwd,
|
|
737
848
|
spawnedBy: opts.spawnedBy ?? null,
|
|
849
|
+
parentAddress: opts.parentAddress ?? null,
|
|
738
850
|
});
|
|
739
851
|
/*
|
|
740
852
|
* Typed in once the CLI is up rather than written into argv, where it
|
|
@@ -752,6 +864,7 @@ export class Hub extends EventEmitter {
|
|
|
752
864
|
.join('\n\n');
|
|
753
865
|
if (opening)
|
|
754
866
|
void this.deliverOpeningInstruction(session.id, opening);
|
|
867
|
+
this.store.setOpeningPrompt(session.id, opts.restorePrompt === undefined ? (opts.prompt ?? null) : opts.restorePrompt);
|
|
755
868
|
return session;
|
|
756
869
|
}
|
|
757
870
|
/**
|
|
@@ -792,6 +905,29 @@ export class Hub extends EventEmitter {
|
|
|
792
905
|
void this.deliverOpeningInstruction(session.id, brief);
|
|
793
906
|
return session;
|
|
794
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
|
+
}
|
|
795
931
|
async resumeWorkspace(workspaceId) {
|
|
796
932
|
const out = [];
|
|
797
933
|
for (const s of this.sessions.list()) {
|
|
@@ -900,7 +1036,9 @@ export class Hub extends EventEmitter {
|
|
|
900
1036
|
workspace: ws?.name ?? null,
|
|
901
1037
|
cwd: s.cwd,
|
|
902
1038
|
profile: s.profile,
|
|
903
|
-
|
|
1039
|
+
// By address rather than through the local id, which a parent on
|
|
1040
|
+
// another machine never had.
|
|
1041
|
+
spawnedBy: s.parentAddress ?? null,
|
|
904
1042
|
};
|
|
905
1043
|
}
|
|
906
1044
|
/**
|
|
@@ -919,7 +1057,17 @@ export class Hub extends EventEmitter {
|
|
|
919
1057
|
*/
|
|
920
1058
|
async listTemplates(sessionId, id) {
|
|
921
1059
|
this.requireSession(sessionId);
|
|
922
|
-
const shown = this.
|
|
1060
|
+
const shown = this.uplink?.attached ? await this.templateViewsWithCanvas() : this.templateViews();
|
|
1061
|
+
if (id === undefined)
|
|
1062
|
+
return { templates: shown };
|
|
1063
|
+
const one = shown.find((t) => t.id === id);
|
|
1064
|
+
if (!one)
|
|
1065
|
+
throw new Error(`no template "${id}"`);
|
|
1066
|
+
return one;
|
|
1067
|
+
}
|
|
1068
|
+
/** This hub's own templates, in the shape list_templates answers with. */
|
|
1069
|
+
templateViews() {
|
|
1070
|
+
return this.templates.info().map((t) => ({
|
|
923
1071
|
id: t.id,
|
|
924
1072
|
description: t.description,
|
|
925
1073
|
agent: t.agent,
|
|
@@ -934,12 +1082,48 @@ export class Hub extends EventEmitter {
|
|
|
934
1082
|
error: t.error,
|
|
935
1083
|
source: t.source,
|
|
936
1084
|
}));
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* An attached hub's list: the templates the canvas made, and the bare agents
|
|
1088
|
+
* this machine has (see overlayCanvasTemplates).
|
|
1089
|
+
*
|
|
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.
|
|
1094
|
+
*/
|
|
1095
|
+
async templateViewsWithCanvas() {
|
|
1096
|
+
const local = this.templateViews();
|
|
1097
|
+
try {
|
|
1098
|
+
const canvas = await this.uplink.ask({ t: 'listTemplates' });
|
|
1099
|
+
return overlayCanvasTemplates(canvas, local);
|
|
1100
|
+
}
|
|
1101
|
+
catch {
|
|
1102
|
+
return overlayCanvasTemplates([], local);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* The canvas answering an attached hub's spawn_agent: what `profile` means
|
|
1107
|
+
* here, with the caller's own template standing in when it named none.
|
|
1108
|
+
*
|
|
1109
|
+
* `picked: null` for anything the canvas did not make - a bare agent id, or
|
|
1110
|
+
* a template derived from one - which the asking hub then resolves against
|
|
1111
|
+
* its own config, since that is what it will actually launch. A template
|
|
1112
|
+
* that exists but cannot be used is an answer rather than a failed relay, so
|
|
1113
|
+
* the asking hub can tell it apart from a link that did not answer.
|
|
1114
|
+
*/
|
|
1115
|
+
pickTemplateFor(ask) {
|
|
1116
|
+
const inherited = ask.parentTemplate ? this.templates.get(ask.parentTemplate) : null;
|
|
1117
|
+
const id = ask.profile ?? (inherited && !inherited.error ? ask.parentTemplate : ask.parentProfile);
|
|
1118
|
+
const t = this.templates.get(id);
|
|
1119
|
+
if (!t || t.source === 'derived')
|
|
1120
|
+
return { picked: null };
|
|
1121
|
+
try {
|
|
1122
|
+
return { picked: this.resolveTemplate({ profile: id }) };
|
|
1123
|
+
}
|
|
1124
|
+
catch (err) {
|
|
1125
|
+
return { error: err.message };
|
|
1126
|
+
}
|
|
943
1127
|
}
|
|
944
1128
|
/**
|
|
945
1129
|
* Every machine on the canvas, for `spawn_agent`'s `host` to name and
|
|
@@ -1174,6 +1358,11 @@ export class Hub extends EventEmitter {
|
|
|
1174
1358
|
return this.store.getHost(ws.hostId)?.label ?? 'remote';
|
|
1175
1359
|
return this.thisMachineLabel();
|
|
1176
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
|
+
*/
|
|
1177
1366
|
async listAgents(sessionId, workspace) {
|
|
1178
1367
|
const me = this.requireSession(sessionId);
|
|
1179
1368
|
const wsById = new Map(this.store.listWorkspaces().map((w) => [w.id, w]));
|
|
@@ -1186,6 +1375,7 @@ export class Hub extends EventEmitter {
|
|
|
1186
1375
|
status: s.status,
|
|
1187
1376
|
statusText: s.statusText,
|
|
1188
1377
|
host: myLabel,
|
|
1378
|
+
parentAddress: s.parentAddress ?? null,
|
|
1189
1379
|
isYou: s.id === me.id,
|
|
1190
1380
|
}));
|
|
1191
1381
|
const hostLabels = new Map(this.store.listHosts().map((h) => [h.id, h.label]));
|
|
@@ -1202,6 +1392,7 @@ export class Hub extends EventEmitter {
|
|
|
1202
1392
|
status: s.status,
|
|
1203
1393
|
statusText: s.statusText,
|
|
1204
1394
|
host: (hostId ? hostLabels.get(hostId) : undefined) ?? 'remote',
|
|
1395
|
+
parentAddress: s.parentAddress ?? null,
|
|
1205
1396
|
isYou: false,
|
|
1206
1397
|
};
|
|
1207
1398
|
});
|
|
@@ -1209,12 +1400,13 @@ export class Hub extends EventEmitter {
|
|
|
1209
1400
|
// is the only complete one, so this is where an agent on an attached
|
|
1210
1401
|
// machine learns that anything exists beyond this machine.
|
|
1211
1402
|
const fromCanvas = (this.uplink?.agents() ?? []).map((a) => ({ ...a, isYou: false }));
|
|
1212
|
-
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));
|
|
1213
1404
|
}
|
|
1214
1405
|
async sendMessage(sessionId, to, text) {
|
|
1215
1406
|
const me = this.requireSession(sessionId);
|
|
1216
1407
|
if (me.address === to)
|
|
1217
1408
|
throw new Error('cannot send a message to yourself');
|
|
1409
|
+
this.requireVisible(me, to);
|
|
1218
1410
|
const result = await this.deliverFrom(me.address, to, text);
|
|
1219
1411
|
// Only a successful send counts as a question asked - deliverFrom throws
|
|
1220
1412
|
// on failure, so a message that never arrived does not excuse polling.
|
|
@@ -1256,6 +1448,7 @@ export class Hub extends EventEmitter {
|
|
|
1256
1448
|
statusText: s.statusText,
|
|
1257
1449
|
// What the machine that owns the canvas is called, from anywhere else.
|
|
1258
1450
|
host: 'canvas',
|
|
1451
|
+
parentAddress: s.parentAddress ?? null,
|
|
1259
1452
|
}));
|
|
1260
1453
|
const elsewhere = this.peers
|
|
1261
1454
|
.sessions()
|
|
@@ -1268,6 +1461,7 @@ export class Hub extends EventEmitter {
|
|
|
1268
1461
|
status: s.status,
|
|
1269
1462
|
statusText: s.statusText,
|
|
1270
1463
|
host: hostLabels.get(this.peers.hostIdFor(s.address) ?? '') ?? 'remote',
|
|
1464
|
+
parentAddress: s.parentAddress ?? null,
|
|
1271
1465
|
}));
|
|
1272
1466
|
return [...here, ...elsewhere];
|
|
1273
1467
|
}
|
|
@@ -1320,16 +1514,58 @@ export class Hub extends EventEmitter {
|
|
|
1320
1514
|
throw err;
|
|
1321
1515
|
}
|
|
1322
1516
|
}
|
|
1517
|
+
/**
|
|
1518
|
+
* What a spawn_agent `profile` resolves to for this caller.
|
|
1519
|
+
*
|
|
1520
|
+
* The child starts the way its parent did: from the same template, so the
|
|
1521
|
+
* model and effort the template chose are not silently dropped in favour
|
|
1522
|
+
* of the bare agent's defaults. A template that has been deleted or has
|
|
1523
|
+
* since failed to load falls back to the agent itself — a spawn must not
|
|
1524
|
+
* die because config changed under a running parent; a profile the
|
|
1525
|
+
* spawning agent named explicitly always wins.
|
|
1526
|
+
*
|
|
1527
|
+
* On an attached hub the canvas is asked first, because that is where
|
|
1528
|
+
* templates are made (see templateViewsWithCanvas). Only a template the
|
|
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.
|
|
1533
|
+
*/
|
|
1534
|
+
async pickSpawnTemplate(me, profile) {
|
|
1535
|
+
if (this.uplink?.attached) {
|
|
1536
|
+
let answer = null;
|
|
1537
|
+
try {
|
|
1538
|
+
answer = await this.uplink.ask({
|
|
1539
|
+
t: 'pickTemplate',
|
|
1540
|
+
profile,
|
|
1541
|
+
parentTemplate: me.template,
|
|
1542
|
+
parentProfile: me.profile,
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
catch {
|
|
1546
|
+
// The link, not the template: resolve here as if there were none.
|
|
1547
|
+
}
|
|
1548
|
+
if (answer && 'error' in answer)
|
|
1549
|
+
throw new Error(answer.error);
|
|
1550
|
+
if (answer?.picked)
|
|
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 });
|
|
1562
|
+
}
|
|
1563
|
+
const inherited = me.template ? this.templates.get(me.template) : null;
|
|
1564
|
+
const inherit = profile ?? (inherited && !inherited.error ? me.template : me.profile);
|
|
1565
|
+
return this.resolveTemplate({ profile: inherit });
|
|
1566
|
+
}
|
|
1323
1567
|
async spawnAgent(sessionId, opts) {
|
|
1324
1568
|
const me = this.requireSession(sessionId);
|
|
1325
|
-
// The child starts the way its parent did: from the same template, so the
|
|
1326
|
-
// model and effort the template chose are not silently dropped in favour
|
|
1327
|
-
// of the bare agent's defaults. A template that has been deleted or has
|
|
1328
|
-
// since failed to load falls back to the agent itself — a spawn must not
|
|
1329
|
-
// die because config changed under a running parent; a profile the
|
|
1330
|
-
// spawning agent named explicitly always wins.
|
|
1331
|
-
const inherited = me.template ? this.templates.get(me.template) : null;
|
|
1332
|
-
const inherit = opts.profile ?? (inherited && !inherited.error ? me.template : me.profile);
|
|
1333
1569
|
/*
|
|
1334
1570
|
* Resolved, and the opening instruction built, ahead of the workspace: a
|
|
1335
1571
|
* `host` needs the resolved agent id before it can even ask whether that
|
|
@@ -1342,7 +1578,7 @@ export class Hub extends EventEmitter {
|
|
|
1342
1578
|
* order, so the opening merges here and rides to the child however it
|
|
1343
1579
|
* gets there.
|
|
1344
1580
|
*/
|
|
1345
|
-
const picked = this.
|
|
1581
|
+
const picked = await this.pickSpawnTemplate(me, opts.profile);
|
|
1346
1582
|
const instruction = opts.prompt ? `[from ${me.address}] ${opts.prompt}` : null;
|
|
1347
1583
|
const opening = [picked.prompt, instruction].filter(Boolean).join('\n\n') || undefined;
|
|
1348
1584
|
// A `host` naming something beyond this hub's own machine has to go up
|
|
@@ -1362,6 +1598,7 @@ export class Hub extends EventEmitter {
|
|
|
1362
1598
|
effort: picked.effort,
|
|
1363
1599
|
env: picked.env,
|
|
1364
1600
|
opening,
|
|
1601
|
+
restore: picked.prompt ?? null,
|
|
1365
1602
|
promptGiven: !!opts.prompt,
|
|
1366
1603
|
}, { timeoutMs: SPAWN_RELAY_TIMEOUT_MS });
|
|
1367
1604
|
}
|
|
@@ -1388,13 +1625,7 @@ export class Hub extends EventEmitter {
|
|
|
1388
1625
|
throw new Error(`unknown workspace "${opts.workspace}"`);
|
|
1389
1626
|
ws = found;
|
|
1390
1627
|
}
|
|
1391
|
-
const child = await this.
|
|
1392
|
-
workspaceId: ws.id,
|
|
1393
|
-
profile: inherit,
|
|
1394
|
-
name: opts.name,
|
|
1395
|
-
spawnedBy: me.id,
|
|
1396
|
-
prompt: opening,
|
|
1397
|
-
});
|
|
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 });
|
|
1398
1629
|
return {
|
|
1399
1630
|
address: child.address,
|
|
1400
1631
|
workspace: ws.name,
|
|
@@ -1431,13 +1662,12 @@ export class Hub extends EventEmitter {
|
|
|
1431
1662
|
model: ask.model,
|
|
1432
1663
|
effort: ask.effort,
|
|
1433
1664
|
prompt: ask.opening,
|
|
1665
|
+
restorePrompt: ask.restore ?? null,
|
|
1434
1666
|
env: ask.env,
|
|
1435
1667
|
name: ask.name,
|
|
1436
|
-
//
|
|
1437
|
-
//
|
|
1438
|
-
|
|
1439
|
-
// table's foreign key would reject it.
|
|
1440
|
-
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,
|
|
1441
1671
|
})
|
|
1442
1672
|
: await this.startResolved({
|
|
1443
1673
|
workspaceId: ws.id,
|
|
@@ -1446,9 +1676,14 @@ export class Hub extends EventEmitter {
|
|
|
1446
1676
|
model: ask.model,
|
|
1447
1677
|
effort: ask.effort,
|
|
1448
1678
|
prompt: ask.opening,
|
|
1679
|
+
restorePrompt: ask.restore ?? null,
|
|
1449
1680
|
env: ask.env,
|
|
1450
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.
|
|
1451
1685
|
spawnedBy: null,
|
|
1686
|
+
parentAddress: ask.from,
|
|
1452
1687
|
});
|
|
1453
1688
|
return {
|
|
1454
1689
|
address: child.address,
|
|
@@ -1473,27 +1708,64 @@ export class Hub extends EventEmitter {
|
|
|
1473
1708
|
/**
|
|
1474
1709
|
* Wait for the CLI to be up, then type. Written straight away the text lands
|
|
1475
1710
|
* before the program is reading it; the wait is for output to arrive and
|
|
1476
|
-
* then
|
|
1711
|
+
* then stop, which is as close to "it has drawn its prompt" as this gets
|
|
1477
1712
|
* without knowing the CLI.
|
|
1713
|
+
*
|
|
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.
|
|
1478
1726
|
*/
|
|
1479
1727
|
async typeWhenReady(sessionId, text) {
|
|
1480
1728
|
const pty = this.sessions.pty(sessionId);
|
|
1481
1729
|
if (!pty)
|
|
1482
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));
|
|
1483
1736
|
const ready = await new Promise((res) => {
|
|
1484
1737
|
let settled = false;
|
|
1738
|
+
let seen = false;
|
|
1739
|
+
let quiet = null;
|
|
1485
1740
|
const done = (v) => {
|
|
1486
1741
|
if (settled)
|
|
1487
1742
|
return;
|
|
1488
1743
|
settled = true;
|
|
1489
|
-
clearTimeout(
|
|
1744
|
+
clearTimeout(cap);
|
|
1745
|
+
if (quiet)
|
|
1746
|
+
clearTimeout(quiet);
|
|
1490
1747
|
pty.off('data', onData);
|
|
1748
|
+
pty.off('exit', onExit);
|
|
1491
1749
|
res(v);
|
|
1492
1750
|
};
|
|
1493
|
-
const onData = () =>
|
|
1494
|
-
|
|
1495
|
-
|
|
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?.();
|
|
1496
1767
|
pty.on('data', onData);
|
|
1768
|
+
pty.on('exit', onExit);
|
|
1497
1769
|
});
|
|
1498
1770
|
if (!ready || !pty.running)
|
|
1499
1771
|
return;
|
|
@@ -1511,7 +1783,8 @@ export class Hub extends EventEmitter {
|
|
|
1511
1783
|
}
|
|
1512
1784
|
async readScreen(sessionId, address, lines) {
|
|
1513
1785
|
const me = this.requireSession(sessionId);
|
|
1514
|
-
|
|
1786
|
+
this.requireVisible(me, address);
|
|
1787
|
+
const result = await this.readScreenAt(me.address, address, lines);
|
|
1515
1788
|
// Decorating after the await, rather than counting the call up front,
|
|
1516
1789
|
// means a remote or uplink target gets the note too. Counted here, not in
|
|
1517
1790
|
// readScreenAt: that path also serves peers asking on this hub's behalf
|
|
@@ -1525,13 +1798,15 @@ export class Hub extends EventEmitter {
|
|
|
1525
1798
|
* agent of its own, or a peer that authenticated one and is asking on its
|
|
1526
1799
|
* behalf. Resolves the address the same three ways a delivery does.
|
|
1527
1800
|
*/
|
|
1528
|
-
async readScreenAt(address, lines) {
|
|
1801
|
+
async readScreenAt(from, address, lines) {
|
|
1529
1802
|
const where = this.locate(address);
|
|
1530
1803
|
if (where === 'remote')
|
|
1531
1804
|
return this.peers.readScreen(address, lines);
|
|
1532
1805
|
if (where === 'uplink') {
|
|
1533
1806
|
return this.uplink.ask({
|
|
1534
1807
|
t: 'readScreen',
|
|
1808
|
+
// The canvas decides again whether the caller may look.
|
|
1809
|
+
from,
|
|
1535
1810
|
address,
|
|
1536
1811
|
lines,
|
|
1537
1812
|
});
|
|
@@ -1557,9 +1832,13 @@ export class Hub extends EventEmitter {
|
|
|
1557
1832
|
const me = this.requireSession(sessionId);
|
|
1558
1833
|
const target = this.sessions.getByAddress(address);
|
|
1559
1834
|
if (!target) {
|
|
1560
|
-
//
|
|
1561
|
-
//
|
|
1562
|
-
|
|
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
|
+
}
|
|
1563
1842
|
await this.peers.stopSession(address);
|
|
1564
1843
|
return { stopped: address };
|
|
1565
1844
|
}
|
|
@@ -1568,8 +1847,9 @@ export class Hub extends EventEmitter {
|
|
|
1568
1847
|
if (target.id === me.id)
|
|
1569
1848
|
throw new Error('use your own exit command to stop yourself');
|
|
1570
1849
|
// An agent may only stop what it created. Otherwise a single confused
|
|
1571
|
-
// agent could take down the whole canvas.
|
|
1572
|
-
|
|
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) {
|
|
1573
1853
|
throw new Error(`"${address}" was not spawned by you; only its spawner may stop it`);
|
|
1574
1854
|
}
|
|
1575
1855
|
this.sessions.stop(target.id);
|