agent-office 0.0.4 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +19 -0
- package/dist/commands/communicator.d.ts +8 -0
- package/dist/commands/communicator.js +610 -0
- package/dist/manage/app.js +4 -3
- package/dist/manage/components/SessionList.d.ts +2 -1
- package/dist/manage/components/SessionList.js +43 -2
- package/dist/manage/hooks/useApi.d.ts +9 -0
- package/dist/manage/hooks/useApi.js +4 -0
- package/dist/server/routes.js +78 -30
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -28,6 +28,25 @@ program
|
|
|
28
28
|
const { manage } = await import("./commands/manage.js");
|
|
29
29
|
await manage(url, options);
|
|
30
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
|
+
.option("--url <url>", "URL of the agent-office serve endpoint (e.g. http://localhost:7654)", process.env.AGENT_OFFICE_URL ?? "http://localhost:7654")
|
|
39
|
+
.option("--secret <secret>", "API password for the agent-office server", process.env.AGENT_OFFICE_PASSWORD)
|
|
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
|
+
if (!options.secret) {
|
|
44
|
+
console.error("Error: --secret is required (or set AGENT_OFFICE_PASSWORD)");
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
const { communicatorWeb } = await import("./commands/communicator.js");
|
|
48
|
+
await communicatorWeb(coworker, options);
|
|
49
|
+
});
|
|
31
50
|
const workerCmd = program
|
|
32
51
|
.command("worker")
|
|
33
52
|
.description("Worker agent commands");
|
|
@@ -0,0 +1,610 @@
|
|
|
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
|
+
.send-btn.sending { background: var(--accent-dim); opacity: 0.7; cursor: not-allowed; pointer-events: none; }
|
|
332
|
+
.send-btn.sending svg { display: none; }
|
|
333
|
+
.send-btn .spinner {
|
|
334
|
+
display: none;
|
|
335
|
+
width: 18px; height: 18px;
|
|
336
|
+
border: 2px solid rgba(255,255,255,0.3);
|
|
337
|
+
border-top-color: #fff;
|
|
338
|
+
border-radius: 50%;
|
|
339
|
+
animation: spin 0.6s linear infinite;
|
|
340
|
+
}
|
|
341
|
+
.send-btn.sending .spinner { display: block; }
|
|
342
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
343
|
+
|
|
344
|
+
/* ── Send error feedback ── */
|
|
345
|
+
#send-status { min-height: 0; }
|
|
346
|
+
.send-err { color: var(--red); font-size: 12px; }
|
|
347
|
+
|
|
348
|
+
/* ── HTMX request indicator ── */
|
|
349
|
+
.htmx-request .send-btn { background: var(--accent-dim); }
|
|
350
|
+
</style>
|
|
351
|
+
</head>
|
|
352
|
+
<body>
|
|
353
|
+
<div class="app">
|
|
354
|
+
|
|
355
|
+
<!-- Header -->
|
|
356
|
+
<div class="header">
|
|
357
|
+
<div class="avatar">${escapeHtml(coworker.charAt(0).toUpperCase())}</div>
|
|
358
|
+
<div class="header-info">
|
|
359
|
+
<div class="header-name">${escapeHtml(coworker)}</div>
|
|
360
|
+
<div class="header-sub"
|
|
361
|
+
id="coworker-status"
|
|
362
|
+
hx-get="/status"
|
|
363
|
+
hx-trigger="load, every 5s"
|
|
364
|
+
hx-swap="innerHTML"></div>
|
|
365
|
+
</div>
|
|
366
|
+
<button class="reset-btn"
|
|
367
|
+
hx-post="/reset"
|
|
368
|
+
hx-target="#reset-status"
|
|
369
|
+
hx-swap="innerHTML"
|
|
370
|
+
hx-confirm="Reset ${escapeHtml(coworker)}'s session? This will revert them to their first message and re-inject the enrollment prompt."
|
|
371
|
+
hx-on::after-request="showResetStatus()"
|
|
372
|
+
title="Reset session">
|
|
373
|
+
↺ Reset
|
|
374
|
+
</button>
|
|
375
|
+
<div class="refresh-indicator" id="refresh-dot"
|
|
376
|
+
hx-get="/ping"
|
|
377
|
+
hx-trigger="every 5s"
|
|
378
|
+
hx-swap="none"
|
|
379
|
+
hx-on::before-request="this.classList.add('active')"
|
|
380
|
+
hx-on::after-request="this.classList.remove('active')"></div>
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
<div id="reset-status"></div>
|
|
384
|
+
|
|
385
|
+
<!-- Messages -->
|
|
386
|
+
<div class="messages-outer" id="messages-outer">
|
|
387
|
+
<div id="messages"
|
|
388
|
+
hx-get="/messages"
|
|
389
|
+
hx-trigger="load, every 5s"
|
|
390
|
+
hx-swap="innerHTML">
|
|
391
|
+
${msgsHtml}
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
<!-- Input -->
|
|
396
|
+
<div class="input-bar">
|
|
397
|
+
<div id="send-status"></div>
|
|
398
|
+
<form class="input-form"
|
|
399
|
+
hx-post="/send"
|
|
400
|
+
hx-target="#send-status"
|
|
401
|
+
hx-swap="innerHTML show:no-scroll"
|
|
402
|
+
hx-on::after-request="handleSent(event)"
|
|
403
|
+
hx-on::before-request="this.querySelector('.send-btn').classList.add('sending')"
|
|
404
|
+
hx-encoding="application/x-www-form-urlencoded">
|
|
405
|
+
<textarea
|
|
406
|
+
class="input-textarea"
|
|
407
|
+
name="body"
|
|
408
|
+
id="msg-input"
|
|
409
|
+
placeholder="Message ${escapeHtml(coworker)}…"
|
|
410
|
+
rows="1"
|
|
411
|
+
autocomplete="off"
|
|
412
|
+
autocorrect="on"
|
|
413
|
+
spellcheck="true"
|
|
414
|
+
onkeydown="handleKey(event)"></textarea>
|
|
415
|
+
<button type="submit" class="send-btn" title="Send">
|
|
416
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
417
|
+
<line x1="22" y1="2" x2="11" y2="13"></line>
|
|
418
|
+
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
|
419
|
+
</svg>
|
|
420
|
+
<div class="spinner"></div>
|
|
421
|
+
</button>
|
|
422
|
+
</form>
|
|
423
|
+
</div>
|
|
424
|
+
|
|
425
|
+
</div>
|
|
426
|
+
|
|
427
|
+
<script>
|
|
428
|
+
const outer = document.getElementById('messages-outer')
|
|
429
|
+
const input = document.getElementById('msg-input')
|
|
430
|
+
|
|
431
|
+
let lastSeenId = parseInt(document.querySelector('#messages-inner')?.dataset?.lastId ?? '0', 10)
|
|
432
|
+
|
|
433
|
+
function scrollToBottom() {
|
|
434
|
+
if (outer) outer.scrollTop = outer.scrollHeight
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function isNearBottom() {
|
|
438
|
+
return outer.scrollHeight - outer.scrollTop - outer.clientHeight < 80
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Auto-grow textarea
|
|
442
|
+
input.addEventListener('input', function() {
|
|
443
|
+
this.style.height = 'auto'
|
|
444
|
+
this.style.height = Math.min(this.scrollHeight, 120) + 'px'
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
// Send on Enter (Shift+Enter = newline)
|
|
448
|
+
function handleKey(e) {
|
|
449
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
450
|
+
e.preventDefault()
|
|
451
|
+
const form = e.target.closest('form')
|
|
452
|
+
if (form && input.value.trim()) htmx.trigger(form, 'submit')
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// After send: clear input, re-enable button, trigger refresh
|
|
457
|
+
function handleSent(event) {
|
|
458
|
+
const form = event.target
|
|
459
|
+
const btn = form.querySelector('.send-btn')
|
|
460
|
+
btn.classList.remove('sending')
|
|
461
|
+
if (event.detail.successful) {
|
|
462
|
+
input.value = ''
|
|
463
|
+
input.style.height = 'auto'
|
|
464
|
+
input.focus()
|
|
465
|
+
// Force scroll on the next swap since we just sent a message
|
|
466
|
+
lastSeenId = -1
|
|
467
|
+
htmx.trigger(document.getElementById('messages'), 'load')
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Only scroll to bottom when new messages actually arrive
|
|
472
|
+
document.addEventListener('htmx:afterSwap', (e) => {
|
|
473
|
+
if (e.detail.target.id !== 'messages') return
|
|
474
|
+
const inner = document.getElementById('messages-inner')
|
|
475
|
+
const newLastId = parseInt(inner?.dataset?.lastId ?? '0', 10)
|
|
476
|
+
if (newLastId > lastSeenId) {
|
|
477
|
+
lastSeenId = newLastId
|
|
478
|
+
if (isNearBottom()) scrollToBottom()
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
// Initial scroll
|
|
483
|
+
scrollToBottom()
|
|
484
|
+
|
|
485
|
+
// Flash the reset status toast then fade it out
|
|
486
|
+
function showResetStatus() {
|
|
487
|
+
const el = document.getElementById('reset-status')
|
|
488
|
+
if (!el) return
|
|
489
|
+
el.classList.add('visible')
|
|
490
|
+
clearTimeout(el._hideTimer)
|
|
491
|
+
el._hideTimer = setTimeout(() => el.classList.remove('visible'), 3000)
|
|
492
|
+
}
|
|
493
|
+
</script>
|
|
494
|
+
</body>
|
|
495
|
+
</html>`;
|
|
496
|
+
}
|
|
497
|
+
// ── Express app ───────────────────────────────────────────────────────────────
|
|
498
|
+
export async function communicatorWeb(coworker, options) {
|
|
499
|
+
const { url: agentUrl, secret, host, port: portStr } = options;
|
|
500
|
+
const port = parseInt(portStr, 10);
|
|
501
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
502
|
+
console.error(`Error: invalid port "${portStr}"`);
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
new URL(agentUrl);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
console.error(`Error: invalid --url "${agentUrl}"`);
|
|
510
|
+
process.exit(1);
|
|
511
|
+
}
|
|
512
|
+
// Resolve human name once at startup
|
|
513
|
+
let humanName = "Human";
|
|
514
|
+
try {
|
|
515
|
+
humanName = await getHumanName(agentUrl, secret);
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
console.error(`Warning: could not fetch human name from ${agentUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
519
|
+
console.error("Check that agent-office serve is running and --secret is correct.");
|
|
520
|
+
}
|
|
521
|
+
console.log(`Communicator: chatting as "${humanName}" with "${coworker}"`);
|
|
522
|
+
const app = express();
|
|
523
|
+
app.use(express.urlencoded({ extended: false }));
|
|
524
|
+
app.use(express.json());
|
|
525
|
+
// ── GET / — full page ────────────────────────────────────────────────────
|
|
526
|
+
app.get("/", async (_req, res) => {
|
|
527
|
+
try {
|
|
528
|
+
const msgs = await fetchMessages(agentUrl, secret, humanName, coworker);
|
|
529
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
530
|
+
res.send(renderPage(coworker, msgs, humanName));
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
534
|
+
res.status(502).send(`<pre>Error connecting to agent-office: ${escapeHtml(msg)}</pre>`);
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
// ── GET /messages — HTMX fragment (polled every 5s) ──────────────────────
|
|
538
|
+
app.get("/messages", async (_req, res) => {
|
|
539
|
+
try {
|
|
540
|
+
const msgs = await fetchMessages(agentUrl, secret, humanName, coworker);
|
|
541
|
+
// Mark any unread received messages as read
|
|
542
|
+
const unread = msgs.filter((m) => m.from_name === coworker && !m.read);
|
|
543
|
+
await Promise.allSettled(unread.map((m) => markRead(agentUrl, secret, m.id)));
|
|
544
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
545
|
+
res.send(renderMessages(msgs, humanName));
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
549
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
550
|
+
res.send(`<div class="empty-state" style="color:#ff6b6b">Error: ${escapeHtml(msg)}</div>`);
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
// ── POST /send — HTMX form submit ────────────────────────────────────────
|
|
554
|
+
app.post("/send", async (req, res) => {
|
|
555
|
+
const body = req.body.body?.trim();
|
|
556
|
+
if (!body) {
|
|
557
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
558
|
+
res.send(`<span class="send-err">Message cannot be empty.</span>`);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
await apiFetch(agentUrl, secret, "/messages", {
|
|
563
|
+
method: "POST",
|
|
564
|
+
body: JSON.stringify({ from: humanName, to: [coworker], body }),
|
|
565
|
+
});
|
|
566
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
567
|
+
res.send("");
|
|
568
|
+
}
|
|
569
|
+
catch (err) {
|
|
570
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
571
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
572
|
+
res.send(`<span class="send-err">Failed: ${escapeHtml(msg)}</span>`);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
// ── GET /status — coworker status fragment (polled every 5s) ────────────
|
|
576
|
+
app.get("/status", async (_req, res) => {
|
|
577
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
578
|
+
try {
|
|
579
|
+
const status = await fetchCoworkerStatus(agentUrl, secret, coworker);
|
|
580
|
+
res.send(status ? escapeHtml(status) : `<span style="color:var(--text-dim)">—</span>`);
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
res.send(`<span style="color:var(--text-dim)">—</span>`);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
// ── POST /reset — revert the coworker's session to first message ─────────
|
|
587
|
+
app.post("/reset", async (_req, res) => {
|
|
588
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
589
|
+
try {
|
|
590
|
+
await apiFetch(agentUrl, secret, `/sessions/${encodeURIComponent(coworker)}/revert-to-start`, {
|
|
591
|
+
method: "POST",
|
|
592
|
+
});
|
|
593
|
+
res.send(`<span style="color:var(--green)">✓ ${escapeHtml(coworker)} reset and restarted.</span>`);
|
|
594
|
+
}
|
|
595
|
+
catch (err) {
|
|
596
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
597
|
+
res.send(`<span style="color:var(--red)">✗ Reset failed: ${escapeHtml(msg)}</span>`);
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
// ── GET /ping — keeps the refresh dot alive ───────────────────────────────
|
|
601
|
+
app.get("/ping", (_req, res) => {
|
|
602
|
+
res.status(204).end();
|
|
603
|
+
});
|
|
604
|
+
app.listen(port, host, () => {
|
|
605
|
+
console.log(`Communicator running at http://${host}:${port}`);
|
|
606
|
+
console.log(`Press Ctrl+C to stop.`);
|
|
607
|
+
});
|
|
608
|
+
// Keep process alive
|
|
609
|
+
await new Promise(() => { });
|
|
610
|
+
}
|
package/dist/manage/app.js
CHANGED
|
@@ -23,7 +23,7 @@ const FOOTER_HINTS = {
|
|
|
23
23
|
connecting: "",
|
|
24
24
|
"auth-error": "",
|
|
25
25
|
menu: "↑↓ navigate · Enter select · q quit",
|
|
26
|
-
list: "↑↓ navigate · c create · d delete · r reveal code · g regen · x revert · t tail · i inject · m mail · M memories · Esc back",
|
|
26
|
+
list: "↑↓ navigate · c create · d delete · r reveal code · g regen · x revert · X revert-all · t tail · i inject · m mail · M memories · Esc back",
|
|
27
27
|
"send-message": "Enter submit · Esc back to menu",
|
|
28
28
|
"my-mail": "↑↓ select message · r reply · m mark read · a mark all read · s sent tab · Esc back",
|
|
29
29
|
"profile": "Enter submit · Esc back to menu",
|
|
@@ -44,6 +44,7 @@ export function App({ serverUrl, password }) {
|
|
|
44
44
|
const [termWidth, setTermWidth] = useState(stdout?.columns ?? 80);
|
|
45
45
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
46
46
|
const [replyTo, setReplyTo] = useState(null);
|
|
47
|
+
const [subViewActive, setSubViewActive] = useState(false);
|
|
47
48
|
const humanNameRef = useRef("Human");
|
|
48
49
|
// Track terminal size
|
|
49
50
|
useEffect(() => {
|
|
@@ -79,7 +80,7 @@ export function App({ serverUrl, password }) {
|
|
|
79
80
|
return () => clearInterval(timer);
|
|
80
81
|
}, []);
|
|
81
82
|
useInput((input, key) => {
|
|
82
|
-
if (key.escape && SUB_SCREENS.includes(screen)) {
|
|
83
|
+
if (key.escape && SUB_SCREENS.includes(screen) && !subViewActive) {
|
|
83
84
|
setScreen("menu");
|
|
84
85
|
}
|
|
85
86
|
if (input === "q" && screen === "menu") {
|
|
@@ -107,7 +108,7 @@ export function App({ serverUrl, password }) {
|
|
|
107
108
|
case "menu":
|
|
108
109
|
return (_jsxs(Box, { height: contentHeight, flexDirection: "row", flexGrow: 1, children: [_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, flexGrow: 1, children: [_jsx(Text, { bold: true, children: "Main Menu" }), _jsx(MenuSelect, { options: MENU_OPTIONS, onChange: handleMenuSelect, badges: unreadCount > 0 ? { "my-mail": `(${unreadCount})` } : {} })] }), _jsx(SessionSidebar, { serverUrl: serverUrl, password: password })] }));
|
|
109
110
|
case "list":
|
|
110
|
-
return (_jsx(Box, { height: contentHeight, flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsx(SessionList, { serverUrl: serverUrl, password: password, onBack: goBack, contentHeight: contentHeight - 2 }) }));
|
|
111
|
+
return (_jsx(Box, { height: contentHeight, flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsx(SessionList, { serverUrl: serverUrl, password: password, onBack: goBack, contentHeight: contentHeight - 2, onSubViewChange: setSubViewActive }) }));
|
|
111
112
|
case "send-message":
|
|
112
113
|
return (_jsx(Box, { height: contentHeight, flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsx(SendMessage, { serverUrl: serverUrl, password: password, onBack: () => { setReplyTo(null); goBack(); }, contentHeight: contentHeight - 2, initialRecipient: replyTo ?? undefined }) }));
|
|
113
114
|
case "profile":
|
|
@@ -3,6 +3,7 @@ interface SessionListProps {
|
|
|
3
3
|
password: string;
|
|
4
4
|
onBack: () => void;
|
|
5
5
|
contentHeight: number;
|
|
6
|
+
onSubViewChange?: (active: boolean) => void;
|
|
6
7
|
}
|
|
7
|
-
export declare function SessionList({ serverUrl, password, contentHeight }: SessionListProps): import("react/jsx-runtime").JSX.Element;
|
|
8
|
+
export declare function SessionList({ serverUrl, password, contentHeight, onSubViewChange }: SessionListProps): import("react/jsx-runtime").JSX.Element;
|
|
8
9
|
export {};
|
|
@@ -473,8 +473,8 @@ function MemoryView({ serverUrl, password, sessionName, contentHeight, onClose }
|
|
|
473
473
|
}) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191\u2193 navigate \u00B7 Enter view \u00B7 a add \u00B7 d delete \u00B7 s search \u00B7 Esc back" }) })] }));
|
|
474
474
|
}
|
|
475
475
|
// ─── Main component ──────────────────────────────────────────────────────────
|
|
476
|
-
export function SessionList({ serverUrl, password, contentHeight }) {
|
|
477
|
-
const { listSessions, createSession, deleteSession, regenerateCode, getModes, revertToStart } = useApi(serverUrl, password);
|
|
476
|
+
export function SessionList({ serverUrl, password, contentHeight, onSubViewChange }) {
|
|
477
|
+
const { listSessions, createSession, deleteSession, regenerateCode, getModes, revertToStart, revertAll } = useApi(serverUrl, password);
|
|
478
478
|
const { data: sessions, loading, error: loadError, run } = useAsyncState();
|
|
479
479
|
const [cursor, setCursor] = useState(0);
|
|
480
480
|
const [revealedRows, setRevealedRows] = useState(new Set());
|
|
@@ -487,6 +487,11 @@ export function SessionList({ serverUrl, password, contentHeight }) {
|
|
|
487
487
|
const [modeCursor, setModeCursor] = useState(0);
|
|
488
488
|
const reload = () => void run(listSessions);
|
|
489
489
|
useEffect(() => { reload(); }, []);
|
|
490
|
+
// Notify parent when a sub-view becomes active/inactive so the App-level
|
|
491
|
+
// Esc handler doesn't skip over this page and jump straight to the menu.
|
|
492
|
+
useEffect(() => {
|
|
493
|
+
onSubViewChange?.(subView !== null);
|
|
494
|
+
}, [subView, onSubViewChange]);
|
|
490
495
|
const rows = sessions ?? [];
|
|
491
496
|
// Clamp cursor when list shrinks
|
|
492
497
|
useEffect(() => {
|
|
@@ -544,6 +549,11 @@ export function SessionList({ serverUrl, password, contentHeight }) {
|
|
|
544
549
|
setActionMsg(null);
|
|
545
550
|
setMode("confirm-revert");
|
|
546
551
|
}
|
|
552
|
+
if (input === "X" && rows.length > 0) {
|
|
553
|
+
setActionError(null);
|
|
554
|
+
setActionMsg(null);
|
|
555
|
+
setMode("confirm-revert-all");
|
|
556
|
+
}
|
|
547
557
|
if (rows.length > 0) {
|
|
548
558
|
if (input === "t")
|
|
549
559
|
setSubView("tail");
|
|
@@ -576,6 +586,10 @@ export function SessionList({ serverUrl, password, contentHeight }) {
|
|
|
576
586
|
setMode("browse");
|
|
577
587
|
return;
|
|
578
588
|
}
|
|
589
|
+
if (mode === "confirm-revert-all" && key.escape) {
|
|
590
|
+
setMode("browse");
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
579
593
|
// Dismiss feedback states with any key
|
|
580
594
|
if (mode === "create-done" || mode === "create-error" || mode === "delete-done" || mode === "delete-error") {
|
|
581
595
|
setMode("browse");
|
|
@@ -672,6 +686,29 @@ export function SessionList({ serverUrl, password, contentHeight }) {
|
|
|
672
686
|
setMode("create-error");
|
|
673
687
|
}
|
|
674
688
|
};
|
|
689
|
+
const handleConfirmRevertAll = async (confirmed) => {
|
|
690
|
+
if (!confirmed) {
|
|
691
|
+
setMode("browse");
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
setMode("reverting-all");
|
|
695
|
+
try {
|
|
696
|
+
const result = await revertAll();
|
|
697
|
+
const failed = result.results.filter((r) => !r.ok);
|
|
698
|
+
if (failed.length === 0) {
|
|
699
|
+
setActionMsg(`All ${result.total} session(s) reverted and restarted.`);
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
setActionMsg(`Reverted ${result.total - failed.length}/${result.total} sessions. Failed: ${failed.map((f) => f.name).join(", ")}`);
|
|
703
|
+
}
|
|
704
|
+
setMode("create-done");
|
|
705
|
+
reload();
|
|
706
|
+
}
|
|
707
|
+
catch (err) {
|
|
708
|
+
setActionError(err instanceof Error ? err.message : String(err));
|
|
709
|
+
setMode("create-error");
|
|
710
|
+
}
|
|
711
|
+
};
|
|
675
712
|
// ── Full-screen create flow ───────────────────────────────────────────────
|
|
676
713
|
if (mode === "creating-loading") {
|
|
677
714
|
return (_jsx(Box, { height: contentHeight, alignItems: "center", justifyContent: "center", children: _jsx(Spinner, { label: "Loading agent configs..." }) }));
|
|
@@ -743,6 +780,10 @@ export function SessionList({ serverUrl, password, contentHeight }) {
|
|
|
743
780
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Revert to First Message" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["Revert ", _jsx(Text, { color: "cyan", bold: true, children: target.name }), " to its first message and restart?", " ", _jsx(Text, { dimColor: true, children: "This clears all messages after the first one." })] }) }), _jsx(Box, { marginTop: 1, children: _jsx(ConfirmInput, { defaultChoice: "cancel", onConfirm: () => void handleConfirmRevert(true), onCancel: () => void handleConfirmRevert(false) }) })] }));
|
|
744
781
|
if (mode === "reverting")
|
|
745
782
|
return (_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, marginBottom: 1, children: _jsx(Spinner, { label: `Reverting "${rows[cursor]?.name}" and restarting...` }) }));
|
|
783
|
+
if (mode === "confirm-revert-all")
|
|
784
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Reset All Sessions" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: ["Revert ", _jsxs(Text, { color: "red", bold: true, children: ["all ", rows.length, " coworker(s)"] }), " to their first message and restart?", " ", _jsx(Text, { dimColor: true, children: "This clears all messages after the first one for every session." })] }) }), _jsx(Box, { marginTop: 1, children: _jsx(ConfirmInput, { defaultChoice: "cancel", onConfirm: () => void handleConfirmRevertAll(true), onCancel: () => void handleConfirmRevertAll(false) }) })] }));
|
|
785
|
+
if (mode === "reverting-all")
|
|
786
|
+
return (_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, marginBottom: 1, children: _jsx(Spinner, { label: `Reverting all ${rows.length} session(s) and restarting...` }) }));
|
|
746
787
|
return null;
|
|
747
788
|
};
|
|
748
789
|
// ── Coworker table ────────────────────────────────────────────────────────
|
|
@@ -76,6 +76,15 @@ export declare function useApi(serverUrl: string, password: string): {
|
|
|
76
76
|
ok: boolean;
|
|
77
77
|
messageID: string;
|
|
78
78
|
}>;
|
|
79
|
+
revertAll: () => Promise<{
|
|
80
|
+
ok: boolean;
|
|
81
|
+
total: number;
|
|
82
|
+
results: Array<{
|
|
83
|
+
name: string;
|
|
84
|
+
ok: boolean;
|
|
85
|
+
error?: string;
|
|
86
|
+
}>;
|
|
87
|
+
}>;
|
|
79
88
|
regenerateCode: (name: string) => Promise<Session>;
|
|
80
89
|
getConfig: () => Promise<Config>;
|
|
81
90
|
setConfig: (key: string, value: string) => Promise<{
|
|
@@ -60,6 +60,9 @@ export function useApi(serverUrl, password) {
|
|
|
60
60
|
const revertToStart = useCallback(async (name) => {
|
|
61
61
|
return apiFetch(`${base}/sessions/${encodeURIComponent(name)}/revert-to-start`, password, { method: "POST" });
|
|
62
62
|
}, [base, password]);
|
|
63
|
+
const revertAll = useCallback(async () => {
|
|
64
|
+
return apiFetch(`${base}/sessions/revert-all`, password, { method: "POST" });
|
|
65
|
+
}, [base, password]);
|
|
63
66
|
const regenerateCode = useCallback(async (name) => {
|
|
64
67
|
return apiFetch(`${base}/sessions/${encodeURIComponent(name)}/regenerate-code`, password, { method: "POST" });
|
|
65
68
|
}, [base, password]);
|
|
@@ -132,6 +135,7 @@ export function useApi(serverUrl, password) {
|
|
|
132
135
|
getMessages,
|
|
133
136
|
injectText,
|
|
134
137
|
revertToStart,
|
|
138
|
+
revertAll,
|
|
135
139
|
regenerateCode,
|
|
136
140
|
getConfig,
|
|
137
141
|
setConfig,
|
package/dist/server/routes.js
CHANGED
|
@@ -363,6 +363,9 @@ export function createRouter(sql, opencode, serverUrl, scheduler, memoryManager)
|
|
|
363
363
|
res.status(500).json({ error: "Failed to get first message ID" });
|
|
364
364
|
return;
|
|
365
365
|
}
|
|
366
|
+
// Abort any in-progress generation before reverting, to avoid "session is busy" errors.
|
|
367
|
+
// Swallow errors — the session may not be busy, in which case abort is a no-op.
|
|
368
|
+
await opencode.session.abort(session.session_id).catch(() => { });
|
|
366
369
|
await opencode.session.revert(session.session_id, { messageID: firstMessage.info.id });
|
|
367
370
|
const providers = await opencode.app.providers();
|
|
368
371
|
const defaultEntry = Object.entries(providers.default)[0];
|
|
@@ -384,6 +387,49 @@ export function createRouter(sql, opencode, serverUrl, scheduler, memoryManager)
|
|
|
384
387
|
res.status(502).json({ error: "Failed to revert session", detail: String(err) });
|
|
385
388
|
}
|
|
386
389
|
});
|
|
390
|
+
router.post("/sessions/revert-all", async (_req, res) => {
|
|
391
|
+
const allSessions = await sql `
|
|
392
|
+
SELECT id, name, session_id, agent_code, mode, status, created_at FROM sessions
|
|
393
|
+
`;
|
|
394
|
+
const providers = await opencode.app.providers();
|
|
395
|
+
const defaultEntry = Object.entries(providers.default)[0];
|
|
396
|
+
if (!defaultEntry) {
|
|
397
|
+
res.status(502).json({ error: "No default model configured in OpenCode" });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const results = [];
|
|
401
|
+
for (const session of allSessions) {
|
|
402
|
+
try {
|
|
403
|
+
const messages = await opencode.session.messages(session.session_id);
|
|
404
|
+
if (messages.length === 0) {
|
|
405
|
+
results.push({ name: session.name, ok: false, error: "No messages" });
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const firstMessage = messages[0];
|
|
409
|
+
if (!firstMessage?.info?.id) {
|
|
410
|
+
results.push({ name: session.name, ok: false, error: "Failed to get first message ID" });
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
// Abort any in-progress generation before reverting
|
|
414
|
+
await opencode.session.abort(session.session_id).catch(() => { });
|
|
415
|
+
await opencode.session.revert(session.session_id, { messageID: firstMessage.info.id });
|
|
416
|
+
const clockInToken = `${session.agent_code}@${serverUrl}`;
|
|
417
|
+
const enrollmentMessage = `You have been enrolled in the agent office.\n\nTo clock in and receive your full briefing, run:\n\n agent-office worker clock-in ${clockInToken}`;
|
|
418
|
+
await opencode.session.chat(session.session_id, {
|
|
419
|
+
modelID: defaultEntry[0],
|
|
420
|
+
providerID: defaultEntry[1],
|
|
421
|
+
parts: [{ type: "text", text: enrollmentMessage }],
|
|
422
|
+
});
|
|
423
|
+
results.push({ name: session.name, ok: true });
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
console.error(`POST /sessions/revert-all error for "${session.name}":`, err);
|
|
427
|
+
results.push({ name: session.name, ok: false, error: String(err) });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const failed = results.filter((r) => !r.ok);
|
|
431
|
+
res.json({ ok: failed.length === 0, total: allSessions.length, results });
|
|
432
|
+
});
|
|
387
433
|
router.delete("/sessions/:name", async (req, res) => {
|
|
388
434
|
const { name } = req.params;
|
|
389
435
|
const rows = await sql `
|
|
@@ -518,33 +564,34 @@ export function createRouter(sql, opencode, serverUrl, scheduler, memoryManager)
|
|
|
518
564
|
res.status(400).json({ error: "No valid recipients found" });
|
|
519
565
|
return;
|
|
520
566
|
}
|
|
567
|
+
const providers = await opencode.app.providers();
|
|
568
|
+
const defaultEntry = Object.entries(providers.default)[0];
|
|
521
569
|
const results = [];
|
|
522
570
|
for (const recipient of validRecipients) {
|
|
523
|
-
let injected = false;
|
|
524
571
|
const [msgRow] = await sql `
|
|
525
572
|
INSERT INTO messages (from_name, to_name, body)
|
|
526
573
|
VALUES (${trimmedFrom}, ${recipient}, ${trimmedBody})
|
|
527
574
|
RETURNING id, from_name, to_name, body, read, injected, created_at
|
|
528
575
|
`;
|
|
529
|
-
|
|
576
|
+
const msgId = msgRow.id;
|
|
577
|
+
let injected = false;
|
|
578
|
+
if (sessionMap.has(recipient) && defaultEntry) {
|
|
530
579
|
const sessionId = sessionMap.get(recipient);
|
|
531
|
-
const
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const defaultEntry = Object.entries(providers.default)[0];
|
|
535
|
-
if (!defaultEntry)
|
|
536
|
-
return;
|
|
537
|
-
const injectText = `[Message from "${trimmedFrom}"]: ${trimmedBody}${MAIL_INJECTION_BLURB}`;
|
|
538
|
-
return opencode.session.chat(sessionId, {
|
|
580
|
+
const injectText = `[Message from "${trimmedFrom}"]: ${trimmedBody}${MAIL_INJECTION_BLURB}`;
|
|
581
|
+
try {
|
|
582
|
+
await opencode.session.chat(sessionId, {
|
|
539
583
|
modelID: defaultEntry[0],
|
|
540
584
|
providerID: defaultEntry[1],
|
|
541
585
|
parts: [{ type: "text", text: injectText }],
|
|
542
|
-
})
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
}
|
|
586
|
+
});
|
|
587
|
+
await sql `UPDATE messages SET injected = TRUE WHERE id = ${msgId}`;
|
|
588
|
+
injected = true;
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
console.warn(`Warning: could not inject message into session "${recipient}":`, err);
|
|
592
|
+
}
|
|
546
593
|
}
|
|
547
|
-
results.push({ to: recipient, messageId:
|
|
594
|
+
results.push({ to: recipient, messageId: msgId, injected });
|
|
548
595
|
}
|
|
549
596
|
res.status(201).json({ ok: true, results });
|
|
550
597
|
});
|
|
@@ -1182,33 +1229,34 @@ export function createWorkerRouter(sql, opencode, serverUrl, memoryManager) {
|
|
|
1182
1229
|
res.status(400).json({ error: "No valid recipients found" });
|
|
1183
1230
|
return;
|
|
1184
1231
|
}
|
|
1232
|
+
const providers = await opencode.app.providers();
|
|
1233
|
+
const defaultEntry = Object.entries(providers.default)[0];
|
|
1185
1234
|
const results = [];
|
|
1186
1235
|
for (const recipient of validRecipients) {
|
|
1187
|
-
let injected = false;
|
|
1188
1236
|
const [msgRow] = await sql `
|
|
1189
1237
|
INSERT INTO messages (from_name, to_name, body)
|
|
1190
1238
|
VALUES (${session.name}, ${recipient}, ${trimmedBody})
|
|
1191
1239
|
RETURNING id, from_name, to_name, body, read, injected, created_at
|
|
1192
1240
|
`;
|
|
1193
|
-
|
|
1241
|
+
const msgId = msgRow.id;
|
|
1242
|
+
let injected = false;
|
|
1243
|
+
if (sessionMap.has(recipient) && defaultEntry) {
|
|
1194
1244
|
const recipientSessionId = sessionMap.get(recipient);
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
const defaultEntry = Object.entries(providers.default)[0];
|
|
1199
|
-
if (!defaultEntry)
|
|
1200
|
-
return;
|
|
1201
|
-
const injectText = `[Message from "${session.name}"]: ${trimmedBody}${MAIL_INJECTION_BLURB}`;
|
|
1202
|
-
return opencode.session.chat(recipientSessionId, {
|
|
1245
|
+
const injectText = `[Message from "${session.name}"]: ${trimmedBody}${MAIL_INJECTION_BLURB}`;
|
|
1246
|
+
try {
|
|
1247
|
+
await opencode.session.chat(recipientSessionId, {
|
|
1203
1248
|
modelID: defaultEntry[0],
|
|
1204
1249
|
providerID: defaultEntry[1],
|
|
1205
1250
|
parts: [{ type: "text", text: injectText }],
|
|
1206
|
-
})
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
}
|
|
1251
|
+
});
|
|
1252
|
+
await sql `UPDATE messages SET injected = TRUE WHERE id = ${msgId}`;
|
|
1253
|
+
injected = true;
|
|
1254
|
+
}
|
|
1255
|
+
catch (err) {
|
|
1256
|
+
console.warn(`Warning: could not inject message into session "${recipient}":`, err);
|
|
1257
|
+
}
|
|
1210
1258
|
}
|
|
1211
|
-
results.push({ to: recipient, messageId:
|
|
1259
|
+
results.push({ to: recipient, messageId: msgId, injected });
|
|
1212
1260
|
}
|
|
1213
1261
|
res.status(201).json({ ok: true, results });
|
|
1214
1262
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-office",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"description": "Manage OpenCode sessions with named aliases",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"dev:serve": "tsx --watch src/cli.ts serve",
|
|
24
24
|
"dev:manage": "tsx src/cli.ts manage",
|
|
25
25
|
"dev:worker": "tsx src/cli.ts worker",
|
|
26
|
+
"dev:communicator": "tsx src/cli.ts communicator web",
|
|
26
27
|
"build": "tsc",
|
|
27
28
|
"clean": "rm -rf dist",
|
|
28
29
|
"prepublishOnly": "npm run clean && npm run build"
|