@kdonev/termscape 0.1.6 → 0.1.8
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/router.d.ts +12 -6
- package/dist/agents/router.d.ts.map +1 -1
- package/dist/agents/router.js +12 -6
- package/dist/agents/router.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 +111 -30
- package/dist/hub.d.ts.map +1 -1
- package/dist/hub.js +339 -90
- 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 +23 -3
- package/dist/session/manager.d.ts.map +1 -1
- package/dist/session/manager.js +57 -10
- package/dist/session/manager.js.map +1 -1
- package/dist/session/pty.d.ts +35 -2
- package/dist/session/pty.d.ts.map +1 -1
- package/dist/session/pty.js +110 -17
- 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,37 @@ 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.8';
|
|
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;
|
|
36
|
+
/**
|
|
37
|
+
* How long a delivery waits for a starting agent before going in regardless.
|
|
38
|
+
*
|
|
39
|
+
* There has to be a bound. A session's readiness gate deliberately outlasts
|
|
40
|
+
* its own cap while a question is on screen - the instruction is still wanted
|
|
41
|
+
* once the human answers - but a `send_message` that blocks its caller until
|
|
42
|
+
* somebody walks over to a window is worse than a message that arrives badly.
|
|
43
|
+
* So the wait is bounded here, and past it delivery is what it always was:
|
|
44
|
+
* immediate, recorded, and the CLI's business.
|
|
45
|
+
*/
|
|
46
|
+
const DELIVERY_WAIT_CAP_MS = 20_000;
|
|
29
47
|
/** Colours cycled through when a workspace is created, for canvas grouping. */
|
|
30
48
|
const WORKSPACE_COLORS = [
|
|
31
49
|
'#7c9cf5',
|
|
@@ -77,6 +95,16 @@ export class Hub extends EventEmitter {
|
|
|
77
95
|
* two roles a null check rather than a flag.
|
|
78
96
|
*/
|
|
79
97
|
uplink = null;
|
|
98
|
+
/**
|
|
99
|
+
* Per session: resolves once its CLI is reading and its opening instruction,
|
|
100
|
+
* if it had one, has been typed and sent.
|
|
101
|
+
*
|
|
102
|
+
* Every delivery to a local session queues behind this. Without it a message
|
|
103
|
+
* sent to an agent that was still starting - which is exactly what an agent
|
|
104
|
+
* that has just called `spawn_agent` does next - was written into a program
|
|
105
|
+
* not yet reading, and sat in the composer unsent forever (issue 27).
|
|
106
|
+
*/
|
|
107
|
+
opened = new Map();
|
|
80
108
|
announceTimer = null;
|
|
81
109
|
/** Live SSH tunnels, keyed by host id. Not persisted: they die with the hub. */
|
|
82
110
|
tunnels = new Map();
|
|
@@ -113,12 +141,21 @@ export class Hub extends EventEmitter {
|
|
|
113
141
|
this.sessions = new SessionManager(this.store, this.profiles, this.tokens, 'http://127.0.0.1:0');
|
|
114
142
|
this.router = new MessageRouter(this.store, this.sessions, this.profiles, (m) => this.emit('message', m));
|
|
115
143
|
this.sessions.on('session', (s) => this.emit('session', s));
|
|
116
|
-
this.sessions.on('removed', (id, address) =>
|
|
144
|
+
this.sessions.on('removed', (id, address) => {
|
|
145
|
+
// Nothing left to open, and nothing left to hold a delivery for.
|
|
146
|
+
this.opened.delete(id);
|
|
147
|
+
this.emit('removed', id, address);
|
|
148
|
+
});
|
|
117
149
|
this.sessions.on('data', (id, chunk) => this.emit('data', id, chunk));
|
|
118
150
|
this.peers = new PeerRegistry(this.store, HUB_VERSION, (address) => this.sessions.getByAddress(address)?.id ?? null);
|
|
119
151
|
// Peer windows share the canvas, so a new local window must not land on
|
|
120
152
|
// one. The manager cannot see the registry; this is the view it needs.
|
|
121
153
|
this.sessions.remoteWindows = () => this.peers.sessions().map((s) => s.window);
|
|
154
|
+
// A brief lists who its agent can see, and that is decided across every
|
|
155
|
+
// machine by lineage - which only the hub has the view for.
|
|
156
|
+
this.sessions.visiblePeers = (s) => this.everyAgent()
|
|
157
|
+
.filter((o) => o.address !== s.address && canSee(s, o))
|
|
158
|
+
.map((o) => o.address);
|
|
122
159
|
this.peers.on('host', (h) => this.emit('host', h));
|
|
123
160
|
this.peers.on('peerAgents', (hostId, found) => this.emit('hostAgents', hostId, found));
|
|
124
161
|
this.peers.on('peerSession', (s) => this.emit('session', s));
|
|
@@ -135,11 +172,22 @@ export class Hub extends EventEmitter {
|
|
|
135
172
|
// question came.
|
|
136
173
|
this.peers.on('relay', (ask, reply, fromHostId) => {
|
|
137
174
|
const done = (p) => p.then((result) => reply(true, result, null), (err) => reply(false, null, err.message));
|
|
175
|
+
/*
|
|
176
|
+
* The canvas is authoritative about who may see whom: the asking hub
|
|
177
|
+
* checked already, but from a directory that may be a moment stale,
|
|
178
|
+
* and a host that did not check at all must not be a way round it.
|
|
179
|
+
*/
|
|
138
180
|
switch (ask.t) {
|
|
139
181
|
case 'deliver':
|
|
140
|
-
return void done(
|
|
182
|
+
return void done((async () => {
|
|
183
|
+
this.requireVisible(this.lineageOf(ask.from), ask.to);
|
|
184
|
+
return this.deliverFrom(ask.from, ask.to, ask.body);
|
|
185
|
+
})());
|
|
141
186
|
case 'readScreen':
|
|
142
|
-
return void done(
|
|
187
|
+
return void done((async () => {
|
|
188
|
+
this.requireVisible(this.lineageOf(ask.from), ask.address);
|
|
189
|
+
return this.readScreenAt(ask.from, ask.address, ask.lines);
|
|
190
|
+
})());
|
|
143
191
|
case 'listHosts':
|
|
144
192
|
return void done(Promise.resolve(this.hostsView(fromHostId)));
|
|
145
193
|
case 'listTemplates':
|
|
@@ -148,6 +196,16 @@ export class Hub extends EventEmitter {
|
|
|
148
196
|
return void done(Promise.resolve(this.pickTemplateFor(ask)));
|
|
149
197
|
case 'spawn':
|
|
150
198
|
return void done(this.spawnFrom(ask, fromHostId));
|
|
199
|
+
case 'proposeTemplate': {
|
|
200
|
+
// `from` is only trusted as far as the host that sent it: an
|
|
201
|
+
// agent on one machine must not be able to propose as, and have
|
|
202
|
+
// the answer typed into, an agent on another.
|
|
203
|
+
if (this.peers.hostIdFor(ask.from) !== fromHostId) {
|
|
204
|
+
return void done(Promise.reject(new Error(`no agent at address "${ask.from}"`)));
|
|
205
|
+
}
|
|
206
|
+
const { t: _t, from, ...input } = ask;
|
|
207
|
+
return void done(this.proposeTemplateFrom(from, input));
|
|
208
|
+
}
|
|
151
209
|
}
|
|
152
210
|
});
|
|
153
211
|
// Every attached machine's view of the canvas is whatever we last told it,
|
|
@@ -195,6 +253,39 @@ export class Hub extends EventEmitter {
|
|
|
195
253
|
return 'remote';
|
|
196
254
|
return this.uplink?.agents().some((a) => a.address === address) ? 'uplink' : null;
|
|
197
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Every agent this hub knows of, on any machine, with its lineage: its own,
|
|
258
|
+
* those on machines it holds, and those the canvas told it about. The three
|
|
259
|
+
* are exclusive for the reason `locate` gives.
|
|
260
|
+
*/
|
|
261
|
+
everyAgent() {
|
|
262
|
+
return [
|
|
263
|
+
...this.sessions.list(),
|
|
264
|
+
...this.peers.sessions(),
|
|
265
|
+
...(this.uplink?.agents() ?? []),
|
|
266
|
+
].map((a) => ({ address: a.address, parentAddress: a.parentAddress ?? null }));
|
|
267
|
+
}
|
|
268
|
+
/** One address's lineage, from the same view, or null for one nobody here knows. */
|
|
269
|
+
lineageOf(address) {
|
|
270
|
+
const local = this.sessions.getByAddress(address);
|
|
271
|
+
if (local)
|
|
272
|
+
return { address, parentAddress: local.parentAddress ?? null };
|
|
273
|
+
const found = this.peers.find(address)?.session ??
|
|
274
|
+
this.uplink?.agents().find((a) => a.address === address);
|
|
275
|
+
return found ? { address, parentAddress: found.parentAddress ?? null } : null;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Refuse to act on an address the viewer may not see, in exactly the words
|
|
279
|
+
* used for one that does not exist: a hidden agent must not be discoverable
|
|
280
|
+
* by probing for the difference. An address nobody knows is let through, so
|
|
281
|
+
* the delivery path can record the failed attempt as it always has.
|
|
282
|
+
*/
|
|
283
|
+
requireVisible(viewer, address) {
|
|
284
|
+
const target = this.lineageOf(address);
|
|
285
|
+
if (target && (!viewer || !canSee(viewer, target))) {
|
|
286
|
+
throw new Error(`no agent at address "${address}"`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
198
289
|
/**
|
|
199
290
|
* Attach this hub to the canvas reachable over `uplink`.
|
|
200
291
|
*
|
|
@@ -440,7 +531,21 @@ export class Hub extends EventEmitter {
|
|
|
440
531
|
*/
|
|
441
532
|
async proposeTemplate(sessionId, input) {
|
|
442
533
|
const me = this.requireSession(sessionId);
|
|
443
|
-
|
|
534
|
+
// Templates are central (issue 22): the canvas is where they live and
|
|
535
|
+
// where the human who answers is, so an attached hub hands the proposal
|
|
536
|
+
// up. No local fallback when that fails - a proposal kept here would wait
|
|
537
|
+
// for a dialog nobody is ever shown, which is the bug this fixes.
|
|
538
|
+
if (this.uplink?.attached) {
|
|
539
|
+
return this.uplink.ask({ t: 'proposeTemplate', from: me.address, ...input });
|
|
540
|
+
}
|
|
541
|
+
return this.proposeTemplateFrom(me.address, input);
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* A proposal from an agent already established, here or on an attached
|
|
545
|
+
* machine - the canvas's half of proposeTemplate.
|
|
546
|
+
*/
|
|
547
|
+
async proposeTemplateFrom(fromAddr, input) {
|
|
548
|
+
const mine = [...this.proposals.values()].filter((p) => p.fromAddr === fromAddr);
|
|
444
549
|
if (mine.length >= MAX_PENDING_PROPOSALS) {
|
|
445
550
|
/*
|
|
446
551
|
* Name them. The way this limit is actually reached is a human closing
|
|
@@ -461,7 +566,7 @@ export class Hub extends EventEmitter {
|
|
|
461
566
|
const candidate = this.candidateTemplate(input);
|
|
462
567
|
const proposal = {
|
|
463
568
|
id: randomUUID(),
|
|
464
|
-
fromAddr
|
|
569
|
+
fromAddr,
|
|
465
570
|
proposedAt: Date.now(),
|
|
466
571
|
template: {
|
|
467
572
|
id: candidate.id,
|
|
@@ -527,6 +632,21 @@ export class Hub extends EventEmitter {
|
|
|
527
632
|
* as an instruction.
|
|
528
633
|
*/
|
|
529
634
|
tellAgent(address, text) {
|
|
635
|
+
if (!this.sessions.getByAddress(address)) {
|
|
636
|
+
// An agent on an attached machine proposed it, and only that machine
|
|
637
|
+
// owns its terminal. Best effort, like the local case below.
|
|
638
|
+
if (this.peers.hostIdFor(address))
|
|
639
|
+
void this.peers.notify(address, text).catch(() => { });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
this.notifyAgent(address, text);
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* tellAgent's local half, and what an attached hub runs when the canvas
|
|
646
|
+
* sends it a notice for one of its agents. The one place the `[termscape]`
|
|
647
|
+
* prefix is applied, so a notice reads the same from any machine.
|
|
648
|
+
*/
|
|
649
|
+
notifyAgent(address, text) {
|
|
530
650
|
const target = this.sessions.getByAddress(address);
|
|
531
651
|
if (!target)
|
|
532
652
|
return;
|
|
@@ -684,7 +804,10 @@ export class Hub extends EventEmitter {
|
|
|
684
804
|
};
|
|
685
805
|
}
|
|
686
806
|
async startSession(opts) {
|
|
687
|
-
|
|
807
|
+
const parentAddress = opts.spawnedBy
|
|
808
|
+
? (this.sessions.get(opts.spawnedBy)?.address ?? null)
|
|
809
|
+
: null;
|
|
810
|
+
return this.startPicked({ ...opts, parentAddress }, this.resolveTemplate(opts));
|
|
688
811
|
}
|
|
689
812
|
/**
|
|
690
813
|
* Start a session from a template already resolved, locally or on a host.
|
|
@@ -700,7 +823,6 @@ export class Hub extends EventEmitter {
|
|
|
700
823
|
// A workspace that belongs to a host runs its agents there. The peer owns
|
|
701
824
|
// the PTY; what comes back is a session we show on our own canvas.
|
|
702
825
|
if (ws.hostId) {
|
|
703
|
-
const spawner = opts.spawnedBy ? this.sessions.get(opts.spawnedBy) : null;
|
|
704
826
|
// Resolved values cross, never the template id: a template is config
|
|
705
827
|
// and the two machines do not share config, so a name that means "opus,
|
|
706
828
|
// high effort" here may mean nothing over there. The id travels only so
|
|
@@ -718,7 +840,7 @@ export class Hub extends EventEmitter {
|
|
|
718
840
|
// here and the far side has never heard of it.
|
|
719
841
|
env: picked.env,
|
|
720
842
|
name: opts.name,
|
|
721
|
-
spawnedByAddress:
|
|
843
|
+
spawnedByAddress: opts.parentAddress ?? null,
|
|
722
844
|
});
|
|
723
845
|
}
|
|
724
846
|
return this.startResolved({ ...opts, ...picked });
|
|
@@ -749,6 +871,7 @@ export class Hub extends EventEmitter {
|
|
|
749
871
|
name: opts.name ?? opts.template ?? undefined,
|
|
750
872
|
cwd: opts.cwd,
|
|
751
873
|
spawnedBy: opts.spawnedBy ?? null,
|
|
874
|
+
parentAddress: opts.parentAddress ?? null,
|
|
752
875
|
});
|
|
753
876
|
/*
|
|
754
877
|
* Typed in once the CLI is up rather than written into argv, where it
|
|
@@ -764,35 +887,10 @@ export class Hub extends EventEmitter {
|
|
|
764
887
|
const opening = [this.typedBrief(session), opts.prompt]
|
|
765
888
|
.filter((part) => !!part)
|
|
766
889
|
.join('\n\n');
|
|
767
|
-
|
|
768
|
-
void this.deliverOpeningInstruction(session.id, opening);
|
|
890
|
+
this.noteOpening(session.id, opening || null);
|
|
769
891
|
this.store.setOpeningPrompt(session.id, opts.restorePrompt === undefined ? (opts.prompt ?? null) : opts.restorePrompt);
|
|
770
892
|
return session;
|
|
771
893
|
}
|
|
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
894
|
/**
|
|
797
895
|
* The brief for an agent that cannot be handed one, or null.
|
|
798
896
|
*
|
|
@@ -826,9 +924,31 @@ export class Hub extends EventEmitter {
|
|
|
826
924
|
*/
|
|
827
925
|
async resumeSession(sessionId) {
|
|
828
926
|
const session = await this.sessions.resume(sessionId);
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
927
|
+
// A fresh CLI, so a fresh gate: messages must wait for this one to be
|
|
928
|
+
// reading just as they did when it first started.
|
|
929
|
+
this.noteOpening(session.id, this.typedBrief(session));
|
|
930
|
+
return session;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Start a running session over: a fresh conversation, an empty window, and
|
|
934
|
+
* its opening typed in again - the window's `clear` button.
|
|
935
|
+
*
|
|
936
|
+
* A restart rather than typing `/clear` at the CLI. Only Claude Code could
|
|
937
|
+
* say it had cleared, and only through a hook; a restart is the same for
|
|
938
|
+
* every agent, and the hub knows exactly when the new one is up.
|
|
939
|
+
*
|
|
940
|
+
* What comes back is the opening the session recorded when it started (see
|
|
941
|
+
* startResolved): a template's instruction, or the human's from the dialog -
|
|
942
|
+
* never a spawning peer's task, which would start that work over. A brief
|
|
943
|
+
* that rides in on a flag needs nothing here; a typed one goes first, as it
|
|
944
|
+
* did at start.
|
|
945
|
+
*/
|
|
946
|
+
async clearSession(sessionId) {
|
|
947
|
+
const session = await this.sessions.restartFresh(sessionId);
|
|
948
|
+
const opening = [this.typedBrief(session), this.store.getOpeningPrompt(sessionId)]
|
|
949
|
+
.filter((part) => !!part)
|
|
950
|
+
.join('\n\n');
|
|
951
|
+
this.noteOpening(session.id, opening || null);
|
|
832
952
|
return session;
|
|
833
953
|
}
|
|
834
954
|
async resumeWorkspace(workspaceId) {
|
|
@@ -939,7 +1059,9 @@ export class Hub extends EventEmitter {
|
|
|
939
1059
|
workspace: ws?.name ?? null,
|
|
940
1060
|
cwd: s.cwd,
|
|
941
1061
|
profile: s.profile,
|
|
942
|
-
|
|
1062
|
+
// By address rather than through the local id, which a parent on
|
|
1063
|
+
// another machine never had.
|
|
1064
|
+
spawnedBy: s.parentAddress ?? null,
|
|
943
1065
|
};
|
|
944
1066
|
}
|
|
945
1067
|
/**
|
|
@@ -985,13 +1107,13 @@ export class Hub extends EventEmitter {
|
|
|
985
1107
|
}));
|
|
986
1108
|
}
|
|
987
1109
|
/**
|
|
988
|
-
* An attached hub's list:
|
|
1110
|
+
* An attached hub's list: the templates the canvas made, and the bare agents
|
|
1111
|
+
* this machine has (see overlayCanvasTemplates).
|
|
989
1112
|
*
|
|
990
|
-
* Templates are
|
|
991
|
-
*
|
|
992
|
-
*
|
|
993
|
-
*
|
|
994
|
-
* A link that cannot answer leaves the local list, which is still true.
|
|
1113
|
+
* Templates are central (issue 22), so this hub's own stored or declared
|
|
1114
|
+
* ones are left out: nobody on the canvas can see or edit them, and
|
|
1115
|
+
* spawn_agent refuses them here for the same reason. A link that cannot
|
|
1116
|
+
* answer leaves this machine's bare agents, which are still true.
|
|
995
1117
|
*/
|
|
996
1118
|
async templateViewsWithCanvas() {
|
|
997
1119
|
const local = this.templateViews();
|
|
@@ -1000,7 +1122,7 @@ export class Hub extends EventEmitter {
|
|
|
1000
1122
|
return overlayCanvasTemplates(canvas, local);
|
|
1001
1123
|
}
|
|
1002
1124
|
catch {
|
|
1003
|
-
return local;
|
|
1125
|
+
return overlayCanvasTemplates([], local);
|
|
1004
1126
|
}
|
|
1005
1127
|
}
|
|
1006
1128
|
/**
|
|
@@ -1259,6 +1381,11 @@ export class Hub extends EventEmitter {
|
|
|
1259
1381
|
return this.store.getHost(ws.hostId)?.label ?? 'remote';
|
|
1260
1382
|
return this.thisMachineLabel();
|
|
1261
1383
|
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Every agent the caller can see, on every machine: lineage decides who
|
|
1386
|
+
* that is (see agents/visibility.ts), not the machine or the workspace.
|
|
1387
|
+
* `workspace` only narrows what is already visible.
|
|
1388
|
+
*/
|
|
1262
1389
|
async listAgents(sessionId, workspace) {
|
|
1263
1390
|
const me = this.requireSession(sessionId);
|
|
1264
1391
|
const wsById = new Map(this.store.listWorkspaces().map((w) => [w.id, w]));
|
|
@@ -1271,6 +1398,7 @@ export class Hub extends EventEmitter {
|
|
|
1271
1398
|
status: s.status,
|
|
1272
1399
|
statusText: s.statusText,
|
|
1273
1400
|
host: myLabel,
|
|
1401
|
+
parentAddress: s.parentAddress ?? null,
|
|
1274
1402
|
isYou: s.id === me.id,
|
|
1275
1403
|
}));
|
|
1276
1404
|
const hostLabels = new Map(this.store.listHosts().map((h) => [h.id, h.label]));
|
|
@@ -1287,6 +1415,7 @@ export class Hub extends EventEmitter {
|
|
|
1287
1415
|
status: s.status,
|
|
1288
1416
|
statusText: s.statusText,
|
|
1289
1417
|
host: (hostId ? hostLabels.get(hostId) : undefined) ?? 'remote',
|
|
1418
|
+
parentAddress: s.parentAddress ?? null,
|
|
1290
1419
|
isYou: false,
|
|
1291
1420
|
};
|
|
1292
1421
|
});
|
|
@@ -1294,12 +1423,13 @@ export class Hub extends EventEmitter {
|
|
|
1294
1423
|
// is the only complete one, so this is where an agent on an attached
|
|
1295
1424
|
// machine learns that anything exists beyond this machine.
|
|
1296
1425
|
const fromCanvas = (this.uplink?.agents() ?? []).map((a) => ({ ...a, isYou: false }));
|
|
1297
|
-
return [...local, ...remote, ...fromCanvas].filter((a) => !workspace || a.workspace === workspace);
|
|
1426
|
+
return [...local, ...remote, ...fromCanvas].filter((a) => (a.isYou || canSee(me, a)) && (!workspace || a.workspace === workspace));
|
|
1298
1427
|
}
|
|
1299
1428
|
async sendMessage(sessionId, to, text) {
|
|
1300
1429
|
const me = this.requireSession(sessionId);
|
|
1301
1430
|
if (me.address === to)
|
|
1302
1431
|
throw new Error('cannot send a message to yourself');
|
|
1432
|
+
this.requireVisible(me, to);
|
|
1303
1433
|
const result = await this.deliverFrom(me.address, to, text);
|
|
1304
1434
|
// Only a successful send counts as a question asked - deliverFrom throws
|
|
1305
1435
|
// on failure, so a message that never arrived does not excuse polling.
|
|
@@ -1341,6 +1471,7 @@ export class Hub extends EventEmitter {
|
|
|
1341
1471
|
statusText: s.statusText,
|
|
1342
1472
|
// What the machine that owns the canvas is called, from anywhere else.
|
|
1343
1473
|
host: 'canvas',
|
|
1474
|
+
parentAddress: s.parentAddress ?? null,
|
|
1344
1475
|
}));
|
|
1345
1476
|
const elsewhere = this.peers
|
|
1346
1477
|
.sessions()
|
|
@@ -1353,6 +1484,7 @@ export class Hub extends EventEmitter {
|
|
|
1353
1484
|
status: s.status,
|
|
1354
1485
|
statusText: s.statusText,
|
|
1355
1486
|
host: hostLabels.get(this.peers.hostIdFor(s.address) ?? '') ?? 'remote',
|
|
1487
|
+
parentAddress: s.parentAddress ?? null,
|
|
1356
1488
|
}));
|
|
1357
1489
|
return [...here, ...elsewhere];
|
|
1358
1490
|
}
|
|
@@ -1369,6 +1501,13 @@ export class Hub extends EventEmitter {
|
|
|
1369
1501
|
// records the failed attempt with its reason, which is what keeps the
|
|
1370
1502
|
// promise that no message is ever dropped silently.
|
|
1371
1503
|
if (where === 'local' || where === null) {
|
|
1504
|
+
// Behind the target's opening, if it is still starting. This is the one
|
|
1505
|
+
// place delivery is not immediate, and the exception earns itself: the
|
|
1506
|
+
// alternative is writing into a CLI that is not reading, which is not a
|
|
1507
|
+
// delivery at all.
|
|
1508
|
+
const target = this.sessions.getByAddress(to);
|
|
1509
|
+
if (target)
|
|
1510
|
+
await this.awaitOpened(target.id);
|
|
1372
1511
|
const r = this.router.send(fromAddr, to, text);
|
|
1373
1512
|
if (!r.delivered)
|
|
1374
1513
|
throw new Error(r.error ?? 'delivery failed');
|
|
@@ -1417,8 +1556,10 @@ export class Hub extends EventEmitter {
|
|
|
1417
1556
|
*
|
|
1418
1557
|
* On an attached hub the canvas is asked first, because that is where
|
|
1419
1558
|
* 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
|
|
1559
|
+
* canvas actually holds comes back from there. Anything else, and any link
|
|
1560
|
+
* that does not answer, is resolved against this hub's own config - but
|
|
1561
|
+
* only as a bare agent or its derived template: templates are central, and
|
|
1562
|
+
* one stored or declared only on this machine is not one.
|
|
1422
1563
|
*/
|
|
1423
1564
|
async pickSpawnTemplate(me, profile) {
|
|
1424
1565
|
if (this.uplink?.attached) {
|
|
@@ -1438,6 +1579,16 @@ export class Hub extends EventEmitter {
|
|
|
1438
1579
|
throw new Error(answer.error);
|
|
1439
1580
|
if (answer?.picked)
|
|
1440
1581
|
return answer.picked;
|
|
1582
|
+
const own = (id) => {
|
|
1583
|
+
const t = this.templates.get(id);
|
|
1584
|
+
return !t || t.source === 'derived';
|
|
1585
|
+
};
|
|
1586
|
+
if (profile !== undefined && !own(profile))
|
|
1587
|
+
throw new Error(`no template "${profile}"`);
|
|
1588
|
+
// The caller's own template came from the canvas, which has just said
|
|
1589
|
+
// it no longer holds it; its agent is what is left.
|
|
1590
|
+
const inherit = profile ?? (me.template && own(me.template) ? me.template : me.profile);
|
|
1591
|
+
return this.resolveTemplate({ profile: inherit });
|
|
1441
1592
|
}
|
|
1442
1593
|
const inherited = me.template ? this.templates.get(me.template) : null;
|
|
1443
1594
|
const inherit = profile ?? (inherited && !inherited.error ? me.template : me.profile);
|
|
@@ -1504,7 +1655,7 @@ export class Hub extends EventEmitter {
|
|
|
1504
1655
|
throw new Error(`unknown workspace "${opts.workspace}"`);
|
|
1505
1656
|
ws = found;
|
|
1506
1657
|
}
|
|
1507
|
-
const child = await this.startPicked({ workspaceId: ws.id, name: opts.name, spawnedBy: me.id }, { ...picked, prompt: opening, restorePrompt: picked.prompt ?? null });
|
|
1658
|
+
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
1659
|
return {
|
|
1509
1660
|
address: child.address,
|
|
1510
1661
|
workspace: ws.name,
|
|
@@ -1544,11 +1695,9 @@ export class Hub extends EventEmitter {
|
|
|
1544
1695
|
restorePrompt: ask.restore ?? null,
|
|
1545
1696
|
env: ask.env,
|
|
1546
1697
|
name: ask.name,
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
|
|
1550
|
-
// table's foreign key would reject it.
|
|
1551
|
-
spawnedByAddress: null,
|
|
1698
|
+
// By address, the one id every hub agrees on: the spawner lives on
|
|
1699
|
+
// a hub this canvas has no local id for.
|
|
1700
|
+
spawnedByAddress: ask.from,
|
|
1552
1701
|
})
|
|
1553
1702
|
: await this.startResolved({
|
|
1554
1703
|
workspaceId: ws.id,
|
|
@@ -1560,7 +1709,11 @@ export class Hub extends EventEmitter {
|
|
|
1560
1709
|
restorePrompt: ask.restore ?? null,
|
|
1561
1710
|
env: ask.env,
|
|
1562
1711
|
name: ask.name,
|
|
1712
|
+
// No local id to give - the session table's foreign key would
|
|
1713
|
+
// reject one from another machine - but the address still says
|
|
1714
|
+
// whose child this is, which is what visibility is decided from.
|
|
1563
1715
|
spawnedBy: null,
|
|
1716
|
+
parentAddress: ask.from,
|
|
1564
1717
|
});
|
|
1565
1718
|
return {
|
|
1566
1719
|
address: child.address,
|
|
@@ -1582,56 +1735,145 @@ export class Hub extends EventEmitter {
|
|
|
1582
1735
|
async deliverOpeningInstruction(sessionId, prompt) {
|
|
1583
1736
|
await this.typeWhenReady(sessionId, prompt);
|
|
1584
1737
|
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Record how a session opens, and hold every later delivery behind it.
|
|
1740
|
+
*
|
|
1741
|
+
* Registered whether or not there is anything to type: an agent started with
|
|
1742
|
+
* no instruction at all still has a CLI that is not reading yet, and that is
|
|
1743
|
+
* the case issue 27 was reported for. Not awaited by the caller - the window
|
|
1744
|
+
* appears now - but stored, so the first message to arrive waits for the
|
|
1745
|
+
* same moment the instruction would have.
|
|
1746
|
+
*/
|
|
1747
|
+
noteOpening(sessionId, opening) {
|
|
1748
|
+
const done = opening
|
|
1749
|
+
? this.deliverOpeningInstruction(sessionId, opening)
|
|
1750
|
+
: this.awaitReady(sessionId).then(() => { });
|
|
1751
|
+
// Failures are the gate opening, not the gate jamming: a session that died
|
|
1752
|
+
// on the way up must not hold its own message log hostage.
|
|
1753
|
+
this.opened.set(sessionId, done.catch(() => { }));
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* Wait for a local session to be ready to be typed at, but not forever.
|
|
1757
|
+
*
|
|
1758
|
+
* Once resolved the promise stays resolved, so this costs a steady-state
|
|
1759
|
+
* delivery a microtask and nothing else.
|
|
1760
|
+
*/
|
|
1761
|
+
async awaitOpened(sessionId) {
|
|
1762
|
+
const gate = this.opened.get(sessionId);
|
|
1763
|
+
if (!gate)
|
|
1764
|
+
return;
|
|
1765
|
+
let timer;
|
|
1766
|
+
try {
|
|
1767
|
+
await Promise.race([
|
|
1768
|
+
gate,
|
|
1769
|
+
new Promise((res) => {
|
|
1770
|
+
timer = setTimeout(res, DELIVERY_WAIT_CAP_MS);
|
|
1771
|
+
timer.unref?.();
|
|
1772
|
+
}),
|
|
1773
|
+
]);
|
|
1774
|
+
}
|
|
1775
|
+
finally {
|
|
1776
|
+
if (timer)
|
|
1777
|
+
clearTimeout(timer);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1585
1780
|
/**
|
|
1586
1781
|
* Wait for the CLI to be up, then type. Written straight away the text lands
|
|
1587
1782
|
* before the program is reading it; the wait is for output to arrive and
|
|
1588
|
-
* then
|
|
1783
|
+
* then stop, which is as close to "it has drawn its prompt" as this gets
|
|
1589
1784
|
* without knowing the CLI.
|
|
1590
1785
|
*
|
|
1591
|
-
*
|
|
1592
|
-
*
|
|
1593
|
-
* the
|
|
1594
|
-
* the
|
|
1595
|
-
*
|
|
1786
|
+
* Quiet measured from the last output, not a fixed delay from the first.
|
|
1787
|
+
* The first output is not the CLI at all on Windows - ConPTY draws its own
|
|
1788
|
+
* the moment the process exists - so a delay counted from there was a guess
|
|
1789
|
+
* at how long the CLI takes to load, and a slower machine lost it: the start
|
|
1790
|
+
* of the instruction went into a program not yet reading, the rest landed
|
|
1791
|
+
* in its composer, and the Enter went nowhere. A CLI that never stops
|
|
1792
|
+
* drawing still gets its instruction, once the cap is up.
|
|
1793
|
+
*
|
|
1794
|
+
* Nor into a question. A CLI that opens by asking the human something -
|
|
1795
|
+
* Claude Code in a folder it has not been trusted in - is quiet too, and
|
|
1796
|
+
* the instruction typed at it was spent on the question: gone, or partly
|
|
1797
|
+
* taken as answers to it. Such a screen is waited out until it changes.
|
|
1596
1798
|
*/
|
|
1597
|
-
async typeWhenReady(sessionId, text
|
|
1799
|
+
async typeWhenReady(sessionId, text) {
|
|
1800
|
+
const ready = await this.awaitReady(sessionId);
|
|
1598
1801
|
const pty = this.sessions.pty(sessionId);
|
|
1599
|
-
if (!pty)
|
|
1600
|
-
return;
|
|
1601
|
-
const ready = await new Promise((res) => {
|
|
1602
|
-
let settled = false;
|
|
1603
|
-
const done = (v) => {
|
|
1604
|
-
if (settled)
|
|
1605
|
-
return;
|
|
1606
|
-
settled = true;
|
|
1607
|
-
clearTimeout(timer);
|
|
1608
|
-
pty.off('data', onData);
|
|
1609
|
-
res(v);
|
|
1610
|
-
};
|
|
1611
|
-
const onData = () => setTimeout(() => done(true), 1200);
|
|
1612
|
-
const timer = setTimeout(() => done(!!opts.alreadyUp), 20_000);
|
|
1613
|
-
timer.unref?.();
|
|
1614
|
-
pty.on('data', onData);
|
|
1615
|
-
if (opts.alreadyUp)
|
|
1616
|
-
onData();
|
|
1617
|
-
});
|
|
1618
|
-
if (!ready || !pty.running)
|
|
1802
|
+
if (!ready || !pty?.running)
|
|
1619
1803
|
return;
|
|
1620
1804
|
const session = this.sessions.get(sessionId);
|
|
1621
1805
|
if (!session)
|
|
1622
1806
|
return;
|
|
1623
1807
|
const mode = this.profiles.get(session.profile)?.inject ?? 'bracketed';
|
|
1624
1808
|
try {
|
|
1625
|
-
|
|
1809
|
+
// Awaited, so that a caller holding this session's readiness gate is not
|
|
1810
|
+
// released until the Enter is in: a message injected between the paste
|
|
1811
|
+
// and its Enter is folded into the same prompt.
|
|
1812
|
+
await this.sessions.inject(sessionId, encodeInjection(text, mode), INJECT_SUBMIT);
|
|
1626
1813
|
}
|
|
1627
1814
|
catch {
|
|
1628
1815
|
// The agent died between the readiness check and the write; the message
|
|
1629
1816
|
// log already reflects that it never started.
|
|
1630
1817
|
}
|
|
1631
1818
|
}
|
|
1819
|
+
/**
|
|
1820
|
+
* Wait until the CLI in a session is up and reading, or give up.
|
|
1821
|
+
*
|
|
1822
|
+
* Split out of `typeWhenReady` because typing is not the only thing that has
|
|
1823
|
+
* to wait for this. A message delivered to an agent whose CLI has not
|
|
1824
|
+
* started reading is written into a program that is not listening: the paste
|
|
1825
|
+
* lands in a composer that is not there yet and the Enter goes nowhere, and
|
|
1826
|
+
* what the sender sees is a delivery that was recorded and never acted on.
|
|
1827
|
+
* See `deliverFrom`.
|
|
1828
|
+
*/
|
|
1829
|
+
async awaitReady(sessionId) {
|
|
1830
|
+
const pty = this.sessions.pty(sessionId);
|
|
1831
|
+
if (!pty)
|
|
1832
|
+
return false;
|
|
1833
|
+
const profile = this.profiles.get(this.sessions.get(sessionId)?.profile ?? '');
|
|
1834
|
+
const hint = profile?.askingHint ? new RegExp(profile.askingHint, 'i') : null;
|
|
1835
|
+
// A question on screen outlasts any cap: the human answers it when they
|
|
1836
|
+
// get to the window, and the instruction is still wanted after that.
|
|
1837
|
+
const asking = () => !!hint && hint.test(pty.tailLines(pty.rows));
|
|
1838
|
+
return new Promise((res) => {
|
|
1839
|
+
let settled = false;
|
|
1840
|
+
let seen = false;
|
|
1841
|
+
let quiet = null;
|
|
1842
|
+
const done = (v) => {
|
|
1843
|
+
if (settled)
|
|
1844
|
+
return;
|
|
1845
|
+
settled = true;
|
|
1846
|
+
clearTimeout(cap);
|
|
1847
|
+
if (quiet)
|
|
1848
|
+
clearTimeout(quiet);
|
|
1849
|
+
pty.off('data', onData);
|
|
1850
|
+
pty.off('exit', onExit);
|
|
1851
|
+
res(v);
|
|
1852
|
+
};
|
|
1853
|
+
const onData = () => {
|
|
1854
|
+
seen = true;
|
|
1855
|
+
if (quiet)
|
|
1856
|
+
clearTimeout(quiet);
|
|
1857
|
+
quiet = setTimeout(() => {
|
|
1858
|
+
quiet = null;
|
|
1859
|
+
if (!asking())
|
|
1860
|
+
done(true);
|
|
1861
|
+
}, OPENING_QUIET_MS);
|
|
1862
|
+
};
|
|
1863
|
+
const onExit = () => done(false);
|
|
1864
|
+
const cap = setTimeout(() => {
|
|
1865
|
+
if (!asking())
|
|
1866
|
+
done(seen);
|
|
1867
|
+
}, OPENING_READY_CAP_MS);
|
|
1868
|
+
cap.unref?.();
|
|
1869
|
+
pty.on('data', onData);
|
|
1870
|
+
pty.on('exit', onExit);
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1632
1873
|
async readScreen(sessionId, address, lines) {
|
|
1633
1874
|
const me = this.requireSession(sessionId);
|
|
1634
|
-
|
|
1875
|
+
this.requireVisible(me, address);
|
|
1876
|
+
const result = await this.readScreenAt(me.address, address, lines);
|
|
1635
1877
|
// Decorating after the await, rather than counting the call up front,
|
|
1636
1878
|
// means a remote or uplink target gets the note too. Counted here, not in
|
|
1637
1879
|
// readScreenAt: that path also serves peers asking on this hub's behalf
|
|
@@ -1645,13 +1887,15 @@ export class Hub extends EventEmitter {
|
|
|
1645
1887
|
* agent of its own, or a peer that authenticated one and is asking on its
|
|
1646
1888
|
* behalf. Resolves the address the same three ways a delivery does.
|
|
1647
1889
|
*/
|
|
1648
|
-
async readScreenAt(address, lines) {
|
|
1890
|
+
async readScreenAt(from, address, lines) {
|
|
1649
1891
|
const where = this.locate(address);
|
|
1650
1892
|
if (where === 'remote')
|
|
1651
1893
|
return this.peers.readScreen(address, lines);
|
|
1652
1894
|
if (where === 'uplink') {
|
|
1653
1895
|
return this.uplink.ask({
|
|
1654
1896
|
t: 'readScreen',
|
|
1897
|
+
// The canvas decides again whether the caller may look.
|
|
1898
|
+
from,
|
|
1655
1899
|
address,
|
|
1656
1900
|
lines,
|
|
1657
1901
|
});
|
|
@@ -1677,9 +1921,13 @@ export class Hub extends EventEmitter {
|
|
|
1677
1921
|
const me = this.requireSession(sessionId);
|
|
1678
1922
|
const target = this.sessions.getByAddress(address);
|
|
1679
1923
|
if (!target) {
|
|
1680
|
-
//
|
|
1681
|
-
//
|
|
1682
|
-
|
|
1924
|
+
// A remote session reports who spawned it by address, so the rule
|
|
1925
|
+
// below can be judged here before the host is asked to stop it.
|
|
1926
|
+
const remote = this.peers.find(address)?.session;
|
|
1927
|
+
if (remote) {
|
|
1928
|
+
if (remote.parentAddress !== me.address) {
|
|
1929
|
+
throw new Error(`"${address}" was not spawned by you; only its spawner may stop it`);
|
|
1930
|
+
}
|
|
1683
1931
|
await this.peers.stopSession(address);
|
|
1684
1932
|
return { stopped: address };
|
|
1685
1933
|
}
|
|
@@ -1688,8 +1936,9 @@ export class Hub extends EventEmitter {
|
|
|
1688
1936
|
if (target.id === me.id)
|
|
1689
1937
|
throw new Error('use your own exit command to stop yourself');
|
|
1690
1938
|
// An agent may only stop what it created. Otherwise a single confused
|
|
1691
|
-
// agent could take down the whole canvas.
|
|
1692
|
-
|
|
1939
|
+
// agent could take down the whole canvas. Judged by address, so a child
|
|
1940
|
+
// whose spawner is on another machine is judged the same as a local one.
|
|
1941
|
+
if (target.parentAddress !== me.address) {
|
|
1693
1942
|
throw new Error(`"${address}" was not spawned by you; only its spawner may stop it`);
|
|
1694
1943
|
}
|
|
1695
1944
|
this.sessions.stop(target.id);
|