agent-office 0.0.3 → 0.0.6
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/cli.js +59 -0
- package/dist/commands/communicator.d.ts +8 -0
- package/dist/commands/communicator.js +595 -0
- package/dist/commands/serve.d.ts +1 -0
- package/dist/commands/serve.js +18 -1
- package/dist/commands/worker.d.ts +4 -0
- package/dist/commands/worker.js +63 -0
- package/dist/manage/app.js +4 -3
- package/dist/manage/components/SessionList.d.ts +2 -1
- package/dist/manage/components/SessionList.js +309 -2
- package/dist/manage/hooks/useApi.d.ts +33 -0
- package/dist/manage/hooks/useApi.js +28 -0
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +3 -3
- package/dist/server/memory.d.ts +64 -0
- package/dist/server/memory.js +214 -0
- package/dist/server/routes.d.ts +3 -2
- package/dist/server/routes.js +347 -32
- package/package.json +4 -1
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ program
|
|
|
13
13
|
.option("--opencode-url <url>", "OpenCode server URL", process.env.OPENCODE_URL ?? "http://localhost:4096")
|
|
14
14
|
.option("--host <host>", "Host to bind to", "127.0.0.1")
|
|
15
15
|
.option("--port <port>", "Port to serve on", "7654")
|
|
16
|
+
.option("--memory-path <path>", "Directory for memory storage (default: ./.memory)", "./.memory")
|
|
16
17
|
.option("--password <password>", "REQUIRED. API password", process.env.AGENT_OFFICE_PASSWORD)
|
|
17
18
|
.action(async (options) => {
|
|
18
19
|
const { serve } = await import("./commands/serve.js");
|
|
@@ -27,6 +28,21 @@ program
|
|
|
27
28
|
const { manage } = await import("./commands/manage.js");
|
|
28
29
|
await manage(url, options);
|
|
29
30
|
});
|
|
31
|
+
const communicatorCmd = program
|
|
32
|
+
.command("communicator")
|
|
33
|
+
.description("[HUMAN ONLY] Communicator interfaces for talking with coworkers");
|
|
34
|
+
communicatorCmd
|
|
35
|
+
.command("web")
|
|
36
|
+
.description("[HUMAN ONLY] Launch a web chat interface for a single coworker")
|
|
37
|
+
.argument("<coworker>", "Name of the coworker to chat with (e.g. 'Howard Roark')")
|
|
38
|
+
.requiredOption("--url <url>", "URL of the agent-office serve endpoint (e.g. http://localhost:7654)")
|
|
39
|
+
.requiredOption("--secret <secret>", "API password for the agent-office server")
|
|
40
|
+
.option("--host <host>", "Host to bind the communicator web server to", "127.0.0.1")
|
|
41
|
+
.option("--port <port>", "Port to run the communicator web server on", "7655")
|
|
42
|
+
.action(async (coworker, options) => {
|
|
43
|
+
const { communicatorWeb } = await import("./commands/communicator.js");
|
|
44
|
+
await communicatorWeb(coworker, options);
|
|
45
|
+
});
|
|
30
46
|
const workerCmd = program
|
|
31
47
|
.command("worker")
|
|
32
48
|
.description("Worker agent commands");
|
|
@@ -157,4 +173,47 @@ cronCmd
|
|
|
157
173
|
const { cronHistory } = await import("./commands/worker.js");
|
|
158
174
|
await cronHistory(token, cronId);
|
|
159
175
|
});
|
|
176
|
+
// ── Worker Memory Commands (nested) ──────────────────────────────────────────
|
|
177
|
+
const memoryCmd = workerCmd
|
|
178
|
+
.command("memory")
|
|
179
|
+
.description("Manage your persistent memories");
|
|
180
|
+
memoryCmd
|
|
181
|
+
.command("add")
|
|
182
|
+
.argument("<token>", "Agent token in the format <agent_code>@<server-url>")
|
|
183
|
+
.description("Add a new memory")
|
|
184
|
+
.requiredOption("--content <content>", "Memory content to store")
|
|
185
|
+
.action(async (token, options) => {
|
|
186
|
+
const { memoryAdd } = await import("./commands/worker.js");
|
|
187
|
+
await memoryAdd(token, options.content);
|
|
188
|
+
});
|
|
189
|
+
memoryCmd
|
|
190
|
+
.command("search")
|
|
191
|
+
.argument("<token>", "Agent token in the format <agent_code>@<server-url>")
|
|
192
|
+
.description("Search memories using hybrid search (keyword + semantic)")
|
|
193
|
+
.requiredOption("--query <query>", "Search query")
|
|
194
|
+
.option("--limit <limit>", "Maximum results (default 10)", "10")
|
|
195
|
+
.action(async (token, options) => {
|
|
196
|
+
const limit = parseInt(options.limit ?? "10", 10);
|
|
197
|
+
const { memorySearch } = await import("./commands/worker.js");
|
|
198
|
+
await memorySearch(token, options.query, limit);
|
|
199
|
+
});
|
|
200
|
+
memoryCmd
|
|
201
|
+
.command("list")
|
|
202
|
+
.argument("<token>", "Agent token in the format <agent_code>@<server-url>")
|
|
203
|
+
.description("List all stored memories")
|
|
204
|
+
.option("--limit <limit>", "Maximum memories to list (default 50)", "50")
|
|
205
|
+
.action(async (token, options) => {
|
|
206
|
+
const limit = parseInt(options.limit ?? "50", 10);
|
|
207
|
+
const { memoryList } = await import("./commands/worker.js");
|
|
208
|
+
await memoryList(token, limit);
|
|
209
|
+
});
|
|
210
|
+
memoryCmd
|
|
211
|
+
.command("forget")
|
|
212
|
+
.argument("<token>", "Agent token in the format <agent_code>@<server-url>")
|
|
213
|
+
.argument("<memoryId>", "ID of the memory to forget")
|
|
214
|
+
.description("Delete a memory by ID")
|
|
215
|
+
.action(async (token, memoryId) => {
|
|
216
|
+
const { memoryForget } = await import("./commands/worker.js");
|
|
217
|
+
await memoryForget(token, memoryId);
|
|
218
|
+
});
|
|
160
219
|
program.parse();
|
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
// ── API helpers ───────────────────────────────────────────────────────────────
|
|
3
|
+
async function apiFetch(agentUrl, secret, path, init = {}) {
|
|
4
|
+
const res = await fetch(`${agentUrl}${path}`, {
|
|
5
|
+
...init,
|
|
6
|
+
headers: {
|
|
7
|
+
"Content-Type": "application/json",
|
|
8
|
+
"Authorization": `Bearer ${secret}`,
|
|
9
|
+
...(init.headers ?? {}),
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
if (!res.ok) {
|
|
13
|
+
const body = await res.json().catch(() => ({}));
|
|
14
|
+
throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
15
|
+
}
|
|
16
|
+
return res.json();
|
|
17
|
+
}
|
|
18
|
+
async function getHumanName(agentUrl, secret) {
|
|
19
|
+
const cfg = await apiFetch(agentUrl, secret, "/config");
|
|
20
|
+
return cfg.human_name ?? "Human";
|
|
21
|
+
}
|
|
22
|
+
async function fetchCoworkerStatus(agentUrl, secret, coworker) {
|
|
23
|
+
const sessions = await apiFetch(agentUrl, secret, "/sessions");
|
|
24
|
+
const session = sessions.find((s) => s.name === coworker);
|
|
25
|
+
return session?.status ?? null;
|
|
26
|
+
}
|
|
27
|
+
async function fetchMessages(agentUrl, secret, humanName, coworker) {
|
|
28
|
+
const [sent, received] = await Promise.all([
|
|
29
|
+
apiFetch(agentUrl, secret, `/messages/${encodeURIComponent(humanName)}?sent=true`),
|
|
30
|
+
apiFetch(agentUrl, secret, `/messages/${encodeURIComponent(humanName)}`),
|
|
31
|
+
]);
|
|
32
|
+
// sent: from humanName → coworker
|
|
33
|
+
const sentToCoworker = sent.filter((m) => m.to_name === coworker);
|
|
34
|
+
// received: to humanName, from coworker
|
|
35
|
+
const receivedFromCoworker = received.filter((m) => m.from_name === coworker);
|
|
36
|
+
const all = [...sentToCoworker, ...receivedFromCoworker];
|
|
37
|
+
all.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
|
|
38
|
+
return all;
|
|
39
|
+
}
|
|
40
|
+
async function markRead(agentUrl, secret, id) {
|
|
41
|
+
await apiFetch(agentUrl, secret, `/messages/${id}/read`, { method: "POST" });
|
|
42
|
+
}
|
|
43
|
+
// ── HTML helpers ──────────────────────────────────────────────────────────────
|
|
44
|
+
function escapeHtml(str) {
|
|
45
|
+
return str
|
|
46
|
+
.replace(/&/g, "&")
|
|
47
|
+
.replace(/</g, "<")
|
|
48
|
+
.replace(/>/g, ">")
|
|
49
|
+
.replace(/"/g, """)
|
|
50
|
+
.replace(/'/g, "'");
|
|
51
|
+
}
|
|
52
|
+
function formatTime(iso) {
|
|
53
|
+
return new Date(iso).toLocaleString(undefined, {
|
|
54
|
+
month: "short", day: "numeric",
|
|
55
|
+
hour: "2-digit", minute: "2-digit",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function renderMessage(msg, humanName, spacingClass) {
|
|
59
|
+
const isMine = msg.from_name === humanName;
|
|
60
|
+
const bubbleClass = isMine ? "bubble bubble-mine" : "bubble bubble-theirs";
|
|
61
|
+
const wrapClass = `msg-wrap ${isMine ? "msg-wrap-mine" : "msg-wrap-theirs"} ${spacingClass}`;
|
|
62
|
+
const bodyHtml = escapeHtml(msg.body).replace(/\n/g, "<br>");
|
|
63
|
+
const unreadDot = !isMine && !msg.read ? `<span class="unread-dot"></span>` : "";
|
|
64
|
+
return `<div class="${wrapClass}" data-id="${msg.id}">
|
|
65
|
+
<div class="${bubbleClass}">
|
|
66
|
+
<div class="bubble-body">${bodyHtml}</div>
|
|
67
|
+
<div class="bubble-time">${unreadDot}${formatTime(msg.created_at)}</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>`;
|
|
70
|
+
}
|
|
71
|
+
function renderMessages(msgs, humanName) {
|
|
72
|
+
const lastId = msgs.length > 0 ? msgs[msgs.length - 1].id : 0;
|
|
73
|
+
const inner = msgs.length === 0
|
|
74
|
+
? `<div class="empty-state">No messages yet. Say hello!</div>`
|
|
75
|
+
: msgs.map((m, i) => {
|
|
76
|
+
const prev = msgs[i - 1];
|
|
77
|
+
// larger gap when the sender changes, tight gap within a run
|
|
78
|
+
const spacingClass = (!prev || prev.from_name !== m.from_name) ? "gap-sender-change" : "gap-same-sender";
|
|
79
|
+
return renderMessage(m, humanName, spacingClass);
|
|
80
|
+
}).join("\n");
|
|
81
|
+
// data-last-id lets the client detect whether new messages actually arrived
|
|
82
|
+
return `<div id="messages-inner" data-last-id="${lastId}">${inner}</div>`;
|
|
83
|
+
}
|
|
84
|
+
// ── Full page ─────────────────────────────────────────────────────────────────
|
|
85
|
+
function renderPage(coworker, msgs, humanName) {
|
|
86
|
+
const msgsHtml = renderMessages(msgs, humanName);
|
|
87
|
+
return `<!DOCTYPE html>
|
|
88
|
+
<html lang="en">
|
|
89
|
+
<head>
|
|
90
|
+
<meta charset="UTF-8">
|
|
91
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
|
92
|
+
<title>${escapeHtml(coworker)} — agent-office</title>
|
|
93
|
+
<script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
|
|
94
|
+
<style>
|
|
95
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
96
|
+
|
|
97
|
+
:root {
|
|
98
|
+
--bg: #0f1117;
|
|
99
|
+
--surface: #1a1d27;
|
|
100
|
+
--surface2: #22263a;
|
|
101
|
+
--border: #2e3248;
|
|
102
|
+
--accent: #6c8eff;
|
|
103
|
+
--accent-dim: #3d52a0;
|
|
104
|
+
--text: #e2e8f0;
|
|
105
|
+
--text-dim: #8892a4;
|
|
106
|
+
--mine-bg: #2a3a6e;
|
|
107
|
+
--mine-border: #4a6fa5;
|
|
108
|
+
--theirs-bg: #1e2235;
|
|
109
|
+
--theirs-border: #2e3a55;
|
|
110
|
+
--red: #ff6b6b;
|
|
111
|
+
--green: #6bffb8;
|
|
112
|
+
--radius: 18px;
|
|
113
|
+
--radius-sm: 8px;
|
|
114
|
+
--header-h: 56px;
|
|
115
|
+
--input-h: 64px;
|
|
116
|
+
font-size: 16px;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
html, body {
|
|
120
|
+
height: 100%;
|
|
121
|
+
background: var(--bg);
|
|
122
|
+
color: var(--text);
|
|
123
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
124
|
+
overflow: hidden;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ── Layout ── */
|
|
128
|
+
.app {
|
|
129
|
+
display: flex;
|
|
130
|
+
flex-direction: column;
|
|
131
|
+
height: 100dvh;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ── Header ── */
|
|
135
|
+
.header {
|
|
136
|
+
flex-shrink: 0;
|
|
137
|
+
height: var(--header-h);
|
|
138
|
+
background: var(--surface);
|
|
139
|
+
border-bottom: 1px solid var(--border);
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
padding: 0 16px;
|
|
143
|
+
gap: 12px;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.avatar {
|
|
147
|
+
width: 36px;
|
|
148
|
+
height: 36px;
|
|
149
|
+
border-radius: 50%;
|
|
150
|
+
background: var(--accent-dim);
|
|
151
|
+
color: var(--accent);
|
|
152
|
+
display: flex;
|
|
153
|
+
align-items: center;
|
|
154
|
+
justify-content: center;
|
|
155
|
+
font-weight: 700;
|
|
156
|
+
font-size: 15px;
|
|
157
|
+
flex-shrink: 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.header-info { flex: 1; min-width: 0; }
|
|
161
|
+
.header-name {
|
|
162
|
+
font-weight: 600;
|
|
163
|
+
font-size: 15px;
|
|
164
|
+
white-space: nowrap;
|
|
165
|
+
overflow: hidden;
|
|
166
|
+
text-overflow: ellipsis;
|
|
167
|
+
}
|
|
168
|
+
.header-sub {
|
|
169
|
+
font-size: 11px;
|
|
170
|
+
color: var(--text-dim);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.refresh-indicator {
|
|
174
|
+
width: 8px; height: 8px;
|
|
175
|
+
border-radius: 50%;
|
|
176
|
+
background: var(--text-dim);
|
|
177
|
+
flex-shrink: 0;
|
|
178
|
+
transition: background 0.3s;
|
|
179
|
+
}
|
|
180
|
+
.refresh-indicator.active { background: var(--green); }
|
|
181
|
+
|
|
182
|
+
/* ── Reset button ── */
|
|
183
|
+
.reset-btn {
|
|
184
|
+
background: none;
|
|
185
|
+
border: 1px solid var(--border);
|
|
186
|
+
border-radius: var(--radius-sm);
|
|
187
|
+
color: var(--text-dim);
|
|
188
|
+
cursor: pointer;
|
|
189
|
+
font-size: 12px;
|
|
190
|
+
padding: 5px 10px;
|
|
191
|
+
flex-shrink: 0;
|
|
192
|
+
transition: border-color 0.15s, color 0.15s;
|
|
193
|
+
white-space: nowrap;
|
|
194
|
+
}
|
|
195
|
+
.reset-btn:hover { border-color: var(--red); color: var(--red); }
|
|
196
|
+
.reset-btn:active { opacity: 0.7; }
|
|
197
|
+
.reset-btn.htmx-request { opacity: 0.5; pointer-events: none; }
|
|
198
|
+
|
|
199
|
+
#reset-status {
|
|
200
|
+
position: fixed;
|
|
201
|
+
bottom: 80px;
|
|
202
|
+
left: 50%;
|
|
203
|
+
transform: translateX(-50%);
|
|
204
|
+
background: var(--surface2);
|
|
205
|
+
border: 1px solid var(--border);
|
|
206
|
+
border-radius: var(--radius-sm);
|
|
207
|
+
padding: 8px 16px;
|
|
208
|
+
font-size: 13px;
|
|
209
|
+
pointer-events: none;
|
|
210
|
+
opacity: 0;
|
|
211
|
+
transition: opacity 0.2s;
|
|
212
|
+
white-space: nowrap;
|
|
213
|
+
z-index: 10;
|
|
214
|
+
}
|
|
215
|
+
#reset-status.visible { opacity: 1; }
|
|
216
|
+
|
|
217
|
+
/* ── Message list ── */
|
|
218
|
+
.messages-outer {
|
|
219
|
+
flex: 1;
|
|
220
|
+
overflow-y: auto;
|
|
221
|
+
overscroll-behavior: contain;
|
|
222
|
+
padding: 12px 0 4px;
|
|
223
|
+
scroll-behavior: smooth;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* Subtle scrollbar */
|
|
227
|
+
.messages-outer::-webkit-scrollbar { width: 4px; }
|
|
228
|
+
.messages-outer::-webkit-scrollbar-track { background: transparent; }
|
|
229
|
+
.messages-outer::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
|
230
|
+
|
|
231
|
+
#messages { padding: 0 12px; display: flex; flex-direction: column; }
|
|
232
|
+
.gap-same-sender { margin-top: 3px; }
|
|
233
|
+
.gap-sender-change { margin-top: 12px; }
|
|
234
|
+
|
|
235
|
+
.empty-state {
|
|
236
|
+
text-align: center;
|
|
237
|
+
color: var(--text-dim);
|
|
238
|
+
font-size: 14px;
|
|
239
|
+
margin-top: 48px;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* ── Bubbles ── */
|
|
243
|
+
.msg-wrap { display: flex; }
|
|
244
|
+
.msg-wrap-mine { justify-content: flex-end; }
|
|
245
|
+
.msg-wrap-theirs { justify-content: flex-start; }
|
|
246
|
+
|
|
247
|
+
.bubble {
|
|
248
|
+
max-width: min(72%, 480px);
|
|
249
|
+
padding: 10px 14px 6px;
|
|
250
|
+
border-radius: var(--radius);
|
|
251
|
+
word-break: break-word;
|
|
252
|
+
line-height: 1.45;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.bubble-mine {
|
|
256
|
+
background: var(--mine-bg);
|
|
257
|
+
border: 1px solid var(--mine-border);
|
|
258
|
+
border-bottom-right-radius: var(--radius-sm);
|
|
259
|
+
}
|
|
260
|
+
.bubble-theirs {
|
|
261
|
+
background: var(--theirs-bg);
|
|
262
|
+
border: 1px solid var(--theirs-border);
|
|
263
|
+
border-bottom-left-radius: var(--radius-sm);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.bubble-body { font-size: 14.5px; }
|
|
267
|
+
.bubble-time {
|
|
268
|
+
font-size: 10px;
|
|
269
|
+
color: var(--text-dim);
|
|
270
|
+
margin-top: 4px;
|
|
271
|
+
display: flex;
|
|
272
|
+
align-items: center;
|
|
273
|
+
gap: 4px;
|
|
274
|
+
}
|
|
275
|
+
.msg-wrap-mine .bubble-time { justify-content: flex-end; }
|
|
276
|
+
|
|
277
|
+
.unread-dot {
|
|
278
|
+
width: 6px; height: 6px;
|
|
279
|
+
border-radius: 50%;
|
|
280
|
+
background: var(--accent);
|
|
281
|
+
flex-shrink: 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* ── Input bar ── */
|
|
285
|
+
.input-bar {
|
|
286
|
+
flex-shrink: 0;
|
|
287
|
+
background: var(--surface);
|
|
288
|
+
border-top: 1px solid var(--border);
|
|
289
|
+
padding: 10px 12px;
|
|
290
|
+
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.input-form { display: flex; gap: 8px; align-items: flex-end; }
|
|
294
|
+
|
|
295
|
+
.input-textarea {
|
|
296
|
+
flex: 1;
|
|
297
|
+
background: var(--surface2);
|
|
298
|
+
border: 1px solid var(--border);
|
|
299
|
+
border-radius: 22px;
|
|
300
|
+
color: var(--text);
|
|
301
|
+
font-size: 15px;
|
|
302
|
+
line-height: 1.4;
|
|
303
|
+
padding: 10px 16px;
|
|
304
|
+
resize: none;
|
|
305
|
+
min-height: 44px;
|
|
306
|
+
max-height: 120px;
|
|
307
|
+
overflow-y: auto;
|
|
308
|
+
outline: none;
|
|
309
|
+
font-family: inherit;
|
|
310
|
+
transition: border-color 0.15s;
|
|
311
|
+
}
|
|
312
|
+
.input-textarea:focus { border-color: var(--accent-dim); }
|
|
313
|
+
.input-textarea::placeholder { color: var(--text-dim); }
|
|
314
|
+
|
|
315
|
+
.send-btn {
|
|
316
|
+
width: 44px; height: 44px;
|
|
317
|
+
border-radius: 50%;
|
|
318
|
+
background: var(--accent);
|
|
319
|
+
border: none;
|
|
320
|
+
color: #fff;
|
|
321
|
+
cursor: pointer;
|
|
322
|
+
display: flex;
|
|
323
|
+
align-items: center;
|
|
324
|
+
justify-content: center;
|
|
325
|
+
flex-shrink: 0;
|
|
326
|
+
transition: background 0.15s, transform 0.1s;
|
|
327
|
+
}
|
|
328
|
+
.send-btn:hover { background: #7fa0ff; }
|
|
329
|
+
.send-btn:active { transform: scale(0.93); }
|
|
330
|
+
.send-btn svg { width: 20px; height: 20px; }
|
|
331
|
+
|
|
332
|
+
/* ── Send error feedback ── */
|
|
333
|
+
#send-status { min-height: 0; }
|
|
334
|
+
.send-err { color: var(--red); font-size: 12px; }
|
|
335
|
+
|
|
336
|
+
/* ── HTMX request indicator ── */
|
|
337
|
+
.htmx-request .send-btn { background: var(--accent-dim); }
|
|
338
|
+
</style>
|
|
339
|
+
</head>
|
|
340
|
+
<body>
|
|
341
|
+
<div class="app">
|
|
342
|
+
|
|
343
|
+
<!-- Header -->
|
|
344
|
+
<div class="header">
|
|
345
|
+
<div class="avatar">${escapeHtml(coworker.charAt(0).toUpperCase())}</div>
|
|
346
|
+
<div class="header-info">
|
|
347
|
+
<div class="header-name">${escapeHtml(coworker)}</div>
|
|
348
|
+
<div class="header-sub"
|
|
349
|
+
id="coworker-status"
|
|
350
|
+
hx-get="/status"
|
|
351
|
+
hx-trigger="load, every 5s"
|
|
352
|
+
hx-swap="innerHTML"></div>
|
|
353
|
+
</div>
|
|
354
|
+
<button class="reset-btn"
|
|
355
|
+
hx-post="/reset"
|
|
356
|
+
hx-target="#reset-status"
|
|
357
|
+
hx-swap="innerHTML"
|
|
358
|
+
hx-confirm="Reset ${escapeHtml(coworker)}'s session? This will revert them to their first message and re-inject the enrollment prompt."
|
|
359
|
+
hx-on::after-request="showResetStatus()"
|
|
360
|
+
title="Reset session">
|
|
361
|
+
↺ Reset
|
|
362
|
+
</button>
|
|
363
|
+
<div class="refresh-indicator" id="refresh-dot"
|
|
364
|
+
hx-get="/ping"
|
|
365
|
+
hx-trigger="every 5s"
|
|
366
|
+
hx-swap="none"
|
|
367
|
+
hx-on::before-request="this.classList.add('active')"
|
|
368
|
+
hx-on::after-request="this.classList.remove('active')"></div>
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
<div id="reset-status"></div>
|
|
372
|
+
|
|
373
|
+
<!-- Messages -->
|
|
374
|
+
<div class="messages-outer" id="messages-outer">
|
|
375
|
+
<div id="messages"
|
|
376
|
+
hx-get="/messages"
|
|
377
|
+
hx-trigger="load, every 5s"
|
|
378
|
+
hx-swap="innerHTML">
|
|
379
|
+
${msgsHtml}
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
<!-- Input -->
|
|
384
|
+
<div class="input-bar">
|
|
385
|
+
<div id="send-status"></div>
|
|
386
|
+
<form class="input-form"
|
|
387
|
+
hx-post="/send"
|
|
388
|
+
hx-target="#send-status"
|
|
389
|
+
hx-swap="innerHTML show:no-scroll"
|
|
390
|
+
hx-on::after-request="handleSent(event)"
|
|
391
|
+
hx-on::before-request="this.querySelector('.send-btn').disabled=true"
|
|
392
|
+
hx-encoding="application/x-www-form-urlencoded">
|
|
393
|
+
<textarea
|
|
394
|
+
class="input-textarea"
|
|
395
|
+
name="body"
|
|
396
|
+
id="msg-input"
|
|
397
|
+
placeholder="Message ${escapeHtml(coworker)}…"
|
|
398
|
+
rows="1"
|
|
399
|
+
autocomplete="off"
|
|
400
|
+
autocorrect="on"
|
|
401
|
+
spellcheck="true"
|
|
402
|
+
onkeydown="handleKey(event)"></textarea>
|
|
403
|
+
<button type="submit" class="send-btn" title="Send">
|
|
404
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
405
|
+
<line x1="22" y1="2" x2="11" y2="13"></line>
|
|
406
|
+
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
|
407
|
+
</svg>
|
|
408
|
+
</button>
|
|
409
|
+
</form>
|
|
410
|
+
</div>
|
|
411
|
+
|
|
412
|
+
</div>
|
|
413
|
+
|
|
414
|
+
<script>
|
|
415
|
+
const outer = document.getElementById('messages-outer')
|
|
416
|
+
const input = document.getElementById('msg-input')
|
|
417
|
+
|
|
418
|
+
let lastSeenId = parseInt(document.querySelector('#messages-inner')?.dataset?.lastId ?? '0', 10)
|
|
419
|
+
|
|
420
|
+
function scrollToBottom() {
|
|
421
|
+
if (outer) outer.scrollTop = outer.scrollHeight
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function isNearBottom() {
|
|
425
|
+
return outer.scrollHeight - outer.scrollTop - outer.clientHeight < 80
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Auto-grow textarea
|
|
429
|
+
input.addEventListener('input', function() {
|
|
430
|
+
this.style.height = 'auto'
|
|
431
|
+
this.style.height = Math.min(this.scrollHeight, 120) + 'px'
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
// Send on Enter (Shift+Enter = newline)
|
|
435
|
+
function handleKey(e) {
|
|
436
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
437
|
+
e.preventDefault()
|
|
438
|
+
const form = e.target.closest('form')
|
|
439
|
+
if (form && input.value.trim()) htmx.trigger(form, 'submit')
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// After send: clear input, re-enable button, trigger refresh
|
|
444
|
+
function handleSent(event) {
|
|
445
|
+
const form = event.target
|
|
446
|
+
form.querySelector('.send-btn').disabled = false
|
|
447
|
+
if (event.detail.successful) {
|
|
448
|
+
input.value = ''
|
|
449
|
+
input.style.height = 'auto'
|
|
450
|
+
// Force scroll on the next swap since we just sent a message
|
|
451
|
+
lastSeenId = -1
|
|
452
|
+
htmx.trigger(document.getElementById('messages'), 'load')
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Only scroll to bottom when new messages actually arrive
|
|
457
|
+
document.addEventListener('htmx:afterSwap', (e) => {
|
|
458
|
+
if (e.detail.target.id !== 'messages') return
|
|
459
|
+
const inner = document.getElementById('messages-inner')
|
|
460
|
+
const newLastId = parseInt(inner?.dataset?.lastId ?? '0', 10)
|
|
461
|
+
if (newLastId > lastSeenId) {
|
|
462
|
+
lastSeenId = newLastId
|
|
463
|
+
if (isNearBottom()) scrollToBottom()
|
|
464
|
+
}
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
// Initial scroll
|
|
468
|
+
scrollToBottom()
|
|
469
|
+
|
|
470
|
+
// Flash the reset status toast then fade it out
|
|
471
|
+
function showResetStatus() {
|
|
472
|
+
const el = document.getElementById('reset-status')
|
|
473
|
+
if (!el) return
|
|
474
|
+
el.classList.add('visible')
|
|
475
|
+
clearTimeout(el._hideTimer)
|
|
476
|
+
el._hideTimer = setTimeout(() => el.classList.remove('visible'), 3000)
|
|
477
|
+
}
|
|
478
|
+
</script>
|
|
479
|
+
</body>
|
|
480
|
+
</html>`;
|
|
481
|
+
}
|
|
482
|
+
// ── Express app ───────────────────────────────────────────────────────────────
|
|
483
|
+
export async function communicatorWeb(coworker, options) {
|
|
484
|
+
const { url: agentUrl, secret, host, port: portStr } = options;
|
|
485
|
+
const port = parseInt(portStr, 10);
|
|
486
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
487
|
+
console.error(`Error: invalid port "${portStr}"`);
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
new URL(agentUrl);
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
console.error(`Error: invalid --url "${agentUrl}"`);
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
497
|
+
// Resolve human name once at startup
|
|
498
|
+
let humanName = "Human";
|
|
499
|
+
try {
|
|
500
|
+
humanName = await getHumanName(agentUrl, secret);
|
|
501
|
+
}
|
|
502
|
+
catch (err) {
|
|
503
|
+
console.error(`Warning: could not fetch human name from ${agentUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
504
|
+
console.error("Check that agent-office serve is running and --secret is correct.");
|
|
505
|
+
}
|
|
506
|
+
console.log(`Communicator: chatting as "${humanName}" with "${coworker}"`);
|
|
507
|
+
const app = express();
|
|
508
|
+
app.use(express.urlencoded({ extended: false }));
|
|
509
|
+
app.use(express.json());
|
|
510
|
+
// ── GET / — full page ────────────────────────────────────────────────────
|
|
511
|
+
app.get("/", async (_req, res) => {
|
|
512
|
+
try {
|
|
513
|
+
const msgs = await fetchMessages(agentUrl, secret, humanName, coworker);
|
|
514
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
515
|
+
res.send(renderPage(coworker, msgs, humanName));
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
519
|
+
res.status(502).send(`<pre>Error connecting to agent-office: ${escapeHtml(msg)}</pre>`);
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
// ── GET /messages — HTMX fragment (polled every 5s) ──────────────────────
|
|
523
|
+
app.get("/messages", async (_req, res) => {
|
|
524
|
+
try {
|
|
525
|
+
const msgs = await fetchMessages(agentUrl, secret, humanName, coworker);
|
|
526
|
+
// Mark any unread received messages as read
|
|
527
|
+
const unread = msgs.filter((m) => m.from_name === coworker && !m.read);
|
|
528
|
+
await Promise.allSettled(unread.map((m) => markRead(agentUrl, secret, m.id)));
|
|
529
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
530
|
+
res.send(renderMessages(msgs, humanName));
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
534
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
535
|
+
res.send(`<div class="empty-state" style="color:#ff6b6b">Error: ${escapeHtml(msg)}</div>`);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
// ── POST /send — HTMX form submit ────────────────────────────────────────
|
|
539
|
+
app.post("/send", async (req, res) => {
|
|
540
|
+
const body = req.body.body?.trim();
|
|
541
|
+
if (!body) {
|
|
542
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
543
|
+
res.send(`<span class="send-err">Message cannot be empty.</span>`);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
await apiFetch(agentUrl, secret, "/messages", {
|
|
548
|
+
method: "POST",
|
|
549
|
+
body: JSON.stringify({ from: humanName, to: [coworker], body }),
|
|
550
|
+
});
|
|
551
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
552
|
+
res.send("");
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
556
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
557
|
+
res.send(`<span class="send-err">Failed: ${escapeHtml(msg)}</span>`);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
// ── GET /status — coworker status fragment (polled every 5s) ────────────
|
|
561
|
+
app.get("/status", async (_req, res) => {
|
|
562
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
563
|
+
try {
|
|
564
|
+
const status = await fetchCoworkerStatus(agentUrl, secret, coworker);
|
|
565
|
+
res.send(status ? escapeHtml(status) : `<span style="color:var(--text-dim)">—</span>`);
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
res.send(`<span style="color:var(--text-dim)">—</span>`);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
// ── POST /reset — revert the coworker's session to first message ─────────
|
|
572
|
+
app.post("/reset", async (_req, res) => {
|
|
573
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
574
|
+
try {
|
|
575
|
+
await apiFetch(agentUrl, secret, `/sessions/${encodeURIComponent(coworker)}/revert-to-start`, {
|
|
576
|
+
method: "POST",
|
|
577
|
+
});
|
|
578
|
+
res.send(`<span style="color:var(--green)">✓ ${escapeHtml(coworker)} reset and restarted.</span>`);
|
|
579
|
+
}
|
|
580
|
+
catch (err) {
|
|
581
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
582
|
+
res.send(`<span style="color:var(--red)">✗ Reset failed: ${escapeHtml(msg)}</span>`);
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
// ── GET /ping — keeps the refresh dot alive ───────────────────────────────
|
|
586
|
+
app.get("/ping", (_req, res) => {
|
|
587
|
+
res.status(204).end();
|
|
588
|
+
});
|
|
589
|
+
app.listen(port, host, () => {
|
|
590
|
+
console.log(`Communicator running at http://${host}:${port}`);
|
|
591
|
+
console.log(`Press Ctrl+C to stop.`);
|
|
592
|
+
});
|
|
593
|
+
// Keep process alive
|
|
594
|
+
await new Promise(() => { });
|
|
595
|
+
}
|