agent-office 0.3.1 → 0.4.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.
@@ -1,11 +1,11 @@
1
1
  import express from "express";
2
2
  // ── API helpers ───────────────────────────────────────────────────────────────
3
- async function apiFetch(agentUrl, secret, path, init = {}) {
3
+ async function apiFetch(agentUrl, password, path, init = {}) {
4
4
  const res = await fetch(`${agentUrl}${path}`, {
5
5
  ...init,
6
6
  headers: {
7
7
  "Content-Type": "application/json",
8
- "Authorization": `Bearer ${secret}`,
8
+ "Authorization": `Bearer ${password}`,
9
9
  ...(init.headers ?? {}),
10
10
  },
11
11
  });
@@ -15,19 +15,22 @@ async function apiFetch(agentUrl, secret, path, init = {}) {
15
15
  }
16
16
  return res.json();
17
17
  }
18
- async function getHumanName(agentUrl, secret) {
19
- const cfg = await apiFetch(agentUrl, secret, "/config");
18
+ async function getHumanName(agentUrl, password) {
19
+ const cfg = await apiFetch(agentUrl, password, "/config");
20
20
  return cfg.human_name ?? "Human";
21
21
  }
22
- async function fetchCoworkerStatus(agentUrl, secret, coworker) {
23
- const sessions = await apiFetch(agentUrl, secret, "/sessions");
22
+ async function fetchSessions(agentUrl, password) {
23
+ return apiFetch(agentUrl, password, "/sessions");
24
+ }
25
+ async function fetchCoworkerStatus(agentUrl, password, coworker) {
26
+ const sessions = await fetchSessions(agentUrl, password);
24
27
  const session = sessions.find((s) => s.name === coworker);
25
28
  return session?.status ?? null;
26
29
  }
27
- async function fetchMessages(agentUrl, secret, humanName, coworker) {
30
+ async function fetchMessages(agentUrl, password, humanName, coworker) {
28
31
  const [sent, received] = await Promise.all([
29
- apiFetch(agentUrl, secret, `/messages/${encodeURIComponent(humanName)}?sent=true`),
30
- apiFetch(agentUrl, secret, `/messages/${encodeURIComponent(humanName)}`),
32
+ apiFetch(agentUrl, password, `/messages/${encodeURIComponent(humanName)}?sent=true`),
33
+ apiFetch(agentUrl, password, `/messages/${encodeURIComponent(humanName)}`),
31
34
  ]);
32
35
  // sent: from humanName → coworker
33
36
  const sentToCoworker = sent.filter((m) => m.to_name === coworker);
@@ -37,8 +40,8 @@ async function fetchMessages(agentUrl, secret, humanName, coworker) {
37
40
  all.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
38
41
  return all;
39
42
  }
40
- async function markRead(agentUrl, secret, id) {
41
- await apiFetch(agentUrl, secret, `/messages/${id}/read`, { method: "POST" });
43
+ async function markRead(agentUrl, password, id) {
44
+ await apiFetch(agentUrl, password, `/messages/${id}/read`, { method: "POST" });
42
45
  }
43
46
  // ── HTML helpers ──────────────────────────────────────────────────────────────
44
47
  function escapeHtml(str) {
@@ -83,14 +86,31 @@ function renderMessages(msgs, humanName) {
83
86
  return `<div id="messages-inner" data-last-id="${lastId}">${inner}</div>`;
84
87
  }
85
88
  // ── Full page ─────────────────────────────────────────────────────────────────
86
- function renderPage(coworker, msgs, humanName) {
89
+ function renderDropdown(coworker, coworkers) {
90
+ const selected = coworker ?? "";
91
+ const dropdownOptions = coworkers.filter(c => !c.isHuman);
92
+ const totalUnread = dropdownOptions.reduce((sum, c) => sum + (c.unreadMessages ?? 0), 0);
93
+ const unreadDot = totalUnread > 0 ? '<span class="unread-badge"></span>' : '';
94
+ const coworkerOptions = dropdownOptions.map(c => {
95
+ const unreadBadge = c.unreadMessages && c.unreadMessages > 0 ? ` (${c.unreadMessages})` : '';
96
+ return `<option value="${escapeHtml(c.name)}" ${c.name === selected ? 'selected' : ''}>${escapeHtml(c.name)}${unreadBadge}</option>`;
97
+ }).join('');
98
+ return `<select id="coworker-select" class="coworker-select" onchange="switchCoworker(this.value)">
99
+ <option value="">Select coworker…</option>
100
+ ${coworkerOptions}
101
+ </select>
102
+ ${unreadDot}`;
103
+ }
104
+ function renderPage(coworker, coworkers, msgs, humanName) {
87
105
  const msgsHtml = renderMessages(msgs, humanName);
106
+ const selected = coworker ?? "";
107
+ const dropdownHtml = renderDropdown(coworker, coworkers);
88
108
  return `<!DOCTYPE html>
89
109
  <html lang="en">
90
110
  <head>
91
111
  <meta charset="UTF-8">
92
112
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
93
- <title>${escapeHtml(coworker)} — agent-office</title>
113
+ <title>${selected ? escapeHtml(selected) + ' ' : ''}agent-office</title>
94
114
  <script src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"></script>
95
115
  <script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
96
116
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5.5.1/github-markdown-light.min.css" media="(prefers-color-scheme: light)">
@@ -162,6 +182,29 @@ function renderPage(coworker, msgs, humanName) {
162
182
  }
163
183
 
164
184
  .header-info { flex: 1; min-width: 0; }
185
+ .select-wrapper { display: inline-flex; align-items: center; position: relative; }
186
+ .coworker-select {
187
+ font-weight: 600;
188
+ font-size: 15px;
189
+ background: transparent;
190
+ border: none;
191
+ color: var(--text);
192
+ cursor: pointer;
193
+ padding: 0;
194
+ margin: 0;
195
+ outline: none;
196
+ max-width: 180px;
197
+ }
198
+ .coworker-select:focus { color: var(--accent); }
199
+ .coworker-select option { background: var(--surface); color: var(--text); }
200
+ .unread-badge {
201
+ width: 8px;
202
+ height: 8px;
203
+ background: #ff4444;
204
+ border-radius: 50%;
205
+ margin-left: 6px;
206
+ flex-shrink: 0;
207
+ }
165
208
  .header-name {
166
209
  font-weight: 600;
167
210
  font-size: 15px;
@@ -418,22 +461,28 @@ function renderPage(coworker, msgs, humanName) {
418
461
 
419
462
  <!-- Header -->
420
463
  <div class="header">
421
- <div class="avatar">${escapeHtml(coworker.charAt(0).toUpperCase())}</div>
464
+ <div class="avatar">${selected ? escapeHtml(selected.charAt(0).toUpperCase()) : '?'}</div>
422
465
  <div class="header-info">
423
- <div class="header-name">${escapeHtml(coworker)}</div>
466
+ <div class="select-wrapper" id="dropdown-wrapper"
467
+ hx-get="/dropdown?coworker=${encodeURIComponent(selected)}"
468
+ hx-trigger="load, every 5s"
469
+ hx-swap="innerHTML">
470
+ ${dropdownHtml}
471
+ </div>
424
472
  <div class="header-sub"
425
473
  id="coworker-status"
426
- hx-get="/status"
474
+ hx-get="/status?coworker=${encodeURIComponent(selected)}"
427
475
  hx-trigger="load, every 5s"
428
476
  hx-swap="innerHTML"></div>
429
477
  </div>
430
478
  <button class="reset-btn"
431
- hx-post="/reset"
479
+ hx-post="/reset?coworker=${encodeURIComponent(selected)}"
432
480
  hx-target="#reset-status"
433
481
  hx-swap="innerHTML"
434
- hx-confirm="Reset ${escapeHtml(coworker)}'s session? This will revert them to their first message and re-inject the enrollment prompt."
482
+ hx-confirm="Reset session? This will revert them to their first message and re-inject the enrollment prompt."
435
483
  hx-on::after-request="showResetStatus()"
436
- title="Reset session">
484
+ title="Reset session"
485
+ ${!selected ? 'disabled' : ''}>
437
486
  ↺ Reset
438
487
  </button>
439
488
  <div class="refresh-indicator" id="refresh-dot"
@@ -449,7 +498,7 @@ function renderPage(coworker, msgs, humanName) {
449
498
  <!-- Messages -->
450
499
  <div class="messages-outer" id="messages-outer">
451
500
  <div id="messages"
452
- hx-get="/messages"
501
+ hx-get="/messages?coworker=${encodeURIComponent(selected)}"
453
502
  hx-trigger="load, every 5s"
454
503
  hx-swap="innerHTML">
455
504
  ${msgsHtml}
@@ -466,11 +515,12 @@ function renderPage(coworker, msgs, humanName) {
466
515
  hx-on::after-request="handleSent(event)"
467
516
  hx-on::before-request="this.querySelector('.send-btn').classList.add('sending')"
468
517
  hx-encoding="application/x-www-form-urlencoded">
518
+ <input type="hidden" name="coworker" value="${escapeHtml(selected)}">
469
519
  <textarea
470
520
  class="input-textarea"
471
521
  name="body"
472
522
  id="msg-input"
473
- placeholder="Message ${escapeHtml(coworker)}"
523
+ placeholder="${selected ? 'Message ' + escapeHtml(selected) + '…' : 'Select a coworker to message…'}"
474
524
  rows="1"
475
525
  autocomplete="off"
476
526
  autocorrect="on"
@@ -554,6 +604,14 @@ function renderPage(coworker, msgs, humanName) {
554
604
  // Initial scroll
555
605
  scrollToBottom()
556
606
 
607
+ // Switch to a different coworker
608
+ function switchCoworker(name) {
609
+ if (!name) return
610
+ const url = new URL(window.location.href)
611
+ url.searchParams.set('coworker', name)
612
+ window.location.href = url.toString()
613
+ }
614
+
557
615
  // Flash the reset status toast then fade it out
558
616
  function showResetStatus() {
559
617
  const el = document.getElementById('reset-status')
@@ -569,8 +627,9 @@ function renderPage(coworker, msgs, humanName) {
569
627
  document.querySelectorAll('.markdown-body[data-markdown-b64]').forEach(el => {
570
628
  const b64 = el.getAttribute('data-markdown-b64')
571
629
  if (b64 && !el.hasAttribute('data-rendered')) {
572
- // Decode base64 to get original text with preserved newlines
573
- const text = atob(b64)
630
+ // Decode base64 to get original text with preserved newlines and UTF-8 chars
631
+ const binary = atob(b64)
632
+ const text = new TextDecoder().decode(Uint8Array.from(binary, c => c.charCodeAt(0)))
574
633
  el.innerHTML = marked.parse(text)
575
634
  el.setAttribute('data-rendered', 'true')
576
635
  }
@@ -589,8 +648,8 @@ function renderPage(coworker, msgs, humanName) {
589
648
  </html>`;
590
649
  }
591
650
  // ── Express app ───────────────────────────────────────────────────────────────
592
- export async function appCoworkerChatWeb(coworker, options) {
593
- const { url: agentUrl, secret, host, port: portStr } = options;
651
+ export async function appCoworkerChatWeb(options) {
652
+ const { url: agentUrl, password, host, port: portStr } = options;
594
653
  const port = parseInt(portStr, 10);
595
654
  if (isNaN(port) || port < 1 || port > 65535) {
596
655
  console.error(`Error: invalid port "${portStr}"`);
@@ -606,22 +665,76 @@ export async function appCoworkerChatWeb(coworker, options) {
606
665
  // Resolve human name once at startup
607
666
  let humanName = "Human";
608
667
  try {
609
- humanName = await getHumanName(agentUrl, secret);
668
+ humanName = await getHumanName(agentUrl, password);
610
669
  }
611
670
  catch (err) {
612
671
  console.error(`Warning: could not fetch human name from ${agentUrl}: ${err instanceof Error ? err.message : String(err)}`);
613
- console.error("Check that agent-office serve is running and --secret is correct.");
672
+ console.error("Check that agent-office serve is running and --password is correct.");
614
673
  }
615
- console.log(`Communicator: chatting as "${humanName}" with "${coworker}"`);
674
+ console.log(`Communicator: chatting as "${humanName}"`);
616
675
  const app = express();
617
676
  app.use(express.urlencoded({ extended: false }));
618
677
  app.use(express.json());
678
+ // ── GET /coworkers — list of coworkers (HTMX) ───────────────────────────────
679
+ app.get("/coworkers", async (_req, res) => {
680
+ res.setHeader("Content-Type", "application/json");
681
+ try {
682
+ const response = await fetch(`${agentUrl}/coworkers`, {
683
+ headers: { "Authorization": `Bearer ${password}` },
684
+ });
685
+ if (!response.ok) {
686
+ res.json([{ name: humanName, status: null, isHuman: true, unreadMessages: 0 }]);
687
+ return;
688
+ }
689
+ const coworkers = await response.json();
690
+ res.json(coworkers);
691
+ }
692
+ catch {
693
+ res.json([{ name: humanName, status: null, isHuman: true, unreadMessages: 0 }]);
694
+ }
695
+ });
696
+ // ── GET /dropdown — dropdown fragment for HTMX polling ────────────────────
697
+ app.get("/dropdown", async (req, res) => {
698
+ const coworker = req.query.coworker;
699
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
700
+ try {
701
+ const response = await fetch(`${agentUrl}/coworkers`, {
702
+ headers: { "Authorization": `Bearer ${password}` },
703
+ });
704
+ if (!response.ok) {
705
+ res.send(renderDropdown(coworker ?? null, [{ name: humanName, status: null, isHuman: true, unreadMessages: 0 }]));
706
+ return;
707
+ }
708
+ const coworkers = await response.json();
709
+ res.send(renderDropdown(coworker ?? null, coworkers));
710
+ }
711
+ catch {
712
+ res.send(renderDropdown(coworker ?? null, [{ name: humanName, status: null, isHuman: true, unreadMessages: 0 }]));
713
+ }
714
+ });
619
715
  // ── GET / — full page ────────────────────────────────────────────────────
620
- app.get("/", async (_req, res) => {
716
+ app.get("/", async (req, res) => {
621
717
  try {
622
- const msgs = await fetchMessages(agentUrl, secret, humanName, coworker);
718
+ // Fetch coworkers with unread counts from main server
719
+ const response = await fetch(`${agentUrl}/coworkers`, {
720
+ headers: { "Authorization": `Bearer ${password}` },
721
+ });
722
+ let coworkers;
723
+ if (response.ok) {
724
+ coworkers = await response.json();
725
+ }
726
+ else {
727
+ coworkers = [{ name: humanName, status: null, isHuman: true, unreadMessages: 0 }];
728
+ }
729
+ const nonHumans = coworkers.filter(c => !c.isHuman);
730
+ let coworker = req.query.coworker;
731
+ // Default to first non-human coworker if none specified
732
+ if (!coworker && nonHumans.length > 0) {
733
+ coworker = nonHumans[0].name;
734
+ }
735
+ const msgs = coworker ? await fetchMessages(agentUrl, password, humanName, coworker) : [];
623
736
  res.setHeader("Content-Type", "text/html; charset=utf-8");
624
- res.send(renderPage(coworker, msgs, humanName));
737
+ res.send(renderPage(coworker ?? null, coworkers, msgs, humanName));
625
738
  }
626
739
  catch (err) {
627
740
  const msg = err instanceof Error ? err.message : String(err);
@@ -629,12 +742,18 @@ export async function appCoworkerChatWeb(coworker, options) {
629
742
  }
630
743
  });
631
744
  // ── GET /messages — HTMX fragment (polled every 5s) ──────────────────────
632
- app.get("/messages", async (_req, res) => {
745
+ app.get("/messages", async (req, res) => {
746
+ const coworker = req.query.coworker;
747
+ if (!coworker) {
748
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
749
+ res.send(`<div class="empty-state">Select a coworker to view messages.</div>`);
750
+ return;
751
+ }
633
752
  try {
634
- const msgs = await fetchMessages(agentUrl, secret, humanName, coworker);
753
+ const msgs = await fetchMessages(agentUrl, password, humanName, coworker);
635
754
  // Mark any unread received messages as read
636
755
  const unread = msgs.filter((m) => m.from_name === coworker && !m.read);
637
- await Promise.allSettled(unread.map((m) => markRead(agentUrl, secret, m.id)));
756
+ await Promise.allSettled(unread.map((m) => markRead(agentUrl, password, m.id)));
638
757
  res.setHeader("Content-Type", "text/html; charset=utf-8");
639
758
  res.send(renderMessages(msgs, humanName));
640
759
  }
@@ -646,14 +765,20 @@ export async function appCoworkerChatWeb(coworker, options) {
646
765
  });
647
766
  // ── POST /send — HTMX form submit ────────────────────────────────────────
648
767
  app.post("/send", async (req, res) => {
649
- const body = req.body.body?.trim();
768
+ const { body: msgBody, coworker } = req.body;
769
+ const body = msgBody?.trim();
650
770
  if (!body) {
651
771
  res.setHeader("Content-Type", "text/html; charset=utf-8");
652
772
  res.send(`<span class="send-err">Message cannot be empty.</span>`);
653
773
  return;
654
774
  }
775
+ if (!coworker) {
776
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
777
+ res.send(`<span class="send-err">No coworker selected.</span>`);
778
+ return;
779
+ }
655
780
  try {
656
- await apiFetch(agentUrl, secret, "/messages", {
781
+ await apiFetch(agentUrl, password, "/messages", {
657
782
  method: "POST",
658
783
  body: JSON.stringify({ from: humanName, to: [coworker], body }),
659
784
  });
@@ -667,10 +792,15 @@ export async function appCoworkerChatWeb(coworker, options) {
667
792
  }
668
793
  });
669
794
  // ── GET /status — coworker status fragment (polled every 5s) ────────────
670
- app.get("/status", async (_req, res) => {
795
+ app.get("/status", async (req, res) => {
796
+ const coworker = req.query.coworker;
671
797
  res.setHeader("Content-Type", "text/html; charset=utf-8");
798
+ if (!coworker) {
799
+ res.send(`<span style="color:var(--text-dim)">—</span>`);
800
+ return;
801
+ }
672
802
  try {
673
- const status = await fetchCoworkerStatus(agentUrl, secret, coworker);
803
+ const status = await fetchCoworkerStatus(agentUrl, password, coworker);
674
804
  res.send(status ? escapeHtml(status) : `<span style="color:var(--text-dim)">—</span>`);
675
805
  }
676
806
  catch {
@@ -678,10 +808,15 @@ export async function appCoworkerChatWeb(coworker, options) {
678
808
  }
679
809
  });
680
810
  // ── POST /reset — revert the coworker's session to first message ─────────
681
- app.post("/reset", async (_req, res) => {
811
+ app.post("/reset", async (req, res) => {
812
+ const coworker = req.query.coworker;
682
813
  res.setHeader("Content-Type", "text/html; charset=utf-8");
814
+ if (!coworker) {
815
+ res.send(`<span style="color:var(--red)">✗ No coworker selected.</span>`);
816
+ return;
817
+ }
683
818
  try {
684
- await apiFetch(agentUrl, secret, `/sessions/${encodeURIComponent(coworker)}/revert-to-start`, {
819
+ await apiFetch(agentUrl, password, `/sessions/${encodeURIComponent(coworker)}/revert-to-start`, {
685
820
  method: "POST",
686
821
  });
687
822
  res.send(`<span style="color:var(--green)">✓ ${escapeHtml(coworker)} reset and restarted.</span>`);
@@ -0,0 +1,11 @@
1
+ import { type AgentOfficeNotifier } from "../lib/notifier.js";
2
+ type Options = {
3
+ agentOfficeUrl?: string;
4
+ password?: string;
5
+ toEmail?: string;
6
+ resendApiKey?: string;
7
+ domain?: string;
8
+ waitMinutes?: string;
9
+ };
10
+ export declare function notifier(options: Options, customNotifier?: AgentOfficeNotifier): Promise<void>;
11
+ export {};
@@ -0,0 +1,100 @@
1
+ import { Cron } from "croner";
2
+ import { ResendNotifier } from "../lib/notifier.js";
3
+ export async function notifier(options, customNotifier) {
4
+ const toEmail = options.toEmail;
5
+ if (!toEmail) {
6
+ console.error("Error: --to-email or TO_EMAIL env required");
7
+ process.exit(1);
8
+ }
9
+ const domain = options.domain;
10
+ if (!domain) {
11
+ console.error("Error: --domain or EMAIL_DOMAIN env required");
12
+ process.exit(1);
13
+ }
14
+ const agentOfficeUrl = options.agentOfficeUrl ?? "http://127.0.0.1:7654";
15
+ const password = options.password;
16
+ if (!password) {
17
+ console.error("Error: --password or AGENT_OFFICE_PASSWORD env required");
18
+ process.exit(1);
19
+ }
20
+ const waitMinutes = parseInt(options.waitMinutes ?? "15", 10);
21
+ const waitHours = waitMinutes / 60;
22
+ let notify;
23
+ if (customNotifier) {
24
+ notify = customNotifier;
25
+ }
26
+ else {
27
+ const resendApiKey = options.resendApiKey;
28
+ if (!resendApiKey) {
29
+ console.error("Error: --resend-api-key or RESEND_API_KEY env required");
30
+ process.exit(1);
31
+ }
32
+ notify = new ResendNotifier(resendApiKey);
33
+ }
34
+ const authHeaders = {
35
+ Authorization: `Bearer ${password}`,
36
+ "Content-Type": "application/json",
37
+ };
38
+ const check = async () => {
39
+ try {
40
+ // Fetch messages old enough to notify about
41
+ const resp = await fetch(`${agentOfficeUrl}/human/unread-old?hours=${waitHours}`, {
42
+ headers: authHeaders,
43
+ });
44
+ if (!resp.ok) {
45
+ console.error(`GET /human/unread-old failed: ${resp.status}`);
46
+ return;
47
+ }
48
+ const qualifying = (await resp.json());
49
+ if (qualifying.length === 0) {
50
+ // Check if there are any unread messages at all (just not old enough yet)
51
+ const allResp = await fetch(`${agentOfficeUrl}/human/unread-old?hours=0`, {
52
+ headers: authHeaders,
53
+ });
54
+ if (allResp.ok) {
55
+ const allUnread = (await allResp.json());
56
+ if (allUnread.length > 0) {
57
+ console.log(`${allUnread.length} unread message(s) exist but haven't been waiting >${waitMinutes}m yet — skipping notification`);
58
+ }
59
+ else {
60
+ console.log("No unread messages — nothing to notify");
61
+ }
62
+ }
63
+ return;
64
+ }
65
+ const senders = [...new Set(qualifying.map((m) => m.from_name))];
66
+ for (const sender of senders) {
67
+ const fromAddress = `${sender} <${sender.replace(/\s+/g, "+")}@${domain}>`;
68
+ await notify.send({
69
+ from: fromAddress,
70
+ to: toEmail,
71
+ subject: "Agent Office: Message Waiting",
72
+ text: "There is a message waiting for you at Agent Office.",
73
+ });
74
+ console.log(`Sent out notification for waiting mail | from: ${fromAddress} | to: ${toEmail}`);
75
+ }
76
+ const ids = qualifying.map((m) => m.id);
77
+ const markResp = await fetch(`${agentOfficeUrl}/human/mark-notified`, {
78
+ method: "POST",
79
+ headers: authHeaders,
80
+ body: JSON.stringify({ ids }),
81
+ });
82
+ if (!markResp.ok) {
83
+ console.error(`POST /human/mark-notified failed: ${markResp.status}`);
84
+ }
85
+ }
86
+ catch (e) {
87
+ console.error("Notifier cron error:", e);
88
+ }
89
+ };
90
+ const cron = new Cron("0 * * * *", check);
91
+ console.log(`Agent Office notifier started. Notifying for messages unread >${waitMinutes}m. Checking ${agentOfficeUrl} every hour. ^C to stop.`);
92
+ await check();
93
+ const shutdown = async () => {
94
+ console.log("\nShutting down...");
95
+ cron.stop();
96
+ process.exit(0);
97
+ };
98
+ process.on("SIGINT", shutdown);
99
+ process.on("SIGTERM", shutdown);
100
+ }
@@ -1,6 +1,6 @@
1
1
  interface ScreensaverOptions {
2
2
  url: string;
3
- secret: string;
3
+ password: string;
4
4
  host: string;
5
5
  port: string;
6
6
  }
@@ -1,6 +1,6 @@
1
1
  import express from "express";
2
2
  export async function appScreensaver(options) {
3
- const { url: agentUrl, secret, host, port: portStr } = options;
3
+ const { url: agentUrl, password, host, port: portStr } = options;
4
4
  const port = parseInt(portStr, 10);
5
5
  if (isNaN(port) || port < 1 || port > 65535) {
6
6
  console.error(`Error: invalid port "${portStr}"`);
@@ -16,6 +16,26 @@ export async function appScreensaver(options) {
16
16
  console.log(`Screensaver: visualizing mail activity from ${agentUrl}/watch`);
17
17
  const app = express();
18
18
  app.use(express.json());
19
+ // ── GET /coworkers — proxies coworker list from agent-office ───────────────
20
+ app.get("/coworkers", async (_req, res) => {
21
+ res.setHeader("Content-Type", "application/json");
22
+ try {
23
+ const response = await fetch(`${agentUrl}/coworkers`, {
24
+ headers: {
25
+ "Authorization": `Bearer ${password}`,
26
+ },
27
+ });
28
+ if (!response.ok) {
29
+ res.json([]);
30
+ return;
31
+ }
32
+ const data = await response.json();
33
+ res.json(data);
34
+ }
35
+ catch {
36
+ res.json([]);
37
+ }
38
+ });
19
39
  // ── GET / — Three.js ring visualization ─────────────────────────────────────
20
40
  app.get("/", (_req, res) => {
21
41
  res.setHeader("Content-Type", "text/html; charset=utf-8");
@@ -40,7 +60,7 @@ export async function appScreensaver(options) {
40
60
  // Connect to the agent-office /watch endpoint
41
61
  const response = await fetch(`${agentUrl}/watch`, {
42
62
  headers: {
43
- "Authorization": `Bearer ${secret}`,
63
+ "Authorization": `Bearer ${password}`,
44
64
  },
45
65
  signal: abortController.signal,
46
66
  });
@@ -150,6 +170,7 @@ function renderScreensaverPage() {
150
170
  let agentNodes = new Map() // name -> { mesh, label, angle }
151
171
  let arrowObjects = [] // { from, to, mesh, arrowHead, createdAt, opacity }
152
172
  let watchState = {} // latest state from SSE
173
+ let coworkersSet = new Set() // valid coworker names from /coworkers endpoint
153
174
 
154
175
  // Track all messages we've seen: key = "from->to", value = lastSent ISO string
155
176
  let messageEdges = new Map() // "from->to" -> { lastSent: Date }
@@ -1092,11 +1113,15 @@ function renderScreensaverPage() {
1092
1113
  watchState = state
1093
1114
  const nameSet = new Set()
1094
1115
 
1095
- // Collect all agent names and message edges
1116
+ // Collect all agent names and message edges (only for valid coworkers)
1096
1117
  for (const agentName in state) {
1118
+ // Only include if this is a known coworker
1119
+ if (!coworkersSet.has(agentName)) continue
1097
1120
  nameSet.add(agentName)
1098
1121
  const senders = state[agentName] || {}
1099
1122
  for (const senderName in senders) {
1123
+ // Only include sender if they're also a known coworker
1124
+ if (!coworkersSet.has(senderName)) continue
1100
1125
  nameSet.add(senderName)
1101
1126
  const edgeKey = senderName + '->' + agentName
1102
1127
  const lastSent = new Date(senders[senderName].lastSent)
@@ -1135,6 +1160,25 @@ function renderScreensaverPage() {
1135
1160
  updateArrows()
1136
1161
  }
1137
1162
 
1163
+ // ── Fetch coworkers list on startup ──────────────────────────────────────
1164
+ async function fetchCoworkers() {
1165
+ try {
1166
+ const response = await fetch('/coworkers')
1167
+ const coworkers = await response.json()
1168
+ coworkersSet = new Set(coworkers.map(c => c.name))
1169
+ console.log('Loaded coworkers:', Array.from(coworkersSet))
1170
+ // Re-process watch state if we already have data
1171
+ if (Object.keys(watchState).length > 0) {
1172
+ processWatchState(watchState)
1173
+ }
1174
+ } catch (err) {
1175
+ console.error('Failed to fetch coworkers:', err)
1176
+ }
1177
+ }
1178
+
1179
+ // Fetch coworkers immediately
1180
+ fetchCoworkers()
1181
+
1138
1182
  // ── SSE connection ─────────────────────────────────────────────────────
1139
1183
  const es = new EventSource('/watch-stream')
1140
1184
 
@@ -3,10 +3,8 @@ interface ServeOptions {
3
3
  sqlite?: string;
4
4
  host: string;
5
5
  port: string;
6
- memoryPath: string;
7
6
  password?: string;
8
7
  opencodeUrl: string;
9
- simpleMemory?: boolean;
10
8
  }
11
9
  export declare function serve(options: ServeOptions): Promise<void>;
12
10
  export {};
@@ -3,7 +3,6 @@ import { runMigrations } from "../db/migrate.js";
3
3
  import { OpenCodeCodingServer } from "../lib/opencode-coding-server.js";
4
4
  import { createApp } from "../server/index.js";
5
5
  import { CronScheduler } from "../server/cron.js";
6
- import { AgentOfficeFastMemory, AgentOfficeSimpleMemory } from "../server/memory.js";
7
6
  export async function serve(options) {
8
7
  const password = options.password;
9
8
  if (!password) {
@@ -44,33 +43,10 @@ export async function serve(options) {
44
43
  const agenticCodingServer = new OpenCodeCodingServer(options.opencodeUrl);
45
44
  console.log(`Connecting to OpenCode server at ${options.opencodeUrl}...`);
46
45
  const serverUrl = `http://${options.host}:${port}`;
47
- // Create memory manager
48
- let memoryManager;
49
- if (options.simpleMemory) {
50
- console.log("Using simple SQLite+BM25 memory backend.");
51
- memoryManager = new AgentOfficeSimpleMemory(options.memoryPath);
52
- await memoryManager.warmup();
53
- }
54
- else {
55
- memoryManager = new AgentOfficeFastMemory(options.memoryPath);
56
- console.log("Warming up embedding model...");
57
- try {
58
- await memoryManager.warmup();
59
- console.log("Embedding model ready.");
60
- }
61
- catch (err) {
62
- console.error("Embedding model failed to load:", err);
63
- console.error("Try deleting the model cache and restarting:");
64
- console.error(` rm -rf ${options.memoryPath}/.model-cache`);
65
- memoryManager.closeAll();
66
- await storage.close();
67
- process.exit(1);
68
- }
69
- }
70
46
  // Create cron scheduler
71
47
  const cronScheduler = new CronScheduler();
72
48
  // Create Express app
73
- const app = createApp(storage, agenticCodingServer, password, serverUrl, cronScheduler, memoryManager);
49
+ const app = createApp(storage, agenticCodingServer, password, serverUrl, cronScheduler);
74
50
  // Start cron scheduler
75
51
  await cronScheduler.start(storage, agenticCodingServer, serverUrl);
76
52
  // Start server
@@ -82,7 +58,6 @@ export async function serve(options) {
82
58
  console.log("\nShutting down...");
83
59
  server.close(async () => {
84
60
  cronScheduler.stop();
85
- memoryManager.closeAll();
86
61
  await storage.close();
87
62
  console.log("Goodbye.");
88
63
  process.exit(0);
@@ -6,13 +6,10 @@ export declare function createCron(token: string, options: {
6
6
  name: string;
7
7
  schedule: string;
8
8
  message: string;
9
+ respondTo: string;
9
10
  timezone?: string;
10
11
  }): Promise<void>;
11
12
  export declare function deleteCron(token: string, cronId: number): Promise<void>;
12
13
  export declare function enableCron(token: string, cronId: number): Promise<void>;
13
14
  export declare function disableCron(token: string, cronId: number): Promise<void>;
14
15
  export declare function cronHistory(token: string, cronId: number): Promise<void>;
15
- export declare function memoryAdd(token: string, content: string): Promise<void>;
16
- export declare function memorySearch(token: string, query: string, limit: number): Promise<void>;
17
- export declare function memoryList(token: string, limit: number): Promise<void>;
18
- export declare function memoryForget(token: string, memoryId: string): Promise<void>;