@claudecollab/cli 0.1.0
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/LICENSE +21 -0
- package/README.md +109 -0
- package/package.json +36 -0
- package/packages/cli/bin/claude-share.js +1319 -0
- package/packages/cli/package.json +15 -0
- package/packages/cli/src/brain/card.js +157 -0
- package/packages/cli/src/brain/commands.js +122 -0
- package/packages/cli/src/brain/drafts.js +557 -0
- package/packages/cli/src/brain/gate.js +134 -0
- package/packages/cli/src/brain/knocks.js +27 -0
- package/packages/cli/src/brain/log.js +123 -0
- package/packages/cli/src/brain/queue.js +81 -0
- package/packages/cli/src/brain/state.js +245 -0
- package/packages/cli/src/hooks.js +243 -0
- package/packages/cli/src/invite.js +43 -0
- package/packages/cli/src/known-relays.js +66 -0
- package/packages/cli/src/pty.js +175 -0
- package/packages/cli/src/relay-client.js +271 -0
- package/packages/cli/src/renderer.js +230 -0
- package/packages/cli/src/screen-snapshot.js +139 -0
- package/packages/cli/src/term-chatter.js +89 -0
- package/packages/relay/auth.js +18 -0
- package/packages/relay/bin/serve.js +73 -0
- package/packages/relay/names.js +27 -0
- package/packages/relay/package.json +13 -0
- package/packages/relay/public/client.js +1553 -0
- package/packages/relay/public/index.html +110 -0
- package/packages/relay/public/style.css +952 -0
- package/packages/relay/rooms.js +149 -0
- package/packages/relay/server.js +652 -0
- package/packages/relay/web.js +334 -0
- package/packages/relay/wordlists.js +38 -0
- package/packages/shared/package.json +8 -0
- package/packages/shared/protocol.js +189 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Pending-knock bookkeeping (spec §knock). One card per guest: a WS reconnect during
|
|
2
|
+
// the join flow re-knocks with a NEW connection id but the SAME fingerprint, and the
|
|
3
|
+
// stock ssh/browser flows can double-fire a knock too. Without dedup the host sees two
|
|
4
|
+
// cards for one guest and can admit the stale one (finding 3), landing a phantom in the
|
|
5
|
+
// roster and count. So a re-knock from the same fingerprint REPLACES the prior pending
|
|
6
|
+
// knock rather than stacking a duplicate.
|
|
7
|
+
//
|
|
8
|
+
// Pure, so it is unit-tested without a relay. bin/claude-share.js calls foldKnock on
|
|
9
|
+
// each relay KNOCK, swaps in the returned list, and denies the `replaced` connections.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fold a new knock into the pending list, replacing any earlier pending knock from the
|
|
13
|
+
* same (non-empty) fingerprint. Keyless / tokenless guests carry an empty fp — every
|
|
14
|
+
* one of those is a distinct person, so they are never deduped.
|
|
15
|
+
*
|
|
16
|
+
* @param {Array<{id:string,name:string,fp:string,seen:*}>} pending current pending knocks
|
|
17
|
+
* @param {{id:string,name:string,fp:string,seen:*}} knock the arriving knock
|
|
18
|
+
* @returns {{pending: Array, replaced: string[]}} new list (newest knock last) + the
|
|
19
|
+
* connection ids of the superseded knocks the caller should deny/forget
|
|
20
|
+
*/
|
|
21
|
+
export function foldKnock(pending, knock) {
|
|
22
|
+
const list = Array.isArray(pending) ? pending : [];
|
|
23
|
+
if (!knock || !knock.fp) return { pending: [...list, knock], replaced: [] };
|
|
24
|
+
const replaced = list.filter((k) => k.fp === knock.fp).map((k) => k.id);
|
|
25
|
+
const kept = list.filter((k) => k.fp !== knock.fp);
|
|
26
|
+
return { pending: [...kept, knock], replaced };
|
|
27
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The in-memory attributed log — the room's who-typed-what record (spec §host
|
|
2
|
+
// controls, §join context card). It captures three kinds of entry:
|
|
3
|
+
//
|
|
4
|
+
// prompt — a sent draft, attributed to its author (feeds the card + /recap)
|
|
5
|
+
// event — a room event: joins, leaves, role changes, kicks, mode flips, pause
|
|
6
|
+
// tool — a Claude PostToolUse, with the files it touched (feeds the card)
|
|
7
|
+
//
|
|
8
|
+
// The log lives only for the session. On /end the host chooses whether to persist
|
|
9
|
+
// it: N discards it, Y writes session.md via write(). toText() is the plain
|
|
10
|
+
// transcript /recap hands to a one-shot `claude -p`.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
|
|
14
|
+
export class Log {
|
|
15
|
+
#entries = [];
|
|
16
|
+
#files = [];
|
|
17
|
+
#now;
|
|
18
|
+
#startedAt;
|
|
19
|
+
|
|
20
|
+
/** @param {object} [opts] @param {() => number} [opts.now] injectable clock */
|
|
21
|
+
constructor({ now = () => Date.now() } = {}) {
|
|
22
|
+
this.#now = now;
|
|
23
|
+
this.#startedAt = now();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Record an attributed prompt. */
|
|
27
|
+
prompt(author, text, at = this.#now()) {
|
|
28
|
+
const e = { kind: 'prompt', author, text: String(text), at };
|
|
29
|
+
this.#entries.push(e);
|
|
30
|
+
return e;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Record a room event (join/leave/role/kick/mode/pause). */
|
|
34
|
+
event(text, at = this.#now()) {
|
|
35
|
+
const e = { kind: 'event', text: String(text), at };
|
|
36
|
+
this.#entries.push(e);
|
|
37
|
+
return e;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Record a tool use and merge any touched files into the ordered unique list. */
|
|
41
|
+
tool(name, files = [], at = this.#now()) {
|
|
42
|
+
const list = Array.isArray(files) ? files : [];
|
|
43
|
+
const e = { kind: 'tool', name, files: [...list], at };
|
|
44
|
+
for (const f of list) if (f && !this.#files.includes(f)) this.#files.push(f);
|
|
45
|
+
this.#entries.push(e);
|
|
46
|
+
return e;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** All entries, as copies. */
|
|
50
|
+
get entries() {
|
|
51
|
+
return this.#entries.map((e) => ({ ...e }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The de-duplicated files touched so far, in first-touch order. */
|
|
55
|
+
get files() {
|
|
56
|
+
return [...this.#files];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get startedAt() {
|
|
60
|
+
return this.#startedAt;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Just the prompt entries. */
|
|
64
|
+
prompts() {
|
|
65
|
+
return this.#entries.filter((e) => e.kind === 'prompt').map((e) => ({ ...e }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The last `n` prompts, oldest→newest. */
|
|
69
|
+
recentPrompts(n = 3) {
|
|
70
|
+
const p = this.prompts();
|
|
71
|
+
return p.slice(Math.max(0, p.length - n));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Author of the most recent prompt, or null. */
|
|
75
|
+
lastPromptAuthor() {
|
|
76
|
+
const p = this.#entries.filter((e) => e.kind === 'prompt');
|
|
77
|
+
return p.length ? p[p.length - 1].author : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Plain attributed transcript — the input /recap feeds to `claude -p`. */
|
|
81
|
+
toText() {
|
|
82
|
+
return this.#entries
|
|
83
|
+
.map((e) => {
|
|
84
|
+
if (e.kind === 'prompt') return `${e.author}: ${e.text}`;
|
|
85
|
+
if (e.kind === 'event') return `* ${e.text}`;
|
|
86
|
+
if (e.kind === 'tool') return `[tool] ${e.name}${e.files.length ? ' ' + e.files.join(', ') : ''}`;
|
|
87
|
+
return '';
|
|
88
|
+
})
|
|
89
|
+
.join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Render the attributed log as a Markdown session record.
|
|
94
|
+
* @param {object} [opts]
|
|
95
|
+
* @param {string} [opts.title] document title
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
toMarkdown({ title = 'claude-share session' } = {}) {
|
|
99
|
+
const rel = (at) => {
|
|
100
|
+
const s = Math.max(0, Math.round((at - this.#startedAt) / 1000));
|
|
101
|
+
const mm = String(Math.floor(s / 60)).padStart(2, '0');
|
|
102
|
+
const ss = String(s % 60).padStart(2, '0');
|
|
103
|
+
return `${mm}:${ss}`;
|
|
104
|
+
};
|
|
105
|
+
const lines = [`# ${title}`, ''];
|
|
106
|
+
for (const e of this.#entries) {
|
|
107
|
+
if (e.kind === 'prompt') lines.push(`- \`${rel(e.at)}\` **${e.author}**: ${e.text}`);
|
|
108
|
+
else if (e.kind === 'event') lines.push(`- \`${rel(e.at)}\` _${e.text}_`);
|
|
109
|
+
else if (e.kind === 'tool')
|
|
110
|
+
lines.push(`- \`${rel(e.at)}\` \`${e.name}\`${e.files.length ? ' — ' + e.files.join(', ') : ''}`);
|
|
111
|
+
}
|
|
112
|
+
if (this.#files.length) {
|
|
113
|
+
lines.push('', '## Files touched', '', ...this.#files.map((f) => `- ${f}`));
|
|
114
|
+
}
|
|
115
|
+
return lines.join('\n') + '\n';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Write the Markdown record to a file (spec §host controls: /end → session.md). */
|
|
119
|
+
write(filePath, opts) {
|
|
120
|
+
fs.writeFileSync(filePath, this.toMarkdown(opts));
|
|
121
|
+
return filePath;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// The attributed queue — where sent drafts land while Claude is busy, and from
|
|
2
|
+
// which they drain, in order, one per idle turn (spec §queue). Pure logic; the
|
|
3
|
+
// wiring calls drain() from the 'idle' hook handler and feeds the released item
|
|
4
|
+
// to the PTY.
|
|
5
|
+
//
|
|
6
|
+
// Permissions (spec): the author may edit or delete their own item; the host and
|
|
7
|
+
// the host may delete anyone's. Draining is fail-closed — it only releases an item
|
|
8
|
+
// when Claude is *known* idle; any other (or unknown) state keeps the queue frozen
|
|
9
|
+
// so a missed hook can never auto-fire a stranger's prompt.
|
|
10
|
+
|
|
11
|
+
import { atLeast } from './state.js';
|
|
12
|
+
|
|
13
|
+
export class Queue {
|
|
14
|
+
#items = [];
|
|
15
|
+
#seq = 0;
|
|
16
|
+
|
|
17
|
+
/** Append an attributed item; returns the stored record (with its id). */
|
|
18
|
+
enqueue(text, author) {
|
|
19
|
+
const item = { id: `q${++this.#seq}`, text: String(text), author };
|
|
20
|
+
this.#items.push(item);
|
|
21
|
+
return item;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Defensive copies so a caller can't mutate queue internals through a read. */
|
|
25
|
+
get items() {
|
|
26
|
+
return this.#items.map((i) => ({ ...i }));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get length() {
|
|
30
|
+
return this.#items.length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The front item (copy) without removing it, or null. */
|
|
34
|
+
peek() {
|
|
35
|
+
return this.#items[0] ? { ...this.#items[0] } : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Edit a queued item's text. Author-only (spec): the host can DELETE any
|
|
40
|
+
* item but may not rewrite what someone else meant to say.
|
|
41
|
+
* @returns {boolean} true if edited
|
|
42
|
+
*/
|
|
43
|
+
edit(id, text, byUserId, _byRole) {
|
|
44
|
+
const it = this.#items.find((i) => i.id === id);
|
|
45
|
+
if (!it || it.author !== byUserId) return false;
|
|
46
|
+
it.text = String(text);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Delete a queued item. Allowed for the author, or for the host on any item.
|
|
52
|
+
* @returns {boolean} true if removed
|
|
53
|
+
*/
|
|
54
|
+
remove(id, byUserId, byRole) {
|
|
55
|
+
const idx = this.#items.findIndex((i) => i.id === id);
|
|
56
|
+
if (idx === -1) return false;
|
|
57
|
+
const it = this.#items[idx];
|
|
58
|
+
if (it.author !== byUserId && !atLeast(byRole, 'host')) return false;
|
|
59
|
+
this.#items.splice(idx, 1);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Drop every item authored by a user (they left or were kicked). Returns the count. */
|
|
64
|
+
removeByAuthor(userId) {
|
|
65
|
+
const before = this.#items.length;
|
|
66
|
+
this.#items = this.#items.filter((i) => i.author !== userId);
|
|
67
|
+
return before - this.#items.length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Release the front item — but only when Claude is *known* idle. Any other state
|
|
72
|
+
* (busy, ask, or an ambiguous/undefined signal) returns null and leaves the queue
|
|
73
|
+
* untouched (fail closed, spec §agent-state-detection).
|
|
74
|
+
* @param {string} claudeState the current hook-derived state
|
|
75
|
+
* @returns {{id:string,text:string,author:string}|null}
|
|
76
|
+
*/
|
|
77
|
+
drain(claudeState) {
|
|
78
|
+
if (claudeState !== 'idle') return null;
|
|
79
|
+
return this.#items.shift() ?? null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Room state — the host brain's canonical view of who is in the room, what role
|
|
2
|
+
// each holds, Claude's current permission mode, whether sharing is paused, and
|
|
3
|
+
// how big the shared screen may be (spec §roles, §renderer size clamp).
|
|
4
|
+
//
|
|
5
|
+
// Pure state, no I/O. The relay client feeds it join/leave/resize; the hook
|
|
6
|
+
// listener feeds it the mode; commands mutate roles/pause. The renderer and the
|
|
7
|
+
// join-context card read it. Roles rank here so gate.js / queue.js / commands.js
|
|
8
|
+
// share one source of truth for "who outranks whom".
|
|
9
|
+
|
|
10
|
+
/** Role names, lowest privilege first. */
|
|
11
|
+
export const ROLES = Object.freeze(['viewer', 'prompter', 'host']);
|
|
12
|
+
|
|
13
|
+
/** Numeric privilege rank — higher answers more (spec §roles table). */
|
|
14
|
+
export const ROLE_RANK = Object.freeze({ viewer: 0, prompter: 1, host: 2 });
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Is `role` at least as privileged as `min`? Fails closed: an unknown actor role
|
|
18
|
+
* ranks below everything, and an unknown required floor is unreachable.
|
|
19
|
+
* @param {string} role
|
|
20
|
+
* @param {string} min
|
|
21
|
+
* @returns {boolean}
|
|
22
|
+
*/
|
|
23
|
+
export function atLeast(role, min) {
|
|
24
|
+
const have = ROLE_RANK[role];
|
|
25
|
+
const need = ROLE_RANK[min];
|
|
26
|
+
if (have == null || need == null) return false;
|
|
27
|
+
return have >= need;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Spec §renderer: the shared view is clamped to the smallest participant's
|
|
31
|
+
// terminal, floored at 80×24. A participant below the floor spectates instead of
|
|
32
|
+
// shrinking the room for everyone.
|
|
33
|
+
export const FLOOR_COLS = 80;
|
|
34
|
+
export const FLOOR_ROWS = 24;
|
|
35
|
+
|
|
36
|
+
// Stable participant colors for the browser overlay (spec §roles: "color is a
|
|
37
|
+
// bonus — colorblind-safe by not being load-bearing"; name tags carry identity).
|
|
38
|
+
// Eight distinct hues, assigned by the brain per fingerprint so a returning key
|
|
39
|
+
// keeps its color on reconnect. Kept accessible/distinct; never load-bearing.
|
|
40
|
+
export const PALETTE = Object.freeze([
|
|
41
|
+
'#e5484d', // red
|
|
42
|
+
'#f76b15', // orange
|
|
43
|
+
'#ffb224', // amber
|
|
44
|
+
'#30a46c', // green
|
|
45
|
+
'#0091ff', // blue
|
|
46
|
+
'#8e4ec6', // purple
|
|
47
|
+
'#e93d82', // pink
|
|
48
|
+
'#12a594', // teal
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/** The host's own participant id (the host is not a relay guest). */
|
|
52
|
+
export const HOST_ID = 'host';
|
|
53
|
+
|
|
54
|
+
export class RoomState {
|
|
55
|
+
/** @type {Map<string,{id:string,name:string,fp:string|null,role:string,cols?:number,rows?:number}>} */
|
|
56
|
+
participants = new Map();
|
|
57
|
+
mode = 'default';
|
|
58
|
+
paused = false;
|
|
59
|
+
room = null;
|
|
60
|
+
startedAt;
|
|
61
|
+
|
|
62
|
+
#defaultRole;
|
|
63
|
+
#now;
|
|
64
|
+
// fp -> the last role that fingerprint held this session. A returning key resumes
|
|
65
|
+
// its seat's role on reconnect (spec §identity: name, color, role restored on
|
|
66
|
+
// reconnect); keyless guests (no fp) are never recorded and rejoin as new.
|
|
67
|
+
#seatRoles = new Map();
|
|
68
|
+
// colorKey -> palette color. The key is a fingerprint (so a returning key keeps its
|
|
69
|
+
// color) or, for the host and keyless guests, the participant id. Assigned in first-
|
|
70
|
+
// seen order and wrapped modulo the palette so it never runs out.
|
|
71
|
+
#colorByKey = new Map();
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {object} [opts]
|
|
75
|
+
* @param {string} [opts.hostName='host'] display name for the host
|
|
76
|
+
* @param {string} [opts.defaultRole='prompter'] role new guests get (spec default)
|
|
77
|
+
* @param {{cols:number,rows:number}} [opts.hostSize] the host terminal size
|
|
78
|
+
* @param {() => number} [opts.now] injectable clock (ms)
|
|
79
|
+
*/
|
|
80
|
+
constructor({ hostName = 'host', defaultRole = 'prompter', hostSize, now = () => Date.now() } = {}) {
|
|
81
|
+
this.#now = now;
|
|
82
|
+
this.#defaultRole = defaultRole;
|
|
83
|
+
this.startedAt = now();
|
|
84
|
+
this.participants.set(HOST_ID, {
|
|
85
|
+
id: HOST_ID,
|
|
86
|
+
name: hostName,
|
|
87
|
+
fp: null,
|
|
88
|
+
role: 'host',
|
|
89
|
+
color: this.#assignColor(HOST_ID),
|
|
90
|
+
cols: hostSize?.cols,
|
|
91
|
+
rows: hostSize?.rows,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Return the stable color for a color key, assigning the next palette slot on
|
|
96
|
+
// first sight. Reused for a returning fingerprint (same key ⇒ same color).
|
|
97
|
+
#assignColor(key) {
|
|
98
|
+
const existing = this.#colorByKey.get(key);
|
|
99
|
+
if (existing) return existing;
|
|
100
|
+
const color = PALETTE[this.#colorByKey.size % PALETTE.length];
|
|
101
|
+
this.#colorByKey.set(key, color);
|
|
102
|
+
return color;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The palette color assigned to a participant, or null if unknown. */
|
|
106
|
+
colorOf(id) {
|
|
107
|
+
return this.participants.get(id)?.color ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
setRoom(code) {
|
|
111
|
+
this.room = code;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Update the permission mode; returns true only if it actually changed. */
|
|
115
|
+
setMode(mode) {
|
|
116
|
+
if (mode === this.mode) return false;
|
|
117
|
+
this.mode = mode;
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setPaused(paused) {
|
|
122
|
+
this.paused = !!paused;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
get(id) {
|
|
126
|
+
return this.participants.get(id);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
roleOf(id) {
|
|
130
|
+
return this.participants.get(id)?.role ?? null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
nameOf(id) {
|
|
134
|
+
return this.participants.get(id)?.name ?? null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** All participants including the host, in insertion order. */
|
|
138
|
+
list() {
|
|
139
|
+
return [...this.participants.values()];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Just the guests (everyone but the host). */
|
|
143
|
+
guests() {
|
|
144
|
+
return this.list().filter((p) => p.id !== HOST_ID);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Add a guest. A returning fingerprint (one that held a seat earlier this session)
|
|
149
|
+
* resumes the role it last held — spec §identity: role is restored on reconnect,
|
|
150
|
+
* and §failure-behavior: "reconnect with same key restores identity + role". A new
|
|
151
|
+
* or keyless guest gets the explicit role, else the room default. Returns the record.
|
|
152
|
+
*/
|
|
153
|
+
addGuest(id, { name, fp, role } = {}) {
|
|
154
|
+
const restored = fp ? this.#seatRoles.get(fp) : undefined;
|
|
155
|
+
const p = {
|
|
156
|
+
id,
|
|
157
|
+
name: name ?? 'guest',
|
|
158
|
+
fp: fp ?? null,
|
|
159
|
+
role: restored ?? role ?? this.#defaultRole,
|
|
160
|
+
// Keyed guests color by fingerprint (restored on reconnect); keyless by id.
|
|
161
|
+
color: this.#assignColor(fp ?? id),
|
|
162
|
+
cols: undefined,
|
|
163
|
+
rows: undefined,
|
|
164
|
+
};
|
|
165
|
+
this.participants.set(id, p);
|
|
166
|
+
return p;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Drop a guest. The host can never be removed. A keyed guest's current role is
|
|
171
|
+
* remembered against its fingerprint so a same-key reconnect resumes the seat.
|
|
172
|
+
*/
|
|
173
|
+
removeGuest(id) {
|
|
174
|
+
if (id === HOST_ID) return false;
|
|
175
|
+
const p = this.participants.get(id);
|
|
176
|
+
if (p?.fp) this.#seatRoles.set(p.fp, p.role);
|
|
177
|
+
return this.participants.delete(id);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Set a guest's role. Refuses to touch the host, to promote anyone to host, or
|
|
182
|
+
* to set an unknown role (spec: host is fixed; /role targets prompter|viewer).
|
|
183
|
+
*/
|
|
184
|
+
setRole(id, role) {
|
|
185
|
+
const p = this.participants.get(id);
|
|
186
|
+
if (!p || id === HOST_ID) return false;
|
|
187
|
+
if (!Object.prototype.hasOwnProperty.call(ROLE_RANK, role) || role === 'host') return false;
|
|
188
|
+
p.role = role;
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Record a participant's terminal size (host or guest). */
|
|
193
|
+
setSize(id, cols, rows) {
|
|
194
|
+
const p = this.participants.get(id);
|
|
195
|
+
if (!p) return false;
|
|
196
|
+
p.cols = cols;
|
|
197
|
+
p.rows = rows;
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** True if this participant's terminal is below the 80×24 floor (they spectate). */
|
|
202
|
+
belowFloor(id) {
|
|
203
|
+
const p = this.participants.get(id);
|
|
204
|
+
if (!p) return false;
|
|
205
|
+
return (p.cols != null && p.cols < FLOOR_COLS) || (p.rows != null && p.rows < FLOOR_ROWS);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Guest ids eligible for the live mirror — at or above the 80×24 floor. Below-floor
|
|
210
|
+
* guests are excluded: they spectate on a "make your terminal bigger" hint instead
|
|
211
|
+
* of receiving a shared view sized for larger terminals (spec §renderer clamp).
|
|
212
|
+
*/
|
|
213
|
+
mirrorTargets() {
|
|
214
|
+
return this.guests().filter((p) => !this.belowFloor(p.id)).map((p) => p.id);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Guest ids parked on the spectate hint (below the floor). */
|
|
218
|
+
spectators() {
|
|
219
|
+
return this.guests().filter((p) => this.belowFloor(p.id)).map((p) => p.id);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The shared view dimensions: the component-wise minimum over participants that
|
|
224
|
+
* meet the floor, then raised to the 80×24 floor. Below-floor participants are
|
|
225
|
+
* excluded (they spectate) and participants of unknown size do not constrain it.
|
|
226
|
+
* @returns {{cols:number, rows:number}}
|
|
227
|
+
*/
|
|
228
|
+
clamp() {
|
|
229
|
+
let cols = Infinity;
|
|
230
|
+
let rows = Infinity;
|
|
231
|
+
for (const p of this.participants.values()) {
|
|
232
|
+
if (this.belowFloor(p.id)) continue;
|
|
233
|
+
if (p.cols != null) cols = Math.min(cols, p.cols);
|
|
234
|
+
if (p.rows != null) rows = Math.min(rows, p.rows);
|
|
235
|
+
}
|
|
236
|
+
if (!Number.isFinite(cols)) cols = FLOOR_COLS;
|
|
237
|
+
if (!Number.isFinite(rows)) rows = FLOOR_ROWS;
|
|
238
|
+
return { cols: Math.max(FLOOR_COLS, cols), rows: Math.max(FLOOR_ROWS, rows) };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Milliseconds since the session started (uses the injected clock). */
|
|
242
|
+
ageMs() {
|
|
243
|
+
return this.#now() - this.startedAt;
|
|
244
|
+
}
|
|
245
|
+
}
|