agent-office 0.0.0 → 0.0.1

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 CHANGED
@@ -38,4 +38,22 @@ workerCmd
38
38
  const { clockIn } = await import("./commands/worker.js");
39
39
  await clockIn(token);
40
40
  });
41
+ workerCmd
42
+ .command("list-coworkers")
43
+ .description("List all other workers (coworkers)")
44
+ .argument("<token>", "Agent token in the format <agent_code>@<server-url>")
45
+ .action(async (token) => {
46
+ const { listCoworkers } = await import("./commands/worker.js");
47
+ await listCoworkers(token);
48
+ });
49
+ workerCmd
50
+ .command("send-message")
51
+ .description("Send a message to one or more coworkers")
52
+ .argument("<token>", "Agent token in the format <agent_code>@<server-url>")
53
+ .requiredOption("--name <name>", "Recipient name (can be specified multiple times)", (val, prev) => [...prev, val], [])
54
+ .requiredOption("--body <body>", "Message body")
55
+ .action(async (token, options) => {
56
+ const { sendMessage } = await import("./commands/worker.js");
57
+ await sendMessage(token, options.name, options.body);
58
+ });
41
59
  program.parse();
@@ -34,11 +34,12 @@ export async function serve(options) {
34
34
  }
35
35
  // Init OpenCode client
36
36
  const opencode = createOpencodeClient(options.opencodeUrl);
37
+ const serverUrl = `http://${options.host}:${port}`;
37
38
  // Create Express app
38
- const app = createApp(sql, opencode, password);
39
+ const app = createApp(sql, opencode, password, serverUrl);
39
40
  // Start server
40
41
  const server = app.listen(port, options.host, () => {
41
- console.log(`agent-office server listening on http://${options.host}:${port}`);
42
+ console.log(`agent-office server listening on ${serverUrl}`);
42
43
  });
43
44
  // Graceful shutdown
44
45
  const shutdown = async () => {
@@ -1 +1,3 @@
1
1
  export declare function clockIn(token: string): Promise<void>;
2
+ export declare function listCoworkers(token: string): Promise<void>;
3
+ export declare function sendMessage(token: string, recipients: string[], body: string): Promise<void>;
@@ -1,7 +1,5 @@
1
- // UUID v4 pattern
2
1
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
3
- export async function clockIn(token) {
4
- // Parse <agent_code>@<url> — URL may contain @ itself (unlikely but safe with lastIndexOf)
2
+ function parseToken(token) {
5
3
  const atIndex = token.indexOf("@");
6
4
  if (atIndex === -1) {
7
5
  console.error("Error: token must be in the format <agent_code>@<server-url>");
@@ -14,14 +12,76 @@ export async function clockIn(token) {
14
12
  console.error(`Error: "${agentCode}" is not a valid UUID agent code`);
15
13
  process.exit(1);
16
14
  }
17
- let parsedUrl;
18
15
  try {
19
- parsedUrl = new URL(serverUrl);
16
+ new URL(serverUrl);
20
17
  }
21
18
  catch {
22
19
  console.error(`Error: "${serverUrl}" is not a valid URL`);
23
20
  process.exit(1);
24
21
  }
22
+ return { agentCode, serverUrl };
23
+ }
24
+ async function fetchWorker(token, endpoint) {
25
+ const { agentCode, serverUrl } = parseToken(token);
26
+ const url = `${serverUrl}${endpoint}?code=${encodeURIComponent(agentCode)}`;
27
+ let res;
28
+ try {
29
+ res = await fetch(url);
30
+ }
31
+ catch (err) {
32
+ console.error(`Error: could not reach ${serverUrl}`);
33
+ console.error(err instanceof Error ? err.message : String(err));
34
+ process.exit(1);
35
+ }
36
+ let body;
37
+ try {
38
+ body = await res.json();
39
+ }
40
+ catch {
41
+ console.error(`Error: invalid response from server`);
42
+ process.exit(1);
43
+ }
44
+ if (!res.ok) {
45
+ const msg = body.error ?? `HTTP ${res.status}`;
46
+ console.error(`Error: ${msg}`);
47
+ process.exit(1);
48
+ }
49
+ return body;
50
+ }
51
+ async function postWorker(token, endpoint, payload) {
52
+ const { agentCode, serverUrl } = parseToken(token);
53
+ const url = `${serverUrl}${endpoint}?code=${encodeURIComponent(agentCode)}`;
54
+ let res;
55
+ try {
56
+ res = await fetch(url, {
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body: JSON.stringify(payload),
60
+ });
61
+ }
62
+ catch (err) {
63
+ console.error(`Error: could not reach ${serverUrl}`);
64
+ console.error(err instanceof Error ? err.message : String(err));
65
+ process.exit(1);
66
+ }
67
+ let body;
68
+ try {
69
+ body = await res.json();
70
+ }
71
+ catch {
72
+ console.error(`Error: invalid response from server`);
73
+ process.exit(1);
74
+ }
75
+ if (!res.ok) {
76
+ const msg = body.error ?? `HTTP ${res.status}`;
77
+ console.error(`Error: ${msg}`);
78
+ process.exit(1);
79
+ }
80
+ return body;
81
+ }
82
+ export async function clockIn(token) {
83
+ const { agentCode, serverUrl } = parseToken(token);
84
+ const parsedUrl = new URL(serverUrl);
25
85
  const clockInUrl = `${parsedUrl.origin}/worker/clock-in?code=${encodeURIComponent(agentCode)}`;
26
86
  let res;
27
87
  try {
@@ -48,3 +108,11 @@ export async function clockIn(token) {
48
108
  const { message } = body;
49
109
  console.log(message);
50
110
  }
111
+ export async function listCoworkers(token) {
112
+ const workers = await fetchWorker(token, "/worker/list-coworkers");
113
+ console.log(JSON.stringify(workers, null, 2));
114
+ }
115
+ export async function sendMessage(token, recipients, body) {
116
+ const result = await postWorker(token, "/worker/send-message", { to: recipients, body });
117
+ console.log(JSON.stringify(result, null, 2));
118
+ }
@@ -8,3 +8,16 @@ export interface SessionRow {
8
8
  agent_code: string;
9
9
  created_at: Date;
10
10
  }
11
+ export interface ConfigRow {
12
+ key: string;
13
+ value: string;
14
+ }
15
+ export interface MessageRow {
16
+ id: number;
17
+ from_name: string;
18
+ to_name: string;
19
+ body: string;
20
+ read: boolean;
21
+ injected: boolean;
22
+ created_at: Date;
23
+ }
@@ -20,6 +20,36 @@ const MIGRATIONS = [
20
20
  CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_agent_code ON sessions(agent_code);
21
21
  `,
22
22
  },
23
+ {
24
+ version: 3,
25
+ name: "create_config_table",
26
+ sql: `
27
+ CREATE TABLE IF NOT EXISTS config (
28
+ key VARCHAR(255) PRIMARY KEY,
29
+ value TEXT NOT NULL
30
+ );
31
+ INSERT INTO config (key, value) VALUES ('human_name', 'Human') ON CONFLICT DO NOTHING;
32
+ INSERT INTO config (key, value) VALUES ('human_description', '') ON CONFLICT DO NOTHING;
33
+ `,
34
+ },
35
+ {
36
+ version: 4,
37
+ name: "create_messages_table",
38
+ sql: `
39
+ CREATE TABLE IF NOT EXISTS messages (
40
+ id SERIAL PRIMARY KEY,
41
+ from_name VARCHAR(255) NOT NULL,
42
+ to_name VARCHAR(255) NOT NULL,
43
+ body TEXT NOT NULL,
44
+ read BOOLEAN NOT NULL DEFAULT FALSE,
45
+ injected BOOLEAN NOT NULL DEFAULT FALSE,
46
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
47
+ );
48
+ CREATE INDEX IF NOT EXISTS idx_messages_to_name ON messages(to_name);
49
+ CREATE INDEX IF NOT EXISTS idx_messages_from_name ON messages(from_name);
50
+ CREATE INDEX IF NOT EXISTS idx_messages_read ON messages(read);
51
+ `,
52
+ },
23
53
  ];
24
54
  export async function runMigrations(sql) {
25
55
  await sql `
@@ -9,16 +9,25 @@ import { DeleteSession } from "./components/DeleteSession.js";
9
9
  import { TailMessages } from "./components/TailMessages.js";
10
10
  import { InjectText } from "./components/InjectText.js";
11
11
  import { AgentCode } from "./components/AgentCode.js";
12
+ import { ReadMail } from "./components/ReadMail.js";
13
+ import { SendMessage } from "./components/SendMessage.js";
14
+ import { Profile } from "./components/Profile.js";
15
+ import { MyMail } from "./components/MyMail.js";
16
+ import { SessionSidebar } from "./components/SessionSidebar.js";
12
17
  const MENU_OPTIONS = [
18
+ { label: "Send message", value: "send-message" },
19
+ { label: "My mail", value: "my-mail" },
13
20
  { label: "List sessions", value: "list" },
14
21
  { label: "Create session", value: "create" },
15
22
  { label: "Delete session", value: "delete" },
16
23
  { label: "Tail messages", value: "tail" },
17
24
  { label: "Inject text", value: "inject" },
18
25
  { label: "Agent code", value: "agent-code" },
26
+ { label: "Read mail", value: "read-mail" },
27
+ { label: "My profile", value: "profile" },
19
28
  { label: "Quit", value: "quit" },
20
29
  ];
21
- const SUB_SCREENS = ["list", "create", "delete", "tail", "inject", "agent-code"];
30
+ const SUB_SCREENS = ["list", "create", "delete", "tail", "inject", "agent-code", "read-mail", "send-message", "my-mail", "profile"];
22
31
  const FOOTER_HINTS = {
23
32
  connecting: "",
24
33
  "auth-error": "",
@@ -29,6 +38,10 @@ const FOOTER_HINTS = {
29
38
  tail: "↑↓ scroll · Esc back to menu",
30
39
  inject: "Enter submit · Esc back to menu",
31
40
  "agent-code": "r reveal/hide · g regenerate · Esc back to menu",
41
+ "read-mail": "r received · s sent · Esc back to menu",
42
+ "send-message": "Enter submit · Esc back to menu",
43
+ "my-mail": "r received · s sent · Esc back to menu",
44
+ "profile": "Enter submit · Esc back to menu",
32
45
  };
33
46
  function Header({ serverUrl }) {
34
47
  return (_jsxs(Box, { borderStyle: "single", borderColor: "cyan", paddingX: 1, justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "cyan", children: "agent-office" }), _jsx(Text, { dimColor: true, children: serverUrl })] }));
@@ -83,7 +96,7 @@ export function App({ serverUrl, password }) {
83
96
  case "auth-error":
84
97
  return (_jsxs(Box, { height: contentHeight, flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 1, children: [_jsx(Text, { color: "red", bold: true, children: "Connection failed" }), _jsxs(Text, { children: ["Could not reach ", _jsx(Text, { color: "cyan", children: serverUrl })] }), _jsx(Text, { dimColor: true, children: "Check that agent-office serve is running and your --password is correct." })] }));
85
98
  case "menu":
86
- return (_jsxs(Box, { height: contentHeight, flexDirection: "column", paddingX: 2, paddingY: 1, gap: 1, children: [_jsx(Text, { bold: true, children: "Main Menu" }), _jsx(Select, { options: MENU_OPTIONS, onChange: handleMenuSelect })] }));
99
+ 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(Select, { options: MENU_OPTIONS, onChange: handleMenuSelect })] }), _jsx(SessionSidebar, { serverUrl: serverUrl, password: password })] }));
87
100
  case "list":
88
101
  return (_jsx(Box, { height: contentHeight, flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsx(SessionList, { serverUrl: serverUrl, password: password, onBack: goBack, contentHeight: contentHeight - 2 }) }));
89
102
  case "create":
@@ -96,6 +109,14 @@ export function App({ serverUrl, password }) {
96
109
  return (_jsx(Box, { height: contentHeight, flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsx(InjectText, { serverUrl: serverUrl, password: password, onBack: goBack, contentHeight: contentHeight - 2 }) }));
97
110
  case "agent-code":
98
111
  return (_jsx(Box, { height: contentHeight, flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsx(AgentCode, { serverUrl: serverUrl, password: password, onBack: goBack, contentHeight: contentHeight - 2 }) }));
112
+ case "read-mail":
113
+ return (_jsx(Box, { height: contentHeight, flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsx(ReadMail, { serverUrl: serverUrl, password: password, onBack: goBack, contentHeight: contentHeight - 2 }) }));
114
+ case "send-message":
115
+ return (_jsx(Box, { height: contentHeight, flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsx(SendMessage, { serverUrl: serverUrl, password: password, onBack: goBack, contentHeight: contentHeight - 2 }) }));
116
+ case "profile":
117
+ return (_jsx(Box, { height: contentHeight, flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsx(Profile, { serverUrl: serverUrl, password: password, onBack: goBack, contentHeight: contentHeight - 2 }) }));
118
+ case "my-mail":
119
+ return (_jsx(Box, { height: contentHeight, flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsx(MyMail, { serverUrl: serverUrl, password: password, onBack: goBack, contentHeight: contentHeight - 2 }) }));
99
120
  }
100
121
  };
101
122
  return (_jsxs(Box, { flexDirection: "column", width: termWidth, children: [_jsx(Header, { serverUrl: serverUrl }), renderContent(), screen !== "connecting" && (_jsx(Footer, { hint: FOOTER_HINTS[screen] }))] }));
@@ -0,0 +1,8 @@
1
+ interface MyMailProps {
2
+ serverUrl: string;
3
+ password: string;
4
+ onBack: () => void;
5
+ contentHeight: number;
6
+ }
7
+ export declare function MyMail({ serverUrl, password, onBack, contentHeight }: MyMailProps): import("react/jsx-runtime").JSX.Element;
8
+ export {};
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { useApi } from "../hooks/useApi.js";
5
+ export function MyMail({ serverUrl, password, onBack, contentHeight }) {
6
+ const { getConfig, getMailMessages } = useApi(serverUrl, password);
7
+ const [humanName, setHumanName] = useState("Human");
8
+ const [viewTab, setViewTab] = useState("received");
9
+ const [messages, setMessages] = useState([]);
10
+ const [error, setError] = useState(null);
11
+ const [scrollOffset, setScrollOffset] = useState(0);
12
+ useEffect(() => {
13
+ getConfig().then((config) => {
14
+ setHumanName(config.human_name ?? "Human");
15
+ loadMessages(config.human_name ?? "Human");
16
+ }).catch((err) => {
17
+ setError(err instanceof Error ? err.message : String(err));
18
+ });
19
+ }, []);
20
+ const loadMessages = async (name) => {
21
+ const sent = viewTab === "sent";
22
+ try {
23
+ const msgs = await getMailMessages(name, { sent });
24
+ setMessages(msgs);
25
+ setScrollOffset(0);
26
+ }
27
+ catch (err) {
28
+ setError(err instanceof Error ? err.message : String(err));
29
+ }
30
+ };
31
+ const handleTabSwitch = (newTab) => {
32
+ setViewTab(newTab);
33
+ setScrollOffset(0);
34
+ loadMessages(humanName);
35
+ };
36
+ useInput((input, key) => {
37
+ if (key.upArrow)
38
+ setScrollOffset((o) => Math.max(0, o - 1));
39
+ if (key.downArrow)
40
+ setScrollOffset((o) => o + 1);
41
+ if (input === "r" && viewTab !== "received") {
42
+ handleTabSwitch("received");
43
+ }
44
+ if (input === "s" && viewTab !== "sent") {
45
+ handleTabSwitch("sent");
46
+ }
47
+ });
48
+ const renderMessages = () => {
49
+ const lines = [];
50
+ const maxNameLen = Math.max(...messages.map((m) => m.from_name.length), humanName.length);
51
+ for (const msg of messages) {
52
+ const timestamp = new Date(msg.created_at).toLocaleString();
53
+ lines.push({ text: `─`, color: "gray" });
54
+ lines.push({ text: `${msg.from_name.padEnd(maxNameLen)} → ${msg.to_name}`, color: "cyan" });
55
+ lines.push({ text: `${timestamp}`, color: "gray" });
56
+ lines.push({ text: msg.read ? "" : " [unread]" });
57
+ lines.push({ text: "" });
58
+ for (const line of msg.body.split("\n")) {
59
+ lines.push({ text: ` ${line}` });
60
+ }
61
+ lines.push({ text: "" });
62
+ }
63
+ const viewHeight = contentHeight - 8;
64
+ const maxOffset = Math.max(0, lines.length - viewHeight);
65
+ const clampedOffset = Math.min(scrollOffset, maxOffset);
66
+ const visible = lines.slice(clampedOffset, clampedOffset + viewHeight);
67
+ return (_jsx(Box, { flexDirection: "column", height: viewHeight, overflow: "hidden", children: visible.length === 0 ? (_jsx(Text, { dimColor: true, children: "No messages." })) : (visible.map((line, i) => (_jsx(Text, { color: line.color, children: line.text }, i)))) }));
68
+ };
69
+ return (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "My Mail" }), _jsx(Text, { color: "cyan", children: humanName }), _jsxs(Text, { dimColor: true, children: ["(", messages.length, " ", viewTab, ")"] })] }), _jsxs(Box, { gap: 2, marginBottom: 1, children: [viewTab === "received" ? (_jsx(Text, { bold: true, color: "green", children: "[Received]" })) : (_jsx(Text, { dimColor: true, children: "Received (press r)" })), viewTab === "sent" ? (_jsx(Text, { bold: true, color: "yellow", children: "[Sent]" })) : (_jsx(Text, { dimColor: true, children: "Sent (press s)" }))] }), renderMessages()] }));
70
+ }
@@ -0,0 +1,8 @@
1
+ interface ProfileProps {
2
+ serverUrl: string;
3
+ password: string;
4
+ onBack: () => void;
5
+ contentHeight: number;
6
+ }
7
+ export declare function Profile({ serverUrl, password, onBack, contentHeight }: ProfileProps): import("react/jsx-runtime").JSX.Element;
8
+ export {};
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text } from "ink";
4
+ import { Spinner, TextInput } from "@inkjs/ui";
5
+ import { useApi } from "../hooks/useApi.js";
6
+ export function Profile({ serverUrl, password, onBack, contentHeight }) {
7
+ const { getConfig, setConfig } = useApi(serverUrl, password);
8
+ const [name, setName] = useState("");
9
+ const [description, setDescription] = useState("");
10
+ const [stage, setStage] = useState("loading");
11
+ const [error, setError] = useState(null);
12
+ useEffect(() => {
13
+ getConfig()
14
+ .then((config) => {
15
+ setName(config.human_name ?? "Human");
16
+ setDescription(config.human_description ?? "");
17
+ setStage("editing-name");
18
+ })
19
+ .catch((err) => {
20
+ setError(err instanceof Error ? err.message : String(err));
21
+ setStage("error");
22
+ });
23
+ }, []);
24
+ useEffect(() => {
25
+ if (stage === "done" || stage === "error") {
26
+ const timer = setTimeout(onBack, 1500);
27
+ return () => clearTimeout(timer);
28
+ }
29
+ }, [stage, onBack]);
30
+ const handleNameSubmit = (newName) => {
31
+ setName(newName);
32
+ setStage("editing-description");
33
+ };
34
+ const handleDescriptionSubmit = async (newDesc) => {
35
+ setDescription(newDesc);
36
+ setStage("saving");
37
+ try {
38
+ await setConfig("human_name", name);
39
+ await setConfig("human_description", newDesc);
40
+ setStage("done");
41
+ }
42
+ catch (err) {
43
+ setError(err instanceof Error ? err.message : String(err));
44
+ setStage("error");
45
+ }
46
+ };
47
+ if (stage === "loading") {
48
+ return (_jsx(Box, { height: contentHeight, alignItems: "center", justifyContent: "center", children: _jsx(Spinner, { label: "Loading profile..." }) }));
49
+ }
50
+ if (stage === "error") {
51
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: "My Profile" }), _jsxs(Text, { color: "red", children: ["Error: ", error] }), _jsx(Text, { dimColor: true, children: "Returning to menu..." })] }));
52
+ }
53
+ if (stage === "saving") {
54
+ return (_jsx(Box, { height: contentHeight, alignItems: "center", justifyContent: "center", children: _jsx(Spinner, { label: "Saving profile..." }) }));
55
+ }
56
+ if (stage === "done") {
57
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: "My Profile" }), _jsx(Text, { color: "green", children: "Profile saved successfully!" }), _jsx(Text, { dimColor: true, children: "Returning to menu..." })] }));
58
+ }
59
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: "My Profile" }), _jsx(Text, { dimColor: true, children: "Configure your name and description (visible to agents)" }), _jsxs(Box, { flexDirection: "column", gap: 1, marginTop: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, children: "Name:" }), stage === "editing-name" ? (_jsx(TextInput, { placeholder: name || "Your name", onSubmit: handleNameSubmit })) : (_jsx(Text, { color: "cyan", children: name }))] }), stage === "editing-description" && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, children: "Description:" }), _jsx(TextInput, { placeholder: description || "Optional description", onSubmit: handleDescriptionSubmit })] }))] }), stage === "editing-name" && (_jsx(Text, { dimColor: true, children: "Enter to continue \u00B7 Esc to cancel" })), stage === "editing-description" && (_jsx(Text, { dimColor: true, children: "Enter to save \u00B7 Esc to cancel" }))] }));
60
+ }
@@ -0,0 +1,8 @@
1
+ interface ReadMailProps {
2
+ serverUrl: string;
3
+ password: string;
4
+ onBack: () => void;
5
+ contentHeight: number;
6
+ }
7
+ export declare function ReadMail({ serverUrl, password, onBack, contentHeight }: ReadMailProps): import("react/jsx-runtime").JSX.Element;
8
+ export {};
@@ -0,0 +1,110 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { Select, Spinner } from "@inkjs/ui";
5
+ import { useApi, useAsyncState } from "../hooks/useApi.js";
6
+ export function ReadMail({ serverUrl, password, onBack, contentHeight }) {
7
+ const { listSessions, getMailMessages, markMessageRead } = useApi(serverUrl, password);
8
+ const { run: runList } = useAsyncState();
9
+ const [sessions, setSessions] = useState([]);
10
+ const [sessionsLoading, setSessionsLoading] = useState(true);
11
+ const [stage, setStage] = useState("select-session");
12
+ const [selectedName, setSelectedName] = useState(null);
13
+ const [viewTab, setViewTab] = useState("received");
14
+ const [messages, setMessages] = useState([]);
15
+ const [error, setError] = useState(null);
16
+ const [scrollOffset, setScrollOffset] = useState(0);
17
+ useEffect(() => {
18
+ runList(listSessions).then((rows) => {
19
+ setSessions(rows ?? []);
20
+ setSessionsLoading(false);
21
+ });
22
+ }, []);
23
+ const loadMessages = async (name) => {
24
+ setSelectedName(name);
25
+ setStage("loading");
26
+ try {
27
+ const msgs = await getMailMessages(name, { sent: false });
28
+ setMessages(msgs);
29
+ setScrollOffset(0);
30
+ setStage("view-received");
31
+ }
32
+ catch (err) {
33
+ setError(err instanceof Error ? err.message : String(err));
34
+ setStage("error");
35
+ }
36
+ };
37
+ const handleTabSwitch = async (newTab) => {
38
+ if (!selectedName)
39
+ return;
40
+ setStage("loading");
41
+ setViewTab(newTab);
42
+ setScrollOffset(0);
43
+ try {
44
+ const msgs = await getMailMessages(selectedName, { sent: newTab === "sent" });
45
+ setMessages(msgs);
46
+ setStage(newTab === "sent" ? "view-sent" : "view-received");
47
+ }
48
+ catch (err) {
49
+ setError(err instanceof Error ? err.message : String(err));
50
+ setStage("error");
51
+ }
52
+ };
53
+ useInput((input, key) => {
54
+ if ((stage === "view-received" || stage === "view-sent")) {
55
+ if (key.upArrow)
56
+ setScrollOffset((o) => Math.max(0, o - 1));
57
+ if (key.downArrow)
58
+ setScrollOffset((o) => o + 1);
59
+ if (input === "r" && stage === "view-received") {
60
+ handleTabSwitch("received");
61
+ }
62
+ if (input === "s" && stage === "view-received") {
63
+ handleTabSwitch("sent");
64
+ }
65
+ if (input === "r" && stage === "view-sent") {
66
+ handleTabSwitch("received");
67
+ }
68
+ }
69
+ });
70
+ if (sessionsLoading) {
71
+ return (_jsx(Box, { height: contentHeight, alignItems: "center", justifyContent: "center", children: _jsx(Spinner, { label: "Loading sessions..." }) }));
72
+ }
73
+ if (stage === "select-session") {
74
+ if (sessions.length === 0) {
75
+ return (_jsx(Box, { height: contentHeight, alignItems: "center", justifyContent: "center", children: _jsx(Text, { dimColor: true, children: "No sessions yet. Create one first." }) }));
76
+ }
77
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: "Read Mail" }), _jsx(Text, { dimColor: true, children: "Select a session:" }), _jsx(Select, { options: sessions.map((s) => ({ label: s.name, value: s.name })), onChange: loadMessages })] }));
78
+ }
79
+ if (stage === "loading") {
80
+ return (_jsx(Box, { height: contentHeight, alignItems: "center", justifyContent: "center", children: _jsx(Spinner, { label: `Loading ${viewTab} messages...` }) }));
81
+ }
82
+ if (stage === "error") {
83
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: "Read Mail" }), _jsxs(Text, { color: "red", children: ["Error: ", error] })] }));
84
+ }
85
+ const renderMessages = () => {
86
+ if (messages.length === 0) {
87
+ return (_jsx(Box, { height: contentHeight - 6, alignItems: "center", justifyContent: "center", children: _jsxs(Text, { dimColor: true, children: ["No ", viewTab, " messages."] }) }));
88
+ }
89
+ const lines = [];
90
+ const maxNameLen = Math.max(...messages.map((m) => m.from_name.length));
91
+ for (const msg of messages) {
92
+ const timestamp = new Date(msg.created_at).toLocaleString();
93
+ lines.push({ text: `─`, color: "gray" });
94
+ lines.push({ text: `${msg.from_name.padEnd(maxNameLen)} → ${msg.to_name}`, color: "cyan" });
95
+ lines.push({ text: `${timestamp}`, color: "gray" });
96
+ lines.push({ text: msg.read ? "" : " [unread]" });
97
+ lines.push({ text: "" });
98
+ for (const line of msg.body.split("\n")) {
99
+ lines.push({ text: ` ${line}` });
100
+ }
101
+ lines.push({ text: "" });
102
+ }
103
+ const viewHeight = contentHeight - 6;
104
+ const maxOffset = Math.max(0, lines.length - viewHeight);
105
+ const clampedOffset = Math.min(scrollOffset, maxOffset);
106
+ const visible = lines.slice(clampedOffset, clampedOffset + viewHeight);
107
+ return (_jsx(Box, { flexDirection: "column", height: viewHeight, overflow: "hidden", children: visible.map((line, i) => (_jsx(Text, { color: line.color, children: line.text }, i))) }));
108
+ };
109
+ return (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Mail" }), _jsx(Text, { color: "cyan", children: selectedName }), _jsxs(Text, { dimColor: true, children: ["(", messages.length, " ", viewTab, " messages)"] })] }), _jsxs(Box, { gap: 2, marginBottom: 1, children: [viewTab === "received" ? (_jsx(Text, { bold: true, color: "green", children: "[Received]" })) : (_jsx(Text, { dimColor: true, children: "Received (press r)" })), viewTab === "sent" ? (_jsx(Text, { bold: true, color: "yellow", children: "[Sent]" })) : (_jsx(Text, { dimColor: true, children: "Sent (press s)" }))] }), renderMessages()] }));
110
+ }
@@ -0,0 +1,8 @@
1
+ interface SendMessageProps {
2
+ serverUrl: string;
3
+ password: string;
4
+ onBack: () => void;
5
+ contentHeight: number;
6
+ }
7
+ export declare function SendMessage({ serverUrl, password, onBack, contentHeight }: SendMessageProps): import("react/jsx-runtime").JSX.Element | null;
8
+ export {};
@@ -0,0 +1,76 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text } from "ink";
4
+ import { Select, Spinner, TextInput } from "@inkjs/ui";
5
+ import { useApi, useAsyncState } from "../hooks/useApi.js";
6
+ export function SendMessage({ serverUrl, password, onBack, contentHeight }) {
7
+ const { listSessions, sendMailMessage, getConfig } = useApi(serverUrl, password);
8
+ const { run: runList } = useAsyncState();
9
+ const [sessions, setSessions] = useState([]);
10
+ const [sessionsLoading, setSessionsLoading] = useState(true);
11
+ const [stage, setStage] = useState("select-recipients");
12
+ const [recipients, setRecipients] = useState([]);
13
+ const [messageBody, setMessageBody] = useState("");
14
+ const [error, setError] = useState(null);
15
+ const [submitted, setSubmitted] = useState(false);
16
+ const [senderName, setSenderName] = useState("Human");
17
+ useEffect(() => {
18
+ getConfig().then((config) => {
19
+ setSenderName(config.human_name ?? "Human");
20
+ });
21
+ }, [getConfig]);
22
+ useEffect(() => {
23
+ runList(listSessions).then((rows) => {
24
+ setSessions(rows ?? []);
25
+ setSessionsLoading(false);
26
+ });
27
+ }, []);
28
+ useEffect(() => {
29
+ if (stage === "done" || stage === "error") {
30
+ const timer = setTimeout(onBack, 1500);
31
+ return () => clearTimeout(timer);
32
+ }
33
+ }, [stage, onBack]);
34
+ const handleRecipientSelect = (value) => {
35
+ setRecipients([value]);
36
+ setStage("enter-body");
37
+ };
38
+ const handleBodySubmit = async (body) => {
39
+ const trimmed = body.trim();
40
+ if (!trimmed || recipients.length === 0)
41
+ return;
42
+ setSubmitted(true);
43
+ setMessageBody(trimmed);
44
+ setStage("sending");
45
+ try {
46
+ await sendMailMessage(senderName, recipients, trimmed);
47
+ setStage("done");
48
+ }
49
+ catch (err) {
50
+ setError(err instanceof Error ? err.message : String(err));
51
+ setStage("error");
52
+ }
53
+ };
54
+ if (sessionsLoading) {
55
+ return (_jsx(Box, { height: contentHeight, alignItems: "center", justifyContent: "center", children: _jsx(Spinner, { label: "Loading sessions..." }) }));
56
+ }
57
+ if (stage === "select-recipients") {
58
+ if (sessions.length === 0) {
59
+ return (_jsx(Box, { height: contentHeight, alignItems: "center", justifyContent: "center", children: _jsx(Text, { dimColor: true, children: "No sessions yet. Create one first." }) }));
60
+ }
61
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: "Send Message" }), _jsx(Text, { dimColor: true, children: "Select a recipient agent:" }), _jsx(Select, { options: sessions.map((s) => ({ label: s.name, value: s.name })), onChange: handleRecipientSelect })] }));
62
+ }
63
+ if (stage === "enter-body" && !submitted) {
64
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: "Send Message" }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { children: "From: " }), _jsx(Text, { color: "cyan", children: senderName })] }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { children: "To: " }), _jsx(Text, { color: "cyan", children: recipients[0] })] }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { children: "Body: " }), _jsx(TextInput, { placeholder: "Type your message...", onSubmit: handleBodySubmit })] })] }));
65
+ }
66
+ if (stage === "sending") {
67
+ return (_jsx(Spinner, { label: "Sending message..." }));
68
+ }
69
+ if (stage === "done") {
70
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: "Send Message" }), _jsx(Text, { color: "green", children: "Message sent!" }), _jsx(Text, { dimColor: true, children: "Returning to menu..." })] }));
71
+ }
72
+ if (stage === "error") {
73
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: "Send Message" }), _jsxs(Text, { color: "red", children: ["Error: ", error] }), _jsx(Text, { dimColor: true, children: "Returning to menu..." })] }));
74
+ }
75
+ return null;
76
+ }
@@ -0,0 +1,6 @@
1
+ interface SessionSidebarProps {
2
+ serverUrl: string;
3
+ password: string;
4
+ }
5
+ export declare function SessionSidebar({ serverUrl, password }: SessionSidebarProps): import("react/jsx-runtime").JSX.Element | null;
6
+ export {};
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from "react";
3
+ import { Box, Text } from "ink";
4
+ import { useApi } from "../hooks/useApi.js";
5
+ export function SessionSidebar({ serverUrl, password }) {
6
+ const { listSessions } = useApi(serverUrl, password);
7
+ const [sessions, setSessions] = useState([]);
8
+ useEffect(() => {
9
+ listSessions().then((s) => setSessions(s));
10
+ }, [listSessions]);
11
+ if (sessions.length === 0)
12
+ return null;
13
+ return (_jsxs(Box, { borderStyle: "single", borderColor: "cyan", paddingX: 1, flexDirection: "column", gap: 0, children: [_jsx(Text, { bold: true, color: "cyan", children: "Sessions" }), sessions.map((session, i) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: getNodeColor(i), children: "\u25CF" }), _jsx(Text, { children: session.name })] }, session.id)))] }));
14
+ }
15
+ function getNodeColor(index) {
16
+ const colors = ["green", "blue", "yellow", "magenta", "cyan", "red", "white"];
17
+ return colors[index % colors.length];
18
+ }
@@ -13,6 +13,18 @@ export interface SessionMessage {
13
13
  role: "user" | "assistant";
14
14
  parts: MessagePart[];
15
15
  }
16
+ export interface MailMessage {
17
+ id: number;
18
+ from_name: string;
19
+ to_name: string;
20
+ body: string;
21
+ read: boolean;
22
+ injected: boolean;
23
+ created_at: string;
24
+ }
25
+ export interface Config {
26
+ [key: string]: string;
27
+ }
16
28
  export declare function useApi(serverUrl: string, password: string): {
17
29
  listSessions: () => Promise<Session[]>;
18
30
  createSession: (name: string) => Promise<Session>;
@@ -24,6 +36,25 @@ export declare function useApi(serverUrl: string, password: string): {
24
36
  messageID: string;
25
37
  }>;
26
38
  regenerateCode: (name: string) => Promise<Session>;
39
+ getConfig: () => Promise<Config>;
40
+ setConfig: (key: string, value: string) => Promise<{
41
+ ok: boolean;
42
+ key: string;
43
+ value: string;
44
+ }>;
45
+ getMailMessages: (name: string, options?: {
46
+ sent?: boolean;
47
+ unreadOnly?: boolean;
48
+ }) => Promise<MailMessage[]>;
49
+ sendMailMessage: (from: string, to: string[], body: string) => Promise<{
50
+ ok: boolean;
51
+ results: Array<{
52
+ to: string;
53
+ messageId: number;
54
+ injected: boolean;
55
+ }>;
56
+ }>;
57
+ markMessageRead: (id: number) => Promise<MailMessage>;
27
58
  };
28
59
  export declare function useAsyncState<T>(): {
29
60
  run: (fn: () => Promise<T>) => Promise<T | null>;
@@ -57,7 +57,41 @@ export function useApi(serverUrl, password) {
57
57
  const regenerateCode = useCallback(async (name) => {
58
58
  return apiFetch(`${base}/sessions/${encodeURIComponent(name)}/regenerate-code`, password, { method: "POST" });
59
59
  }, [base, password]);
60
- return { listSessions, createSession, deleteSession, checkHealth, getMessages, injectText, regenerateCode };
60
+ const getConfig = useCallback(async () => {
61
+ return apiFetch(`${base}/config`, password);
62
+ }, [base, password]);
63
+ const setConfig = useCallback(async (key, value) => {
64
+ return apiFetch(`${base}/config`, password, { method: "PUT", body: JSON.stringify({ key, value }) });
65
+ }, [base, password]);
66
+ const getMailMessages = useCallback(async (name, options) => {
67
+ const params = new URLSearchParams();
68
+ if (options?.sent)
69
+ params.set("sent", "true");
70
+ if (options?.unreadOnly)
71
+ params.set("unread_only", "true");
72
+ const query = params.toString() ? `?${params.toString()}` : "";
73
+ return apiFetch(`${base}/messages/${encodeURIComponent(name)}${query}`, password);
74
+ }, [base, password]);
75
+ const sendMailMessage = useCallback(async (from, to, body) => {
76
+ return apiFetch(`${base}/messages`, password, { method: "POST", body: JSON.stringify({ from, to, body }) });
77
+ }, [base, password]);
78
+ const markMessageRead = useCallback(async (id) => {
79
+ return apiFetch(`${base}/messages/${id}/read`, password, { method: "POST" });
80
+ }, [base, password]);
81
+ return {
82
+ listSessions,
83
+ createSession,
84
+ deleteSession,
85
+ checkHealth,
86
+ getMessages,
87
+ injectText,
88
+ regenerateCode,
89
+ getConfig,
90
+ setConfig,
91
+ getMailMessages,
92
+ sendMailMessage,
93
+ markMessageRead,
94
+ };
61
95
  }
62
96
  export function useAsyncState() {
63
97
  const [state, setState] = useState({
@@ -1,3 +1,3 @@
1
1
  import type { Sql } from "../db/index.js";
2
2
  import type { OpencodeClient } from "../lib/opencode.js";
3
- export declare function createApp(sql: Sql, opencode: OpencodeClient, password: string): import("express-serve-static-core").Express;
3
+ export declare function createApp(sql: Sql, opencode: OpencodeClient, password: string, serverUrl: string): import("express-serve-static-core").Express;
@@ -10,13 +10,13 @@ function authMiddleware(password) {
10
10
  next();
11
11
  };
12
12
  }
13
- export function createApp(sql, opencode, password) {
13
+ export function createApp(sql, opencode, password, serverUrl) {
14
14
  const app = express();
15
15
  app.use(express.json());
16
16
  // Worker routes are unauthenticated — mounted before auth middleware
17
- app.use("/", createWorkerRouter(sql));
17
+ app.use("/", createWorkerRouter(sql, opencode, serverUrl));
18
18
  // Everything else requires Bearer auth
19
19
  app.use(authMiddleware(password));
20
- app.use("/", createRouter(sql, opencode));
20
+ app.use("/", createRouter(sql, opencode, serverUrl));
21
21
  return app;
22
22
  }
@@ -1,5 +1,5 @@
1
1
  import { Router } from "express";
2
2
  import type { Sql } from "../db/index.js";
3
3
  import type { OpencodeClient } from "../lib/opencode.js";
4
- export declare function createRouter(sql: Sql, opencode: OpencodeClient): Router;
5
- export declare function createWorkerRouter(sql: Sql): Router;
4
+ export declare function createRouter(sql: Sql, opencode: OpencodeClient, serverUrl: string): Router;
5
+ export declare function createWorkerRouter(sql: Sql, opencode: OpencodeClient, serverUrl: string): Router;
@@ -1,11 +1,9 @@
1
1
  import { Router } from "express";
2
- export function createRouter(sql, opencode) {
2
+ export function createRouter(sql, opencode, serverUrl) {
3
3
  const router = Router();
4
- // GET /health
5
4
  router.get("/health", (_req, res) => {
6
5
  res.json({ ok: true });
7
6
  });
8
- // GET /sessions
9
7
  router.get("/sessions", async (_req, res) => {
10
8
  try {
11
9
  const rows = await sql `
@@ -20,7 +18,6 @@ export function createRouter(sql, opencode) {
20
18
  res.status(500).json({ error: "Internal server error" });
21
19
  }
22
20
  });
23
- // POST /sessions { name }
24
21
  router.post("/sessions", async (req, res) => {
25
22
  const { name } = req.body;
26
23
  if (!name || typeof name !== "string" || !name.trim()) {
@@ -35,7 +32,6 @@ export function createRouter(sql, opencode) {
35
32
  res.status(409).json({ error: `Session name "${trimmedName}" already exists` });
36
33
  return;
37
34
  }
38
- // Create the OpenCode session
39
35
  let opencodeSessionId;
40
36
  try {
41
37
  const session = await opencode.session.create();
@@ -46,25 +42,42 @@ export function createRouter(sql, opencode) {
46
42
  res.status(502).json({ error: "Failed to create OpenCode session", detail: String(err) });
47
43
  return;
48
44
  }
49
- // Persist — agent_code auto-generated by Postgres gen_random_uuid()
45
+ let row;
50
46
  try {
51
- const [row] = await sql `
47
+ const [inserted] = await sql `
52
48
  INSERT INTO sessions (name, session_id)
53
49
  VALUES (${trimmedName}, ${opencodeSessionId})
54
50
  RETURNING id, name, session_id, agent_code, created_at
55
51
  `;
56
- res.status(201).json(row);
52
+ row = inserted;
57
53
  }
58
54
  catch (err) {
59
55
  console.error("DB insert error:", err);
60
56
  try {
61
57
  await opencode.session.delete(opencodeSessionId);
62
58
  }
63
- catch { /* best-effort */ }
59
+ catch { }
64
60
  res.status(500).json({ error: "Internal server error" });
61
+ return;
62
+ }
63
+ try {
64
+ const providers = await opencode.app.providers();
65
+ const defaultEntry = Object.entries(providers.default)[0];
66
+ if (defaultEntry) {
67
+ const clockInToken = `${row.agent_code}@${serverUrl}`;
68
+ const firstMessage = `Run \`agent-office worker clock-in ${clockInToken}\``;
69
+ await opencode.session.chat(opencodeSessionId, {
70
+ modelID: defaultEntry[0],
71
+ providerID: defaultEntry[1],
72
+ parts: [{ type: "text", text: firstMessage }],
73
+ });
74
+ }
65
75
  }
76
+ catch (err) {
77
+ console.warn("Warning: could not send first message to session:", err);
78
+ }
79
+ res.status(201).json(row);
66
80
  });
67
- // POST /sessions/:name/regenerate-code
68
81
  router.post("/sessions/:name/regenerate-code", async (req, res) => {
69
82
  const { name } = req.params;
70
83
  const rows = await sql `
@@ -88,7 +101,6 @@ export function createRouter(sql, opencode) {
88
101
  res.status(500).json({ error: "Internal server error" });
89
102
  }
90
103
  });
91
- // GET /sessions/:name/messages?limit=N
92
104
  router.get("/sessions/:name/messages", async (req, res) => {
93
105
  const { name } = req.params;
94
106
  const limit = Math.min(parseInt(req.query.limit ?? "20", 10), 100);
@@ -118,7 +130,6 @@ export function createRouter(sql, opencode) {
118
130
  res.status(502).json({ error: "Failed to fetch messages from OpenCode", detail: String(err) });
119
131
  }
120
132
  });
121
- // POST /sessions/:name/inject { text, modelID?, providerID? }
122
133
  router.post("/sessions/:name/inject", async (req, res) => {
123
134
  const { name } = req.params;
124
135
  const { text, modelID, providerID } = req.body;
@@ -166,7 +177,6 @@ export function createRouter(sql, opencode) {
166
177
  res.status(502).json({ error: "Failed to inject message into OpenCode session", detail: String(err) });
167
178
  }
168
179
  });
169
- // DELETE /sessions/:name
170
180
  router.delete("/sessions/:name", async (req, res) => {
171
181
  const { name } = req.params;
172
182
  const rows = await sql `
@@ -194,13 +204,171 @@ export function createRouter(sql, opencode) {
194
204
  res.status(500).json({ error: "Internal server error" });
195
205
  }
196
206
  });
207
+ router.get("/config", async (_req, res) => {
208
+ try {
209
+ const rows = await sql `SELECT key, value FROM config`;
210
+ const config = {};
211
+ for (const row of rows) {
212
+ config[row.key] = row.value;
213
+ }
214
+ res.json(config);
215
+ }
216
+ catch (err) {
217
+ console.error("GET /config error:", err);
218
+ res.status(500).json({ error: "Internal server error" });
219
+ }
220
+ });
221
+ router.put("/config", async (req, res) => {
222
+ const { key, value } = req.body;
223
+ if (!key || typeof key !== "string" || !key.trim()) {
224
+ res.status(400).json({ error: "key is required" });
225
+ return;
226
+ }
227
+ if (typeof value !== "string") {
228
+ res.status(400).json({ error: "value must be a string" });
229
+ return;
230
+ }
231
+ try {
232
+ await sql `
233
+ INSERT INTO config (key, value) VALUES (${key}, ${value})
234
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
235
+ `;
236
+ res.json({ ok: true, key, value });
237
+ }
238
+ catch (err) {
239
+ console.error("PUT /config error:", err);
240
+ res.status(500).json({ error: "Internal server error" });
241
+ }
242
+ });
243
+ router.get("/messages/:name", async (req, res) => {
244
+ const { name } = req.params;
245
+ const { sent, unread_only } = req.query;
246
+ try {
247
+ let rows;
248
+ if (sent === "true") {
249
+ rows = await sql `
250
+ SELECT id, from_name, to_name, body, read, injected, created_at
251
+ FROM messages
252
+ WHERE from_name = ${name}
253
+ ORDER BY created_at DESC
254
+ `;
255
+ }
256
+ else {
257
+ if (unread_only === "true") {
258
+ rows = await sql `
259
+ SELECT id, from_name, to_name, body, read, injected, created_at
260
+ FROM messages
261
+ WHERE to_name = ${name} AND read = FALSE
262
+ ORDER BY created_at DESC
263
+ `;
264
+ }
265
+ else {
266
+ rows = await sql `
267
+ SELECT id, from_name, to_name, body, read, injected, created_at
268
+ FROM messages
269
+ WHERE to_name = ${name}
270
+ ORDER BY created_at DESC
271
+ `;
272
+ }
273
+ }
274
+ res.json(rows);
275
+ }
276
+ catch (err) {
277
+ console.error("GET /messages/:name error:", err);
278
+ res.status(500).json({ error: "Internal server error" });
279
+ }
280
+ });
281
+ router.post("/messages", async (req, res) => {
282
+ const { from, to, body } = req.body;
283
+ if (!from || typeof from !== "string" || !from.trim()) {
284
+ res.status(400).json({ error: "from is required" });
285
+ return;
286
+ }
287
+ if (!to || !Array.isArray(to) || to.length === 0) {
288
+ res.status(400).json({ error: "to must be a non-empty array of recipient names" });
289
+ return;
290
+ }
291
+ if (!body || typeof body !== "string" || !body.trim()) {
292
+ res.status(400).json({ error: "body is required" });
293
+ return;
294
+ }
295
+ const trimmedFrom = from.trim();
296
+ const trimmedBody = body.trim();
297
+ const sessions = await sql `SELECT name, session_id FROM sessions`;
298
+ const sessionMap = new Map(sessions.map((s) => [s.name, s.session_id]));
299
+ const validRecipients = [];
300
+ for (const recipient of to) {
301
+ if (typeof recipient !== "string" || !recipient.trim())
302
+ continue;
303
+ const r = recipient.trim();
304
+ const config = await sql `SELECT value FROM config WHERE key = 'human_name'`;
305
+ const humanName = config[0]?.value ?? "Human";
306
+ if (sessionMap.has(r) || r === humanName) {
307
+ validRecipients.push(r);
308
+ }
309
+ }
310
+ if (validRecipients.length === 0) {
311
+ res.status(400).json({ error: "No valid recipients found" });
312
+ return;
313
+ }
314
+ const results = [];
315
+ for (const recipient of validRecipients) {
316
+ let injected = false;
317
+ const [msgRow] = await sql `
318
+ INSERT INTO messages (from_name, to_name, body)
319
+ VALUES (${trimmedFrom}, ${recipient}, ${trimmedBody})
320
+ RETURNING id, from_name, to_name, body, read, injected, created_at
321
+ `;
322
+ if (sessionMap.has(recipient)) {
323
+ const sessionId = sessionMap.get(recipient);
324
+ try {
325
+ const providers = await opencode.app.providers();
326
+ const defaultEntry = Object.entries(providers.default)[0];
327
+ if (defaultEntry) {
328
+ const injectText = `[Message from "${trimmedFrom}"]: ${trimmedBody}`;
329
+ await opencode.session.chat(sessionId, {
330
+ modelID: defaultEntry[0],
331
+ providerID: defaultEntry[1],
332
+ parts: [{ type: "text", text: injectText }],
333
+ });
334
+ injected = true;
335
+ await sql `UPDATE messages SET injected = TRUE WHERE id = ${msgRow.id}`;
336
+ }
337
+ }
338
+ catch (err) {
339
+ console.warn(`Warning: could not inject message into session ${recipient}:`, err);
340
+ }
341
+ }
342
+ results.push({ to: recipient, messageId: msgRow.id, injected });
343
+ }
344
+ res.status(201).json({ ok: true, results });
345
+ });
346
+ router.post("/messages/:id/read", async (req, res) => {
347
+ const id = parseInt(req.params.id, 10);
348
+ if (isNaN(id)) {
349
+ res.status(400).json({ error: "Invalid message id" });
350
+ return;
351
+ }
352
+ try {
353
+ const [updated] = await sql `
354
+ UPDATE messages SET read = TRUE WHERE id = ${id}
355
+ RETURNING id, from_name, to_name, body, read, injected, created_at
356
+ `;
357
+ if (!updated) {
358
+ res.status(404).json({ error: "Message not found" });
359
+ return;
360
+ }
361
+ res.json(updated);
362
+ }
363
+ catch (err) {
364
+ console.error("POST /messages/:id/read error:", err);
365
+ res.status(500).json({ error: "Internal server error" });
366
+ }
367
+ });
197
368
  return router;
198
369
  }
199
- // Separate unauthenticated router for worker endpoints
200
- export function createWorkerRouter(sql) {
370
+ export function createWorkerRouter(sql, opencode, serverUrl) {
201
371
  const router = Router();
202
- // GET /worker/clock-in — no Bearer auth, validated by agent_code
203
- // Usage: agent-office worker clock-in <agent_code>@<url>
204
372
  router.get("/worker/clock-in", async (req, res) => {
205
373
  const { code } = req.query;
206
374
  if (!code || typeof code !== "string") {
@@ -224,5 +392,109 @@ export function createWorkerRouter(sql) {
224
392
  message: `Welcome to the agent office, your name is ${session.name}. Your OpenCode session ID is ${session.session_id}. You are now clocked in and ready to work.`,
225
393
  });
226
394
  });
395
+ router.get("/worker/list-coworkers", async (req, res) => {
396
+ const { code } = req.query;
397
+ if (!code || typeof code !== "string") {
398
+ res.status(400).json({ error: "code query parameter is required" });
399
+ return;
400
+ }
401
+ const rows = await sql `
402
+ SELECT id, name, session_id, agent_code, created_at
403
+ FROM sessions
404
+ WHERE agent_code = ${code}
405
+ `;
406
+ if (rows.length === 0) {
407
+ res.status(401).json({ error: "Invalid agent code" });
408
+ return;
409
+ }
410
+ const session = rows[0];
411
+ try {
412
+ const sessions = await sql `SELECT name FROM sessions WHERE name != ${session.name}`;
413
+ const config = await sql `SELECT value FROM config WHERE key = 'human_name'`;
414
+ const humanName = config[0]?.value ?? "Human";
415
+ const workers = sessions.map((s) => s.name);
416
+ workers.push(humanName);
417
+ res.json(workers);
418
+ }
419
+ catch (err) {
420
+ console.error("GET /worker/list-coworkers error:", err);
421
+ res.status(500).json({ error: "Internal server error" });
422
+ }
423
+ });
424
+ router.post("/worker/send-message", async (req, res) => {
425
+ const { code } = req.query;
426
+ const { to, body } = req.body;
427
+ if (!code || typeof code !== "string") {
428
+ res.status(400).json({ error: "code query parameter is required" });
429
+ return;
430
+ }
431
+ const rows = await sql `
432
+ SELECT id, name, session_id, agent_code, created_at
433
+ FROM sessions
434
+ WHERE agent_code = ${code}
435
+ `;
436
+ if (rows.length === 0) {
437
+ res.status(401).json({ error: "Invalid agent code" });
438
+ return;
439
+ }
440
+ const session = rows[0];
441
+ if (!to || !Array.isArray(to) || to.length === 0) {
442
+ res.status(400).json({ error: "to must be a non-empty array of recipient names" });
443
+ return;
444
+ }
445
+ if (!body || typeof body !== "string" || !body.trim()) {
446
+ res.status(400).json({ error: "body is required" });
447
+ return;
448
+ }
449
+ const trimmedBody = body.trim();
450
+ const sessions = await sql `SELECT name, session_id FROM sessions`;
451
+ const sessionMap = new Map(sessions.map((s) => [s.name, s.session_id]));
452
+ const config = await sql `SELECT value FROM config WHERE key = 'human_name'`;
453
+ const humanName = config[0]?.value ?? "Human";
454
+ const validRecipients = [];
455
+ for (const recipient of to) {
456
+ if (typeof recipient !== "string" || !recipient.trim())
457
+ continue;
458
+ const r = recipient.trim();
459
+ if (sessionMap.has(r) || r === humanName) {
460
+ validRecipients.push(r);
461
+ }
462
+ }
463
+ if (validRecipients.length === 0) {
464
+ res.status(400).json({ error: "No valid recipients found" });
465
+ return;
466
+ }
467
+ const results = [];
468
+ for (const recipient of validRecipients) {
469
+ let injected = false;
470
+ const [msgRow] = await sql `
471
+ INSERT INTO messages (from_name, to_name, body)
472
+ VALUES (${session.name}, ${recipient}, ${trimmedBody})
473
+ RETURNING id, from_name, to_name, body, read, injected, created_at
474
+ `;
475
+ if (sessionMap.has(recipient)) {
476
+ const recipientSessionId = sessionMap.get(recipient);
477
+ try {
478
+ const providers = await opencode.app.providers();
479
+ const defaultEntry = Object.entries(providers.default)[0];
480
+ if (defaultEntry) {
481
+ const injectText = `[Message from "${session.name}"]: ${trimmedBody}`;
482
+ await opencode.session.chat(recipientSessionId, {
483
+ modelID: defaultEntry[0],
484
+ providerID: defaultEntry[1],
485
+ parts: [{ type: "text", text: injectText }],
486
+ });
487
+ injected = true;
488
+ await sql `UPDATE messages SET injected = TRUE WHERE id = ${msgRow.id}`;
489
+ }
490
+ }
491
+ catch (err) {
492
+ console.warn(`Warning: could not inject message into session ${recipient}:`, err);
493
+ }
494
+ }
495
+ results.push({ to: recipient, messageId: msgRow.id, injected });
496
+ }
497
+ res.status(201).json({ ok: true, results });
498
+ });
227
499
  return router;
228
500
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-office",
3
- "version": "0.0.0",
3
+ "version": "0.0.1",
4
4
  "description": "Manage OpenCode sessions with named aliases",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,7 +20,7 @@
20
20
  "dist"
21
21
  ],
22
22
  "scripts": {
23
- "dev:serve": "tsx src/cli.ts serve",
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
26
  "build": "tsc",