agent-office 0.3.1 → 0.3.2

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/README.md CHANGED
@@ -84,7 +84,7 @@ From the TUI you can create coworkers, send them messages, browse mail, manage c
84
84
  ### 3. Chat with a coworker in the browser
85
85
 
86
86
  ```bash
87
- agent-office communicator web "Alice" --secret mysecret
87
+ agent-office communicator web "Alice" --password mysecret
88
88
  ```
89
89
 
90
90
  Opens a web-based chat interface at `http://127.0.0.1:7655` for real-time conversation with the named coworker.
@@ -183,7 +183,7 @@ Arguments:
183
183
 
184
184
  Options:
185
185
  --url <url> Server URL (default: http://127.0.0.1:7654)
186
- --secret <secret> API password (env: AGENT_OFFICE_PASSWORD)
186
+ --password <password> API password (env: AGENT_OFFICE_PASSWORD)
187
187
  --host <host> Communicator bind host (default: 127.0.0.1)
188
188
  --port <port> Communicator bind port (default: 7655)
189
189
  ```
@@ -330,7 +330,7 @@ npm run dev:serve -- --password secret
330
330
  npm run dev:manage -- --password secret
331
331
 
332
332
  # Run communicator (in another terminal)
333
- npm run dev:communicator -- "Alice" --secret secret
333
+ npm run dev:communicator -- "Alice" --password secret
334
334
 
335
335
  # Build
336
336
  npm run build
package/dist/cli.js CHANGED
@@ -35,32 +35,23 @@ const appCmd = program
35
35
  .description("[HUMAN ONLY] Interactive visual applications");
36
36
  appCmd
37
37
  .command("coworker-chat-web")
38
- .description("[HUMAN ONLY] Launch a web chat interface for a single coworker")
39
- .argument("<coworker>", "Name of the coworker to chat with (e.g. 'Howard Roark')")
38
+ .description("[HUMAN ONLY] Launch a web chat interface for coworkers")
40
39
  .option("--url <url>", "URL of the agent-office serve endpoint (e.g. http://127.0.0.1:7654)", process.env.AGENT_OFFICE_URL ?? "http://127.0.0.1:7654")
41
- .option("--secret <secret>", "API password for the agent-office server", process.env.AGENT_OFFICE_PASSWORD)
40
+ .option("--password <password>", "API password for the agent-office server", process.env.AGENT_OFFICE_PASSWORD ?? "secret")
42
41
  .option("--host <host>", "Host to bind the web server to", "127.0.0.1")
43
42
  .option("--port <port>", "Port to run the web server on", "7655")
44
- .action(async (coworker, options) => {
45
- if (!options.secret) {
46
- console.error("Error: --secret is required (or set AGENT_OFFICE_PASSWORD)");
47
- process.exit(1);
48
- }
43
+ .action(async (options) => {
49
44
  const { appCoworkerChatWeb } = await import("./commands/communicator.js");
50
- await appCoworkerChatWeb(coworker, options);
45
+ await appCoworkerChatWeb(options);
51
46
  });
52
47
  appCmd
53
48
  .command("screensaver")
54
49
  .description("[HUMAN ONLY] Launch a visualization of recent mail activity (live screensaver)")
55
50
  .option("--url <url>", "URL of the agent-office serve endpoint (e.g. http://127.0.0.1:7654)", process.env.AGENT_OFFICE_URL ?? "http://127.0.0.1:7654")
56
- .option("--secret <secret>", "API password for the agent-office server", process.env.AGENT_OFFICE_PASSWORD)
51
+ .option("--password <password>", "API password for the agent-office server", process.env.AGENT_OFFICE_PASSWORD ?? "secret")
57
52
  .option("--host <host>", "Host to bind the screensaver web server to", "127.0.0.1")
58
53
  .option("--port <port>", "Port to run the screensaver web server on", "7656")
59
54
  .action(async (options) => {
60
- if (!options.secret) {
61
- console.error("Error: --secret is required (or set AGENT_OFFICE_PASSWORD)");
62
- process.exit(1);
63
- }
64
55
  const { appScreensaver } = await import("./commands/screensaver.js");
65
56
  await appScreensaver(options);
66
57
  });
@@ -123,7 +114,8 @@ cronCmd
123
114
  .description("Create a new cron job")
124
115
  .requiredOption("--name <name>", "Cron job name")
125
116
  .requiredOption("--schedule <schedule>", "Cron expression (e.g., '0 9 * * *' for daily at 9am)")
126
- .requiredOption("--message <message>", "Message to inject when job fires")
117
+ .requiredOption("--message <message>", "Action to perform when job fires")
118
+ .requiredOption("--respond-to <respondTo>", "Who to respond to when done")
127
119
  .option("--timezone <timezone>", "IANA timezone (e.g., 'America/New_York')")
128
120
  .action(async (token, options) => {
129
121
  const { createCron } = await import("./commands/worker.js");
@@ -1,8 +1,8 @@
1
1
  interface CommunicatorOptions {
2
2
  url: string;
3
- secret: string;
3
+ password: string;
4
4
  host: string;
5
5
  port: string;
6
6
  }
7
- export declare function appCoworkerChatWeb(coworker: string, options: CommunicatorOptions): Promise<void>;
7
+ export declare function appCoworkerChatWeb(options: CommunicatorOptions): Promise<void>;
8
8
  export {};
@@ -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>`);
@@ -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
 
@@ -6,6 +6,7 @@ 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>;
@@ -96,7 +96,8 @@ export async function listCrons(token) {
96
96
  console.log(JSON.stringify(crons, null, 2));
97
97
  }
98
98
  export async function createCron(token, options) {
99
- const cron = await postWorker(token, "/worker/crons", options);
99
+ const finalMessage = `Action: ${options.message}\n\nWho to respond to when done: ${options.respondTo}`;
100
+ const cron = await postWorker(token, "/worker/crons", { name: options.name, schedule: options.schedule, message: finalMessage, timezone: options.timezone });
100
101
  console.log(JSON.stringify(cron, null, 2));
101
102
  }
102
103
  export async function deleteCron(token, cronId) {
@@ -21,6 +21,7 @@ export declare class AgentOfficePostgresqlStorage extends AgentOfficeStorageBase
21
21
  setConfig(key: string, value: string): Promise<void>;
22
22
  listMessagesForRecipient(name: string, unreadOnly: boolean): Promise<MessageRow[]>;
23
23
  listMessagesFromSender(name: string): Promise<MessageRow[]>;
24
+ countUnreadBySender(recipientName: string): Promise<Map<string, number>>;
24
25
  createMessageImpl(from: string, to: string, body: string): Promise<MessageRow>;
25
26
  markMessageAsRead(id: number): Promise<MessageRow | null>;
26
27
  markMessageAsInjected(id: number): Promise<void>;
@@ -131,6 +131,19 @@ export class AgentOfficePostgresqlStorage extends AgentOfficeStorageBase {
131
131
  ORDER BY created_at DESC
132
132
  `;
133
133
  }
134
+ async countUnreadBySender(recipientName) {
135
+ const rows = await this.sql `
136
+ SELECT from_name, COUNT(*) as count
137
+ FROM messages
138
+ WHERE to_name = ${recipientName} AND read = FALSE
139
+ GROUP BY from_name
140
+ `;
141
+ const result = new Map();
142
+ for (const row of rows) {
143
+ result.set(row.from_name, Number(row.count));
144
+ }
145
+ return result;
146
+ }
134
147
  async createMessageImpl(from, to, body) {
135
148
  const [row] = await this.sql `
136
149
  INSERT INTO messages (from_name, to_name, body)
@@ -22,6 +22,7 @@ export declare class AgentOfficeSqliteStorage extends AgentOfficeStorageBase {
22
22
  setConfig(key: string, value: string): Promise<void>;
23
23
  listMessagesForRecipient(name: string, unreadOnly: boolean): Promise<MessageRow[]>;
24
24
  listMessagesFromSender(name: string): Promise<MessageRow[]>;
25
+ countUnreadBySender(recipientName: string): Promise<Map<string, number>>;
25
26
  createMessageImpl(from: string, to: string, body: string): Promise<MessageRow>;
26
27
  markMessageAsRead(id: number): Promise<MessageRow | null>;
27
28
  markMessageAsInjected(id: number): Promise<void>;
@@ -172,6 +172,20 @@ export class AgentOfficeSqliteStorage extends AgentOfficeStorageBase {
172
172
  created_at: new Date(row.created_at + 'Z'), // Treat SQLite datetime as UTC
173
173
  }));
174
174
  }
175
+ async countUnreadBySender(recipientName) {
176
+ const stmt = this.db.prepare(`
177
+ SELECT from_name, COUNT(*) as count
178
+ FROM messages
179
+ WHERE to_name = ? AND read = FALSE
180
+ GROUP BY from_name
181
+ `);
182
+ const rows = stmt.all(recipientName);
183
+ const result = new Map();
184
+ for (const row of rows) {
185
+ result.set(row.from_name, row.count);
186
+ }
187
+ return result;
188
+ }
175
189
  async createMessageImpl(from, to, body) {
176
190
  const stmt = this.db.prepare(`
177
191
  INSERT INTO messages (from_name, to_name, body)
@@ -23,6 +23,7 @@ export declare abstract class AgentOfficeStorageBase implements AgentOfficeStora
23
23
  abstract setConfig(key: string, value: string): Promise<void>;
24
24
  abstract listMessagesForRecipient(name: string, unreadOnly: boolean): Promise<MessageRow[]>;
25
25
  abstract listMessagesFromSender(name: string): Promise<MessageRow[]>;
26
+ abstract countUnreadBySender(recipientName: string): Promise<Map<string, number>>;
26
27
  abstract markMessageAsRead(id: number): Promise<MessageRow | null>;
27
28
  abstract markMessageAsInjected(id: number): Promise<void>;
28
29
  abstract listCronJobs(): Promise<CronJobRow[]>;
@@ -32,6 +32,7 @@ export interface AgentOfficeStorage {
32
32
  setConfig(key: string, value: string): Promise<void>;
33
33
  listMessagesForRecipient(name: string, unreadOnly: boolean): Promise<MessageRow[]>;
34
34
  listMessagesFromSender(name: string): Promise<MessageRow[]>;
35
+ countUnreadBySender(recipientName: string): Promise<Map<string, number>>;
35
36
  createMessage(from: string, to: string, body: string): Promise<MessageRow>;
36
37
  markMessageAsRead(id: number): Promise<MessageRow | null>;
37
38
  markMessageAsInjected(id: number): Promise<void>;
@@ -4,17 +4,12 @@ const MAIL_INJECTION_BLURB = [
4
4
  ``,
5
5
  `---`,
6
6
  `You have a new message. Please review the injected message above and respond accordingly.`,
7
- `IMPORTANT: When reading or responding, note that dollar signs ($) and other special`,
8
- `characters may be interpreted as markdown. The sender may have included them but they`,
9
- `could appear differently in your session view. Interpret context accordingly.`,
10
7
  ``,
11
8
  `When responding to the sender:`,
12
9
  `- Use the \`agent-office worker send-message\` tool so they can see your reply`,
13
10
  `- Avoid excessive length - keep responses concise`,
14
- `- Use markdown front-matter with a "choices" array if offering options`,
15
- ``,
16
- `Tip: For currency or prices, use code blocks. Example: put numbers in single or`,
17
- `double quotes to preserve formatting characters like dollar signs.`,
11
+ `- Feel free to use markdown formatting`,
12
+ `- IMPORTANT: Remember when using bash commands certain characters (like dollar signs) may need to be escaped or wrapped in quotes`,
18
13
  ].join("\n");
19
14
  /**
20
15
  * Build the persistent system-prompt briefing for a worker session.
@@ -78,6 +73,23 @@ export function generateSystemPrompt(name, status, humanName, humanDescription,
78
73
  ` agent-office worker cron \\`,
79
74
  ` ${token}`,
80
75
  ``,
76
+ ` Create a cron job (scheduled task)`,
77
+ ` agent-office worker cron create \\`,
78
+ ` --name <job-name> \\`,
79
+ ` --schedule "<cron-expression>" \\`,
80
+ ` --message "<action-to-perform>" \\`,
81
+ ` --respond-to "<who-to-respond-to-when-done>" \\`,
82
+ ` ${token}`,
83
+ ``,
84
+ ` Example: Daily standup reminder at 9am`,
85
+ ` --schedule "0 9 * * *" \\`,
86
+ ` --message "Prepare your standup update" \\`,
87
+ ` --respond-to "${humanName} in the standup channel"`,
88
+ ``,
89
+ ` The final injected message will be formatted as:`,
90
+ ` Action: <message>`,
91
+ ` Who to respond to when done: <respond-to>`,
92
+ ``,
81
93
  ` Store a memory (persistent across sessions)`,
82
94
  ` agent-office worker memory add \\`,
83
95
  ` --content "your memory here" \\`,
@@ -192,6 +204,27 @@ export function createRouter(storage, agenticCodingServer, serverUrl, scheduler,
192
204
  res.status(500).json({ error: "Internal server error" });
193
205
  }
194
206
  });
207
+ router.get("/coworkers", async (_req, res) => {
208
+ try {
209
+ const sessions = await storage.listSessions();
210
+ const humanName = await storage.getConfig('human_name') ?? "Human";
211
+ const unreadCounts = await storage.countUnreadBySender(humanName);
212
+ const coworkers = [
213
+ { name: humanName, status: null, isHuman: true, unreadMessages: 0 },
214
+ ...sessions.map(s => ({
215
+ name: s.name,
216
+ status: s.status,
217
+ isHuman: false,
218
+ unreadMessages: unreadCounts.get(s.name) ?? 0
219
+ }))
220
+ ];
221
+ res.json(coworkers);
222
+ }
223
+ catch (err) {
224
+ console.error("GET /coworkers error:", err);
225
+ res.status(500).json({ error: "Internal server error" });
226
+ }
227
+ });
195
228
  router.post("/sessions", async (req, res) => {
196
229
  const { name, agent: agentArg } = req.body;
197
230
  if (!name || typeof name !== "string" || !name.trim()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-office",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "An office for your AI agents",
5
5
  "type": "module",
6
6
  "license": "MIT",