agentgather 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 +418 -0
- package/SECURITY.md +104 -0
- package/dist/src/auth/index.js +1 -0
- package/dist/src/auth/tokens.js +12 -0
- package/dist/src/browser/room.css +666 -0
- package/dist/src/browser/room.html +80 -0
- package/dist/src/browser/room.js +435 -0
- package/dist/src/cli/args.js +29 -0
- package/dist/src/cli/commands/attend/index.js +26 -0
- package/dist/src/cli/commands/broker/index.js +61 -0
- package/dist/src/cli/commands/doctor/index.js +93 -0
- package/dist/src/cli/commands/export/index.js +42 -0
- package/dist/src/cli/commands/handoff/index.js +41 -0
- package/dist/src/cli/commands/instructions/index.js +7 -0
- package/dist/src/cli/commands/message/index.js +50 -0
- package/dist/src/cli/commands/message/transport.js +108 -0
- package/dist/src/cli/commands/room/index.js +350 -0
- package/dist/src/cli/commands/tunnel/index.js +131 -0
- package/dist/src/cli/commands/watch/index.js +16 -0
- package/dist/src/cli/context.js +9 -0
- package/dist/src/cli/help.js +53 -0
- package/dist/src/cli/index.js +63 -0
- package/dist/src/cli/state.js +40 -0
- package/dist/src/protocol/attendance.js +20 -0
- package/dist/src/protocol/index.js +7 -0
- package/dist/src/protocol/instructions.js +29 -0
- package/dist/src/protocol/mentions.js +48 -0
- package/dist/src/protocol/messages.js +71 -0
- package/dist/src/protocol/types.js +1 -0
- package/dist/src/protocol/urls.js +9 -0
- package/dist/src/protocol/validation.js +21 -0
- package/dist/src/server/errors.js +12 -0
- package/dist/src/server/http.js +583 -0
- package/dist/src/server/index.js +2 -0
- package/dist/src/server/wait.js +44 -0
- package/dist/src/storage/index.js +4 -0
- package/dist/src/storage/lock.js +93 -0
- package/dist/src/storage/paths.js +18 -0
- package/dist/src/storage/room-store.js +302 -0
- package/dist/src/storage/secure-fs.js +28 -0
- package/dist/src/tunnel/broker.js +440 -0
- package/dist/src/tunnel/client.js +144 -0
- package/dist/src/tunnel/forwarding.js +176 -0
- package/dist/src/tunnel/host-session.js +133 -0
- package/dist/src/tunnel/index.js +8 -0
- package/dist/src/tunnel/limits.js +81 -0
- package/dist/src/tunnel/logging.js +70 -0
- package/dist/src/tunnel/protocol.js +46 -0
- package/dist/src/tunnel/relay.js +106 -0
- package/docs/FOUNDING-TICKETS.md +759 -0
- package/docs/PROPOSAL.md +2120 -0
- package/docs/agentgather-dev-deployment-guide.md +305 -0
- package/docs/agentgather-dev-tunnel-architecture.md +349 -0
- package/docs/deploy-rooms-agentgather-dev.md +152 -0
- package/docs/dogfood/release-dogfood.md +61 -0
- package/docs/dogfood/sanitized-room-log.jsonl +6 -0
- package/docs/host-guide.md +282 -0
- package/docs/operator-runbook.md +248 -0
- package/docs/remote-exposure.md +269 -0
- package/docs/room-brief-and-attend-card.md +110 -0
- package/package.json +49 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Agent Gather Room</title>
|
|
7
|
+
<link rel="stylesheet" href="room.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<main class="room-shell" data-state="loading">
|
|
11
|
+
<header class="topbar">
|
|
12
|
+
<div class="room-heading">
|
|
13
|
+
<div class="eyebrow">Agent Gather</div>
|
|
14
|
+
<h1 id="room-title">Room</h1>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="topbar-meta">
|
|
17
|
+
<span id="room-status" class="status-dot">loading</span>
|
|
18
|
+
<span id="attendance-policy">manual-ok</span>
|
|
19
|
+
<span id="participant-count">0 participants</span>
|
|
20
|
+
<button id="roster-toggle" class="icon-button" type="button" aria-label="Toggle roster">Menu</button>
|
|
21
|
+
</div>
|
|
22
|
+
</header>
|
|
23
|
+
|
|
24
|
+
<section id="auth-error" class="auth-error" hidden>
|
|
25
|
+
<h2>Invite link required</h2>
|
|
26
|
+
<p>This room needs an invite link or Attend Card from the host. Ask the host for a browser invite URL.</p>
|
|
27
|
+
</section>
|
|
28
|
+
|
|
29
|
+
<section id="join-panel" class="auth-error" hidden>
|
|
30
|
+
<h2>Choose your room name</h2>
|
|
31
|
+
<p>This name is shown in the roster and message timeline. The server still derives your account from the invite token.</p>
|
|
32
|
+
<form id="join-form" class="join-form">
|
|
33
|
+
<label>
|
|
34
|
+
Display name
|
|
35
|
+
<input id="display-name" name="display-name" autocomplete="nickname" maxlength="60" required>
|
|
36
|
+
</label>
|
|
37
|
+
<button id="join-button" type="submit">Join Room</button>
|
|
38
|
+
<div id="join-error" class="send-error" role="alert" hidden></div>
|
|
39
|
+
</form>
|
|
40
|
+
</section>
|
|
41
|
+
|
|
42
|
+
<details class="brief-panel" aria-live="polite" open>
|
|
43
|
+
<summary class="brief-heading">
|
|
44
|
+
<span>Room Brief</span>
|
|
45
|
+
<span id="brief-version">v0</span>
|
|
46
|
+
</summary>
|
|
47
|
+
<button id="brief-refresh" class="brief-refresh" type="button" hidden>Brief updated. Refresh</button>
|
|
48
|
+
<p id="brief-body">Loading brief...</p>
|
|
49
|
+
</details>
|
|
50
|
+
|
|
51
|
+
<section class="workspace">
|
|
52
|
+
<section class="timeline-wrap" aria-label="Room messages">
|
|
53
|
+
<label class="system-filter">
|
|
54
|
+
<input id="system-filter" type="checkbox" checked>
|
|
55
|
+
Show system messages
|
|
56
|
+
</label>
|
|
57
|
+
<div id="empty-state" class="empty-state">No messages yet.</div>
|
|
58
|
+
<ol id="timeline" class="timeline" aria-live="polite"></ol>
|
|
59
|
+
</section>
|
|
60
|
+
|
|
61
|
+
<aside id="roster" class="roster" aria-label="Participants">
|
|
62
|
+
<div class="rail-title">Participants</div>
|
|
63
|
+
<ul id="participant-list"></ul>
|
|
64
|
+
<div class="rail-actions">
|
|
65
|
+
<button id="export-button" type="button">Export</button>
|
|
66
|
+
<button id="close-button" type="button">Close Room</button>
|
|
67
|
+
</div>
|
|
68
|
+
</aside>
|
|
69
|
+
</section>
|
|
70
|
+
|
|
71
|
+
<form id="composer" class="composer">
|
|
72
|
+
<div id="reply-indicator" class="reply-indicator" hidden></div>
|
|
73
|
+
<div id="send-error" class="send-error" role="alert" hidden></div>
|
|
74
|
+
<textarea id="message-text" name="text" rows="1" autocomplete="off" placeholder="@participant message"></textarea>
|
|
75
|
+
<button id="send-button" type="submit">Send</button>
|
|
76
|
+
</form>
|
|
77
|
+
</main>
|
|
78
|
+
<script src="room.js" type="module"></script>
|
|
79
|
+
</body>
|
|
80
|
+
</html>
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
const state = {
|
|
2
|
+
token: null,
|
|
3
|
+
cursor: 0,
|
|
4
|
+
seen: new Set(),
|
|
5
|
+
participants: new Set(),
|
|
6
|
+
participantLabels: new Map(),
|
|
7
|
+
profile: null,
|
|
8
|
+
roomStatus: "open",
|
|
9
|
+
briefVersion: 0,
|
|
10
|
+
replyTo: null,
|
|
11
|
+
composing: false
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const shell = document.querySelector(".room-shell");
|
|
15
|
+
const authError = document.getElementById("auth-error");
|
|
16
|
+
const joinPanel = document.getElementById("join-panel");
|
|
17
|
+
const joinForm = document.getElementById("join-form");
|
|
18
|
+
const displayNameInput = document.getElementById("display-name");
|
|
19
|
+
const joinError = document.getElementById("join-error");
|
|
20
|
+
const roomTitle = document.getElementById("room-title");
|
|
21
|
+
const roomStatus = document.getElementById("room-status");
|
|
22
|
+
const attendancePolicy = document.getElementById("attendance-policy");
|
|
23
|
+
const participantCount = document.getElementById("participant-count");
|
|
24
|
+
const briefVersion = document.getElementById("brief-version");
|
|
25
|
+
const briefBody = document.getElementById("brief-body");
|
|
26
|
+
const briefRefresh = document.getElementById("brief-refresh");
|
|
27
|
+
const emptyState = document.getElementById("empty-state");
|
|
28
|
+
const timeline = document.getElementById("timeline");
|
|
29
|
+
const systemFilter = document.getElementById("system-filter");
|
|
30
|
+
const participantList = document.getElementById("participant-list");
|
|
31
|
+
const rosterToggle = document.getElementById("roster-toggle");
|
|
32
|
+
const composer = document.getElementById("composer");
|
|
33
|
+
const messageText = document.getElementById("message-text");
|
|
34
|
+
const sendButton = document.getElementById("send-button");
|
|
35
|
+
const sendError = document.getElementById("send-error");
|
|
36
|
+
const replyIndicator = document.getElementById("reply-indicator");
|
|
37
|
+
const closeButton = document.getElementById("close-button");
|
|
38
|
+
const exportButton = document.getElementById("export-button");
|
|
39
|
+
|
|
40
|
+
init().catch((error) => showError(error instanceof Error ? error.message : String(error)));
|
|
41
|
+
|
|
42
|
+
async function init() {
|
|
43
|
+
const token = tokenFromFragment() || sessionStorage.getItem("agentgather.token");
|
|
44
|
+
if (!token) {
|
|
45
|
+
authError.hidden = false;
|
|
46
|
+
shell.dataset.state = "auth-error";
|
|
47
|
+
window.addEventListener("hashchange", () => {
|
|
48
|
+
const nextToken = tokenFromFragment();
|
|
49
|
+
if (nextToken) {
|
|
50
|
+
authError.hidden = true;
|
|
51
|
+
void startWithToken(nextToken);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
await startWithToken(token);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function startWithToken(token) {
|
|
60
|
+
state.token = token;
|
|
61
|
+
sessionStorage.setItem("agentgather.token", state.token);
|
|
62
|
+
state.profile = (await authFetch("/profile")).participant;
|
|
63
|
+
if (state.profile.kind === "human" && !state.profile.display_name) {
|
|
64
|
+
joinPanel.hidden = false;
|
|
65
|
+
shell.dataset.state = "joining";
|
|
66
|
+
bindJoinForm();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await enterRoom();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function enterRoom() {
|
|
73
|
+
joinPanel.hidden = true;
|
|
74
|
+
await Promise.all([loadBrief(), loadStatus()]);
|
|
75
|
+
await pollMessages();
|
|
76
|
+
setInterval(() => void pollMessages(), 3000);
|
|
77
|
+
setInterval(() => void loadStatus(), 5000);
|
|
78
|
+
bindEvents();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function bindJoinForm() {
|
|
82
|
+
joinForm.addEventListener("submit", (event) => {
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
void submitProfile();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function submitProfile() {
|
|
89
|
+
joinError.hidden = true;
|
|
90
|
+
const displayName = displayNameInput.value.trim();
|
|
91
|
+
if (!displayName) return;
|
|
92
|
+
try {
|
|
93
|
+
const payload = await authFetch("/profile", {
|
|
94
|
+
method: "POST",
|
|
95
|
+
body: JSON.stringify({ display_name: displayName })
|
|
96
|
+
});
|
|
97
|
+
state.profile = payload.participant;
|
|
98
|
+
await enterRoom();
|
|
99
|
+
} catch (error) {
|
|
100
|
+
joinError.hidden = false;
|
|
101
|
+
joinError.textContent = error instanceof Error ? error.message : String(error);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function bindEvents() {
|
|
106
|
+
rosterToggle.addEventListener("click", () => shell.classList.toggle("roster-open"));
|
|
107
|
+
briefRefresh.addEventListener("click", () => void loadBrief());
|
|
108
|
+
systemFilter.addEventListener("change", () => {
|
|
109
|
+
timeline.classList.toggle("hide-system", !systemFilter.checked);
|
|
110
|
+
});
|
|
111
|
+
messageText.addEventListener("input", autoGrowComposer);
|
|
112
|
+
messageText.addEventListener("compositionstart", () => {
|
|
113
|
+
state.composing = true;
|
|
114
|
+
});
|
|
115
|
+
messageText.addEventListener("compositionend", () => {
|
|
116
|
+
state.composing = false;
|
|
117
|
+
});
|
|
118
|
+
messageText.addEventListener("keydown", (event) => {
|
|
119
|
+
if (event.key === "Enter" && !event.shiftKey && !event.isComposing && !state.composing) {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
void submitMessage();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
composer.addEventListener("submit", (event) => {
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
void submitMessage();
|
|
127
|
+
});
|
|
128
|
+
closeButton.addEventListener("click", () => void closeRoom());
|
|
129
|
+
exportButton.addEventListener("click", exportRoom);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function tokenFromFragment() {
|
|
133
|
+
const fragment = new URLSearchParams(window.location.hash.slice(1));
|
|
134
|
+
const token = fragment.get("token");
|
|
135
|
+
if (token) {
|
|
136
|
+
history.replaceState(null, "", window.location.pathname + window.location.search);
|
|
137
|
+
}
|
|
138
|
+
return token;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function loadBrief() {
|
|
142
|
+
const payload = await authFetch("/brief");
|
|
143
|
+
const brief = payload.brief;
|
|
144
|
+
const changed = state.briefVersion !== 0 && state.briefVersion !== brief.brief_version;
|
|
145
|
+
state.briefVersion = brief.brief_version;
|
|
146
|
+
briefVersion.textContent = changed ? `v${brief.brief_version} updated` : `v${brief.brief_version}`;
|
|
147
|
+
briefRefresh.hidden = true;
|
|
148
|
+
briefBody.textContent = brief.body || "(empty)";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function loadStatus() {
|
|
152
|
+
const payload = await authFetch("/status");
|
|
153
|
+
state.roomStatus = payload.room_status;
|
|
154
|
+
shell.dataset.state = payload.room_status;
|
|
155
|
+
roomTitle.textContent = payload.room;
|
|
156
|
+
roomStatus.textContent = payload.room_status;
|
|
157
|
+
roomStatus.dataset.status = payload.room_status;
|
|
158
|
+
attendancePolicy.textContent = payload.attendance_policy || "manual-ok";
|
|
159
|
+
if (payload.brief_version > state.briefVersion) {
|
|
160
|
+
briefRefresh.hidden = false;
|
|
161
|
+
briefVersion.textContent = `v${payload.brief_version} available`;
|
|
162
|
+
}
|
|
163
|
+
state.participants = new Set(payload.participants.map((participant) => participant.alias));
|
|
164
|
+
state.participantLabels = new Map(
|
|
165
|
+
payload.participants.map((participant) => [participant.alias, participant.display_name || participant.alias])
|
|
166
|
+
);
|
|
167
|
+
participantCount.textContent = `${payload.participants.length} participants`;
|
|
168
|
+
renderParticipants(payload.participants);
|
|
169
|
+
closeButton.hidden = !payload.is_host;
|
|
170
|
+
exportButton.hidden = !payload.is_host;
|
|
171
|
+
const closed = payload.room_status === "closed";
|
|
172
|
+
messageText.disabled = closed;
|
|
173
|
+
sendButton.disabled = closed;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function pollMessages() {
|
|
177
|
+
if (state.roomStatus === "closed") return;
|
|
178
|
+
const payload = await authFetch(`/messages?since_id=${state.cursor}`);
|
|
179
|
+
for (const message of payload.messages) {
|
|
180
|
+
if (state.seen.has(message.id)) continue;
|
|
181
|
+
state.seen.add(message.id);
|
|
182
|
+
renderMessage(message);
|
|
183
|
+
}
|
|
184
|
+
state.cursor = payload.next_since_id;
|
|
185
|
+
emptyState.hidden = state.seen.size > 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function submitMessage() {
|
|
189
|
+
const text = messageText.value.trim();
|
|
190
|
+
if (!text) return;
|
|
191
|
+
sendError.hidden = true;
|
|
192
|
+
const unknownMentions = findUnknownMentions(text);
|
|
193
|
+
if (unknownMentions.length > 0) {
|
|
194
|
+
sendError.hidden = false;
|
|
195
|
+
sendError.textContent = `${unknownMentions.map((alias) => `@${alias}`).join(", ")} not in this room; not delivered as a mention.`;
|
|
196
|
+
}
|
|
197
|
+
const body = {
|
|
198
|
+
text,
|
|
199
|
+
client_msg_id: `browser-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
200
|
+
};
|
|
201
|
+
if (state.replyTo !== null) body.reply_to = state.replyTo;
|
|
202
|
+
let payload;
|
|
203
|
+
try {
|
|
204
|
+
payload = await authFetch("/messages", {
|
|
205
|
+
method: "POST",
|
|
206
|
+
body: JSON.stringify(body)
|
|
207
|
+
});
|
|
208
|
+
} catch (error) {
|
|
209
|
+
sendError.hidden = false;
|
|
210
|
+
sendError.textContent = error instanceof Error ? error.message : String(error);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
messageText.value = "";
|
|
214
|
+
state.replyTo = null;
|
|
215
|
+
replyIndicator.hidden = true;
|
|
216
|
+
autoGrowComposer();
|
|
217
|
+
if (payload.message && !state.seen.has(payload.message.id)) {
|
|
218
|
+
state.seen.add(payload.message.id);
|
|
219
|
+
renderMessage(payload.message);
|
|
220
|
+
state.cursor = Math.max(state.cursor, payload.message.id);
|
|
221
|
+
emptyState.hidden = true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function closeRoom() {
|
|
226
|
+
const payload = await authFetch("/close", { method: "POST" });
|
|
227
|
+
state.roomStatus = payload.room_status;
|
|
228
|
+
await loadStatus();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function exportRoom() {
|
|
232
|
+
const rows = [...timeline.querySelectorAll(".message")].map((row) => row.textContent.trim());
|
|
233
|
+
const blob = new Blob([rows.join("\n\n")], { type: "text/plain" });
|
|
234
|
+
const link = document.createElement("a");
|
|
235
|
+
link.href = URL.createObjectURL(blob);
|
|
236
|
+
link.download = "agentgather-room.txt";
|
|
237
|
+
link.click();
|
|
238
|
+
URL.revokeObjectURL(link.href);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function authFetch(path, options = {}) {
|
|
242
|
+
// Resolve room API paths relative to the document base so the app works both
|
|
243
|
+
// when served locally at "/" and through a broker at "/<slug>/".
|
|
244
|
+
const target = new URL(path.replace(/^\//, ""), document.baseURI);
|
|
245
|
+
const response = await fetch(target, {
|
|
246
|
+
...options,
|
|
247
|
+
headers: {
|
|
248
|
+
Authorization: `Bearer ${state.token}`,
|
|
249
|
+
"Content-Type": "application/json",
|
|
250
|
+
...(options.headers || {})
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
const text = await response.text();
|
|
254
|
+
const payload = text ? JSON.parse(text) : {};
|
|
255
|
+
if (!response.ok) throw new Error(payload.message || `HTTP ${response.status}`);
|
|
256
|
+
return payload;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function renderParticipants(participants) {
|
|
260
|
+
participantList.replaceChildren();
|
|
261
|
+
for (const participant of participants) {
|
|
262
|
+
const item = document.createElement("li");
|
|
263
|
+
item.className = "participant";
|
|
264
|
+
item.dataset.attendanceState = participant.attendance_state || participant.attention;
|
|
265
|
+
const name = document.createElement("strong");
|
|
266
|
+
name.textContent = participant.display_name || participant.alias;
|
|
267
|
+
const status = document.createElement("span");
|
|
268
|
+
status.className = "participant-status";
|
|
269
|
+
status.textContent = participantStatusText(participant);
|
|
270
|
+
const meta = document.createElement("span");
|
|
271
|
+
const alias = participant.display_name ? `@${participant.alias} · ` : "";
|
|
272
|
+
meta.textContent = `${alias}${participant.kind} · ${participant.location} · ${participant.install} · ${participant.attendance_state || participant.attention} · ${formatRelative(participant.lastSeenAt)}`;
|
|
273
|
+
item.append(name, status, meta);
|
|
274
|
+
participantList.append(item);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function participantStatusText(participant) {
|
|
279
|
+
const state = participant.attendance_state || participant.attention;
|
|
280
|
+
if (state === "stale") return "stale";
|
|
281
|
+
if (state === "not_attending") return "not attending";
|
|
282
|
+
return state;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function renderMessage(message) {
|
|
286
|
+
const item = document.createElement("li");
|
|
287
|
+
item.className = `message ${message.type === "system" ? "system" : ""}`;
|
|
288
|
+
if (state.profile && message.from === state.profile.alias) item.classList.add("own");
|
|
289
|
+
item.dataset.messageId = String(message.id);
|
|
290
|
+
|
|
291
|
+
const time = document.createElement("time");
|
|
292
|
+
time.className = "message-time";
|
|
293
|
+
time.dateTime = message.ts;
|
|
294
|
+
time.textContent = formatTime(message.ts);
|
|
295
|
+
|
|
296
|
+
if (message.type === "system") {
|
|
297
|
+
const pill = document.createElement("div");
|
|
298
|
+
pill.className = "system-pill";
|
|
299
|
+
const text = document.createElement("span");
|
|
300
|
+
text.className = "message-text";
|
|
301
|
+
appendRichText(text, message.text);
|
|
302
|
+
pill.append(time, text);
|
|
303
|
+
item.append(pill);
|
|
304
|
+
timeline.append(item);
|
|
305
|
+
item.scrollIntoView({ block: "nearest" });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const from = document.createElement("div");
|
|
310
|
+
from.className = "message-from";
|
|
311
|
+
from.textContent = state.participantLabels.get(message.from) || message.from;
|
|
312
|
+
|
|
313
|
+
const text = document.createElement("div");
|
|
314
|
+
text.className = "message-text";
|
|
315
|
+
appendRichText(text, message.text);
|
|
316
|
+
|
|
317
|
+
const meta = document.createElement("div");
|
|
318
|
+
meta.className = "message-meta";
|
|
319
|
+
meta.append(from, time);
|
|
320
|
+
|
|
321
|
+
const bubble = document.createElement("div");
|
|
322
|
+
bubble.className = "message-bubble";
|
|
323
|
+
bubble.append(meta, text);
|
|
324
|
+
|
|
325
|
+
item.addEventListener("dblclick", () => setReply(message));
|
|
326
|
+
item.append(bubble);
|
|
327
|
+
timeline.append(item);
|
|
328
|
+
item.scrollIntoView({ block: "nearest" });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function setReply(message) {
|
|
332
|
+
state.replyTo = message.id;
|
|
333
|
+
replyIndicator.hidden = false;
|
|
334
|
+
replyIndicator.textContent = `Replying to ${message.from} #${message.id}`;
|
|
335
|
+
messageText.focus();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function appendRichText(parent, text) {
|
|
339
|
+
const parts = text.split(/(```[\s\S]*?```|`[^`\n]+`)/g);
|
|
340
|
+
for (const part of parts) {
|
|
341
|
+
if (!part) continue;
|
|
342
|
+
if (part.startsWith("```") && part.endsWith("```")) {
|
|
343
|
+
const pre = document.createElement("pre");
|
|
344
|
+
const code = document.createElement("code");
|
|
345
|
+
code.textContent = part.slice(3, -3).trim();
|
|
346
|
+
pre.append(code);
|
|
347
|
+
parent.append(pre);
|
|
348
|
+
} else if (part.startsWith("`") && part.endsWith("`")) {
|
|
349
|
+
const code = document.createElement("code");
|
|
350
|
+
code.textContent = part.slice(1, -1);
|
|
351
|
+
parent.append(code);
|
|
352
|
+
} else {
|
|
353
|
+
appendTextWithTokens(parent, part);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function appendTextWithTokens(parent, text) {
|
|
359
|
+
const tokenPattern = /(https?:\/\/[^\s<]+|mailto:[^\s<]+|@[a-z0-9-]+)/g;
|
|
360
|
+
let cursor = 0;
|
|
361
|
+
for (const match of text.matchAll(tokenPattern)) {
|
|
362
|
+
const value = match[0];
|
|
363
|
+
const index = match.index || 0;
|
|
364
|
+
appendText(parent, text.slice(cursor, index));
|
|
365
|
+
if (value.startsWith("@") && state.participants.has(value.slice(1))) {
|
|
366
|
+
const mention = document.createElement("span");
|
|
367
|
+
mention.className = "mention";
|
|
368
|
+
mention.textContent = value;
|
|
369
|
+
parent.append(mention);
|
|
370
|
+
} else if (isSafeHref(value)) {
|
|
371
|
+
const link = document.createElement("a");
|
|
372
|
+
link.href = value;
|
|
373
|
+
link.rel = "noreferrer";
|
|
374
|
+
link.target = "_blank";
|
|
375
|
+
link.textContent = value;
|
|
376
|
+
parent.append(link);
|
|
377
|
+
} else {
|
|
378
|
+
appendText(parent, value);
|
|
379
|
+
}
|
|
380
|
+
cursor = index + value.length;
|
|
381
|
+
}
|
|
382
|
+
appendText(parent, text.slice(cursor));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function appendText(parent, text) {
|
|
386
|
+
if (text) parent.append(document.createTextNode(text));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function findUnknownMentions(text) {
|
|
390
|
+
const found = [];
|
|
391
|
+
const seen = new Set();
|
|
392
|
+
for (const match of text.matchAll(/(^|[^\w-])@([a-z0-9-]+)/g)) {
|
|
393
|
+
const alias = match[2];
|
|
394
|
+
if (!alias || state.participants.has(alias) || seen.has(alias)) continue;
|
|
395
|
+
seen.add(alias);
|
|
396
|
+
found.push(alias);
|
|
397
|
+
}
|
|
398
|
+
return found;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function isSafeHref(value) {
|
|
402
|
+
try {
|
|
403
|
+
const url = new URL(value, window.location.href);
|
|
404
|
+
return url.protocol === "http:" || url.protocol === "https:" || url.protocol === "mailto:";
|
|
405
|
+
} catch {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function autoGrowComposer() {
|
|
411
|
+
messageText.style.height = "auto";
|
|
412
|
+
messageText.style.height = `${Math.min(messageText.scrollHeight, 144)}px`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function formatTime(value) {
|
|
416
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
417
|
+
hour: "2-digit",
|
|
418
|
+
minute: "2-digit"
|
|
419
|
+
}).format(new Date(value));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function formatRelative(value) {
|
|
423
|
+
const deltaSeconds = Math.max(0, Math.round((Date.now() - Date.parse(value)) / 1000));
|
|
424
|
+
if (deltaSeconds < 60) return "last seen now";
|
|
425
|
+
const minutes = Math.round(deltaSeconds / 60);
|
|
426
|
+
if (minutes < 60) return `last seen ${minutes}m ago`;
|
|
427
|
+
const hours = Math.round(minutes / 60);
|
|
428
|
+
return `last seen ${hours}h ago`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function showError(message) {
|
|
432
|
+
authError.hidden = false;
|
|
433
|
+
authError.querySelector("p").textContent = message;
|
|
434
|
+
shell.dataset.state = "error";
|
|
435
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function parseArgs(argv) {
|
|
2
|
+
const positional = [];
|
|
3
|
+
const flags = new Map();
|
|
4
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
5
|
+
const arg = argv[index];
|
|
6
|
+
if (arg === undefined)
|
|
7
|
+
continue;
|
|
8
|
+
if (!arg.startsWith("--")) {
|
|
9
|
+
positional.push(arg);
|
|
10
|
+
continue;
|
|
11
|
+
}
|
|
12
|
+
const key = arg.slice(2);
|
|
13
|
+
const next = argv[index + 1];
|
|
14
|
+
if (next === undefined || next.startsWith("--")) {
|
|
15
|
+
flags.set(key, true);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
flags.set(key, next);
|
|
19
|
+
index += 1;
|
|
20
|
+
}
|
|
21
|
+
return { positional, flags };
|
|
22
|
+
}
|
|
23
|
+
export function flagString(args, key) {
|
|
24
|
+
const value = args.flags.get(key);
|
|
25
|
+
return typeof value === "string" ? value : undefined;
|
|
26
|
+
}
|
|
27
|
+
export function flagBoolean(args, key) {
|
|
28
|
+
return args.flags.get(key) === true;
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { flagBoolean, flagString, parseArgs } from "../../args.js";
|
|
2
|
+
import { currentSinceId, formatMessages, parseSinceId, waitOnce } from "../message/transport.js";
|
|
3
|
+
export async function runAttendCommand(argv, context) {
|
|
4
|
+
const args = parseArgs(argv);
|
|
5
|
+
let sinceId = await currentSinceId(context, flagString(args, "since"));
|
|
6
|
+
const maxTurnsRaw = flagString(args, "max-turns");
|
|
7
|
+
const maxTurns = maxTurnsRaw === undefined ? undefined : parseSinceId(maxTurnsRaw);
|
|
8
|
+
let turns = 0;
|
|
9
|
+
while (maxTurns === undefined || turns < maxTurns) {
|
|
10
|
+
const response = await waitOnce(context, sinceId);
|
|
11
|
+
turns += 1;
|
|
12
|
+
sinceId = response.next_since_id;
|
|
13
|
+
if (flagBoolean(args, "json")) {
|
|
14
|
+
context.stdout.write(`${JSON.stringify({
|
|
15
|
+
...response,
|
|
16
|
+
cli_next_cmd: response.keep_waiting ? `agentgather attend --since ${sinceId} --json` : null
|
|
17
|
+
})}\n`);
|
|
18
|
+
}
|
|
19
|
+
else if (response.messages.length > 0) {
|
|
20
|
+
context.stdout.write(formatMessages(response.messages));
|
|
21
|
+
}
|
|
22
|
+
if (!response.keep_waiting)
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createBrokerHttpServer, TunnelBroker } from "../../../tunnel/index.js";
|
|
2
|
+
import { parseArgs, flagString } from "../../args.js";
|
|
3
|
+
export async function runBrokerCommand(argv, context) {
|
|
4
|
+
const [subcommand, ...rest] = argv;
|
|
5
|
+
if (subcommand === "serve")
|
|
6
|
+
return brokerServe(rest, context);
|
|
7
|
+
context.stderr.write(`Unknown broker command: ${subcommand ?? ""}\n`);
|
|
8
|
+
return 1;
|
|
9
|
+
}
|
|
10
|
+
async function brokerServe(argv, context) {
|
|
11
|
+
const args = parseArgs(argv);
|
|
12
|
+
const host = flagString(args, "host") ?? "127.0.0.1";
|
|
13
|
+
const port = parsePort(flagString(args, "port") ?? "8799");
|
|
14
|
+
const publicUrl = parseOptionalHttpUrl(flagString(args, "public-url"));
|
|
15
|
+
// The broker only logs the redaction-safe coarse fields its BrokerLogger
|
|
16
|
+
// allows, so routing them to stdout is safe for systemd/Caddy journals.
|
|
17
|
+
const broker = new TunnelBroker({
|
|
18
|
+
logSink: (record) => context.stdout.write(`${JSON.stringify(record)}\n`)
|
|
19
|
+
});
|
|
20
|
+
const server = createBrokerHttpServer(broker);
|
|
21
|
+
await new Promise((resolve) => {
|
|
22
|
+
server.listen(port, host, resolve);
|
|
23
|
+
});
|
|
24
|
+
context.stdout.write(`Agent Gather broker serving on ${host}:${port}\n`);
|
|
25
|
+
if (publicUrl !== undefined)
|
|
26
|
+
context.stdout.write(`Public URL: ${publicUrl}\n`);
|
|
27
|
+
context.stdout.write("Stores only ephemeral route metadata; no room history, message bodies, or participant tokens.\n");
|
|
28
|
+
await new Promise((resolve) => {
|
|
29
|
+
const stop = () => {
|
|
30
|
+
process.removeListener("SIGINT", stop);
|
|
31
|
+
process.removeListener("SIGTERM", stop);
|
|
32
|
+
server.close(() => resolve());
|
|
33
|
+
};
|
|
34
|
+
process.once("SIGINT", stop);
|
|
35
|
+
process.once("SIGTERM", stop);
|
|
36
|
+
});
|
|
37
|
+
context.stdout.write("Agent Gather broker stopped.\n");
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
function parsePort(value) {
|
|
41
|
+
const port = Number(value);
|
|
42
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
43
|
+
throw new Error("--port must be an integer between 1 and 65535");
|
|
44
|
+
}
|
|
45
|
+
return port;
|
|
46
|
+
}
|
|
47
|
+
function parseOptionalHttpUrl(value) {
|
|
48
|
+
if (value === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
let url;
|
|
51
|
+
try {
|
|
52
|
+
url = new URL(value);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new Error("--public-url must be a valid URL");
|
|
56
|
+
}
|
|
57
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
58
|
+
throw new Error("--public-url must use http or https");
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|