agent-office 0.0.0 → 0.0.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.
Files changed (33) hide show
  1. package/dist/cli.js +102 -3
  2. package/dist/commands/serve.js +9 -2
  3. package/dist/commands/worker.d.ts +13 -0
  4. package/dist/commands/worker.js +120 -5
  5. package/dist/db/index.d.ts +32 -0
  6. package/dist/db/migrate.js +66 -0
  7. package/dist/manage/app.js +55 -35
  8. package/dist/manage/components/CronList.d.ts +9 -0
  9. package/dist/manage/components/CronList.js +310 -0
  10. package/dist/manage/components/ItemSelector.d.ts +7 -0
  11. package/dist/manage/components/ItemSelector.js +20 -0
  12. package/dist/manage/components/MenuSelect.d.ts +13 -0
  13. package/dist/manage/components/MenuSelect.js +22 -0
  14. package/dist/manage/components/MyMail.d.ts +9 -0
  15. package/dist/manage/components/MyMail.js +143 -0
  16. package/dist/manage/components/Profile.d.ts +8 -0
  17. package/dist/manage/components/Profile.js +60 -0
  18. package/dist/manage/components/ReadMail.d.ts +8 -0
  19. package/dist/manage/components/ReadMail.js +110 -0
  20. package/dist/manage/components/SendMessage.d.ts +9 -0
  21. package/dist/manage/components/SendMessage.js +79 -0
  22. package/dist/manage/components/SessionList.js +392 -31
  23. package/dist/manage/components/SessionSidebar.d.ts +6 -0
  24. package/dist/manage/components/SessionSidebar.js +18 -0
  25. package/dist/manage/hooks/useApi.d.ts +74 -1
  26. package/dist/manage/hooks/useApi.js +69 -3
  27. package/dist/server/cron.d.ts +24 -0
  28. package/dist/server/cron.js +121 -0
  29. package/dist/server/index.d.ts +2 -1
  30. package/dist/server/index.js +3 -3
  31. package/dist/server/routes.d.ts +3 -2
  32. package/dist/server/routes.js +976 -23
  33. package/package.json +3 -2
@@ -1,15 +1,42 @@
1
1
  import { Router } from "express";
2
- export function createRouter(sql, opencode) {
2
+ import { Cron as CronerInstance } from "croner";
3
+ const MAIL_INJECTION_BLURB = [
4
+ ``,
5
+ `---`,
6
+ `You have a new message. Please review the injected message above and respond accordingly.`,
7
+ `Respond using markdown. Your markdown front-matter can contain a property "choices" which`,
8
+ `is an array of choices for the message sender to choose from. These choices are optional`,
9
+ `and shouldn't alter your authentic personality in your responses.`,
10
+ `IMPORTANT: in order for the sender to see your response, you must send them a message back`,
11
+ `using the \`agent-office worker send-message\` tool. Also, don't go on for too long if things seem repetitive.`,
12
+ ].join("\n");
13
+ export function createRouter(sql, opencode, serverUrl, scheduler) {
3
14
  const router = Router();
4
- // GET /health
5
15
  router.get("/health", (_req, res) => {
6
16
  res.json({ ok: true });
7
17
  });
8
- // GET /sessions
18
+ router.get("/modes", async (_req, res) => {
19
+ try {
20
+ const config = await opencode.config.get();
21
+ const agent = config.agent ?? {};
22
+ const modes = Object.entries(agent)
23
+ .filter(([, val]) => val != null)
24
+ .map(([name, val]) => ({
25
+ name,
26
+ description: val.description ?? "",
27
+ model: val.model ?? "",
28
+ }));
29
+ res.json(modes);
30
+ }
31
+ catch (err) {
32
+ console.error("GET /modes error:", err);
33
+ res.json([]);
34
+ }
35
+ });
9
36
  router.get("/sessions", async (_req, res) => {
10
37
  try {
11
38
  const rows = await sql `
12
- SELECT id, name, session_id, agent_code, created_at
39
+ SELECT id, name, session_id, agent_code, mode, created_at
13
40
  FROM sessions
14
41
  ORDER BY created_at DESC
15
42
  `;
@@ -20,14 +47,14 @@ export function createRouter(sql, opencode) {
20
47
  res.status(500).json({ error: "Internal server error" });
21
48
  }
22
49
  });
23
- // POST /sessions { name }
24
50
  router.post("/sessions", async (req, res) => {
25
- const { name } = req.body;
51
+ const { name, mode } = req.body;
26
52
  if (!name || typeof name !== "string" || !name.trim()) {
27
53
  res.status(400).json({ error: "name is required" });
28
54
  return;
29
55
  }
30
56
  const trimmedName = name.trim();
57
+ const trimmedMode = typeof mode === "string" && mode.trim() ? mode.trim() : null;
31
58
  const existing = await sql `
32
59
  SELECT id FROM sessions WHERE name = ${trimmedName}
33
60
  `;
@@ -35,7 +62,6 @@ export function createRouter(sql, opencode) {
35
62
  res.status(409).json({ error: `Session name "${trimmedName}" already exists` });
36
63
  return;
37
64
  }
38
- // Create the OpenCode session
39
65
  let opencodeSessionId;
40
66
  try {
41
67
  const session = await opencode.session.create();
@@ -46,25 +72,44 @@ export function createRouter(sql, opencode) {
46
72
  res.status(502).json({ error: "Failed to create OpenCode session", detail: String(err) });
47
73
  return;
48
74
  }
49
- // Persist — agent_code auto-generated by Postgres gen_random_uuid()
75
+ let row;
50
76
  try {
51
- const [row] = await sql `
52
- INSERT INTO sessions (name, session_id)
53
- VALUES (${trimmedName}, ${opencodeSessionId})
54
- RETURNING id, name, session_id, agent_code, created_at
77
+ const [inserted] = await sql `
78
+ INSERT INTO sessions (name, session_id, mode)
79
+ VALUES (${trimmedName}, ${opencodeSessionId}, ${trimmedMode})
80
+ RETURNING id, name, session_id, agent_code, mode, created_at
55
81
  `;
56
- res.status(201).json(row);
82
+ row = inserted;
57
83
  }
58
84
  catch (err) {
59
85
  console.error("DB insert error:", err);
60
86
  try {
61
87
  await opencode.session.delete(opencodeSessionId);
62
88
  }
63
- catch { /* best-effort */ }
89
+ catch { }
64
90
  res.status(500).json({ error: "Internal server error" });
91
+ return;
92
+ }
93
+ try {
94
+ const providers = await opencode.app.providers();
95
+ const defaultEntry = Object.entries(providers.default)[0];
96
+ if (defaultEntry) {
97
+ const clockInToken = `${row.agent_code}@${serverUrl}`;
98
+ const modeNote = trimmedMode ? `\n Mode: ${trimmedMode}` : "";
99
+ const firstMessage = `You have been enrolled in the agent office.${modeNote}\n\nTo clock in and receive your full briefing, run:\n\n agent-office worker clock-in ${clockInToken}`;
100
+ await opencode.session.chat(opencodeSessionId, {
101
+ modelID: defaultEntry[0],
102
+ providerID: defaultEntry[1],
103
+ parts: [{ type: "text", text: firstMessage }],
104
+ ...(trimmedMode ? { mode: trimmedMode } : {}),
105
+ });
106
+ }
107
+ }
108
+ catch (err) {
109
+ console.warn("Warning: could not send first message to session:", err);
65
110
  }
111
+ res.status(201).json(row);
66
112
  });
67
- // POST /sessions/:name/regenerate-code
68
113
  router.post("/sessions/:name/regenerate-code", async (req, res) => {
69
114
  const { name } = req.params;
70
115
  const rows = await sql `
@@ -88,7 +133,6 @@ export function createRouter(sql, opencode) {
88
133
  res.status(500).json({ error: "Internal server error" });
89
134
  }
90
135
  });
91
- // GET /sessions/:name/messages?limit=N
92
136
  router.get("/sessions/:name/messages", async (req, res) => {
93
137
  const { name } = req.params;
94
138
  const limit = Math.min(parseInt(req.query.limit ?? "20", 10), 100);
@@ -118,7 +162,6 @@ export function createRouter(sql, opencode) {
118
162
  res.status(502).json({ error: "Failed to fetch messages from OpenCode", detail: String(err) });
119
163
  }
120
164
  });
121
- // POST /sessions/:name/inject { text, modelID?, providerID? }
122
165
  router.post("/sessions/:name/inject", async (req, res) => {
123
166
  const { name } = req.params;
124
167
  const { text, modelID, providerID } = req.body;
@@ -166,7 +209,6 @@ export function createRouter(sql, opencode) {
166
209
  res.status(502).json({ error: "Failed to inject message into OpenCode session", detail: String(err) });
167
210
  }
168
211
  });
169
- // DELETE /sessions/:name
170
212
  router.delete("/sessions/:name", async (req, res) => {
171
213
  const { name } = req.params;
172
214
  const rows = await sql `
@@ -194,13 +236,416 @@ export function createRouter(sql, opencode) {
194
236
  res.status(500).json({ error: "Internal server error" });
195
237
  }
196
238
  });
239
+ router.get("/config", async (_req, res) => {
240
+ try {
241
+ const rows = await sql `SELECT key, value FROM config`;
242
+ const config = {};
243
+ for (const row of rows) {
244
+ config[row.key] = row.value;
245
+ }
246
+ res.json(config);
247
+ }
248
+ catch (err) {
249
+ console.error("GET /config error:", err);
250
+ res.status(500).json({ error: "Internal server error" });
251
+ }
252
+ });
253
+ router.put("/config", async (req, res) => {
254
+ const { key, value } = req.body;
255
+ if (!key || typeof key !== "string" || !key.trim()) {
256
+ res.status(400).json({ error: "key is required" });
257
+ return;
258
+ }
259
+ if (typeof value !== "string") {
260
+ res.status(400).json({ error: "value must be a string" });
261
+ return;
262
+ }
263
+ try {
264
+ await sql `
265
+ INSERT INTO config (key, value) VALUES (${key}, ${value})
266
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
267
+ `;
268
+ res.json({ ok: true, key, value });
269
+ }
270
+ catch (err) {
271
+ console.error("PUT /config error:", err);
272
+ res.status(500).json({ error: "Internal server error" });
273
+ }
274
+ });
275
+ router.get("/messages/:name", async (req, res) => {
276
+ const { name } = req.params;
277
+ const { sent, unread_only } = req.query;
278
+ try {
279
+ let rows;
280
+ if (sent === "true") {
281
+ rows = await sql `
282
+ SELECT id, from_name, to_name, body, read, injected, created_at
283
+ FROM messages
284
+ WHERE from_name = ${name}
285
+ ORDER BY created_at DESC
286
+ `;
287
+ }
288
+ else {
289
+ if (unread_only === "true") {
290
+ rows = await sql `
291
+ SELECT id, from_name, to_name, body, read, injected, created_at
292
+ FROM messages
293
+ WHERE to_name = ${name} AND read = FALSE
294
+ ORDER BY created_at DESC
295
+ `;
296
+ }
297
+ else {
298
+ rows = await sql `
299
+ SELECT id, from_name, to_name, body, read, injected, created_at
300
+ FROM messages
301
+ WHERE to_name = ${name}
302
+ ORDER BY created_at DESC
303
+ `;
304
+ }
305
+ }
306
+ res.json(rows);
307
+ }
308
+ catch (err) {
309
+ console.error("GET /messages/:name error:", err);
310
+ res.status(500).json({ error: "Internal server error" });
311
+ }
312
+ });
313
+ router.post("/messages", async (req, res) => {
314
+ const { from, to, body } = req.body;
315
+ if (!from || typeof from !== "string" || !from.trim()) {
316
+ res.status(400).json({ error: "from is required" });
317
+ return;
318
+ }
319
+ if (!to || !Array.isArray(to) || to.length === 0) {
320
+ res.status(400).json({ error: "to must be a non-empty array of recipient names" });
321
+ return;
322
+ }
323
+ if (!body || typeof body !== "string" || !body.trim()) {
324
+ res.status(400).json({ error: "body is required" });
325
+ return;
326
+ }
327
+ const trimmedFrom = from.trim();
328
+ const trimmedBody = body.trim();
329
+ const sessions = await sql `SELECT name, session_id FROM sessions`;
330
+ const sessionMap = new Map(sessions.map((s) => [s.name, s.session_id]));
331
+ const validRecipients = [];
332
+ for (const recipient of to) {
333
+ if (typeof recipient !== "string" || !recipient.trim())
334
+ continue;
335
+ const r = recipient.trim();
336
+ const config = await sql `SELECT value FROM config WHERE key = 'human_name'`;
337
+ const humanName = config[0]?.value ?? "Human";
338
+ if (sessionMap.has(r) || r === humanName) {
339
+ validRecipients.push(r);
340
+ }
341
+ }
342
+ if (validRecipients.length === 0) {
343
+ res.status(400).json({ error: "No valid recipients found" });
344
+ return;
345
+ }
346
+ const results = [];
347
+ for (const recipient of validRecipients) {
348
+ let injected = false;
349
+ const [msgRow] = await sql `
350
+ INSERT INTO messages (from_name, to_name, body)
351
+ VALUES (${trimmedFrom}, ${recipient}, ${trimmedBody})
352
+ RETURNING id, from_name, to_name, body, read, injected, created_at
353
+ `;
354
+ if (sessionMap.has(recipient)) {
355
+ const sessionId = sessionMap.get(recipient);
356
+ const msgId = msgRow.id;
357
+ injected = true;
358
+ opencode.app.providers().then((providers) => {
359
+ const defaultEntry = Object.entries(providers.default)[0];
360
+ if (!defaultEntry)
361
+ return;
362
+ const injectText = `[Message from "${trimmedFrom}"]: ${trimmedBody}${MAIL_INJECTION_BLURB}`;
363
+ return opencode.session.chat(sessionId, {
364
+ modelID: defaultEntry[0],
365
+ providerID: defaultEntry[1],
366
+ parts: [{ type: "text", text: injectText }],
367
+ }).then(() => sql `UPDATE messages SET injected = TRUE WHERE id = ${msgId}`);
368
+ }).catch((err) => {
369
+ console.warn(`Warning: could not inject message into session ${recipient}:`, err);
370
+ });
371
+ }
372
+ results.push({ to: recipient, messageId: msgRow.id, injected });
373
+ }
374
+ res.status(201).json({ ok: true, results });
375
+ });
376
+ router.post("/messages/:id/read", async (req, res) => {
377
+ const id = parseInt(req.params.id, 10);
378
+ if (isNaN(id)) {
379
+ res.status(400).json({ error: "Invalid message id" });
380
+ return;
381
+ }
382
+ try {
383
+ const [updated] = await sql `
384
+ UPDATE messages SET read = TRUE WHERE id = ${id}
385
+ RETURNING id, from_name, to_name, body, read, injected, created_at
386
+ `;
387
+ if (!updated) {
388
+ res.status(404).json({ error: "Message not found" });
389
+ return;
390
+ }
391
+ res.json(updated);
392
+ }
393
+ catch (err) {
394
+ console.error("POST /messages/:id/read error:", err);
395
+ res.status(500).json({ error: "Internal server error" });
396
+ }
397
+ });
398
+ // ── Cron Jobs Endpoints ────────────────────────────────────────────────────
399
+ router.get("/crons", async (req, res) => {
400
+ try {
401
+ const { session_name } = req.query;
402
+ let rows;
403
+ if (session_name) {
404
+ rows = await sql `
405
+ SELECT id, name, session_name, schedule, timezone, message, enabled, created_at, last_run
406
+ FROM cron_jobs
407
+ WHERE session_name = ${session_name}
408
+ ORDER BY name
409
+ `;
410
+ }
411
+ else {
412
+ rows = await sql `
413
+ SELECT id, name, session_name, schedule, timezone, message, enabled, created_at, last_run
414
+ FROM cron_jobs
415
+ ORDER BY name
416
+ `;
417
+ }
418
+ const jobs = rows.map((job) => {
419
+ let nextRun = null;
420
+ if (job.enabled) {
421
+ try {
422
+ const options = {};
423
+ if (job.timezone)
424
+ options.timezone = job.timezone;
425
+ const cronJob = new CronerInstance(job.schedule, options);
426
+ const next = cronJob.nextRun();
427
+ nextRun = next ? next.toISOString() : null;
428
+ }
429
+ catch { }
430
+ }
431
+ return { ...job, next_run: nextRun, last_run: job.last_run ? job.last_run.toISOString() : null, created_at: job.created_at.toISOString() };
432
+ });
433
+ res.json(jobs);
434
+ }
435
+ catch (err) {
436
+ console.error("GET /crons error:", err);
437
+ res.status(500).json({ error: "Internal server error" });
438
+ }
439
+ });
440
+ router.post("/crons", async (req, res) => {
441
+ const { name, session_name, schedule, message, timezone } = req.body;
442
+ if (!name || typeof name !== "string" || !name.trim()) {
443
+ res.status(400).json({ error: "name is required" });
444
+ return;
445
+ }
446
+ if (!session_name || typeof session_name !== "string") {
447
+ res.status(400).json({ error: "session_name is required" });
448
+ return;
449
+ }
450
+ if (!schedule || typeof schedule !== "string" || !schedule.trim()) {
451
+ res.status(400).json({ error: "schedule is required" });
452
+ return;
453
+ }
454
+ if (!message || typeof message !== "string" || !message.trim()) {
455
+ res.status(400).json({ error: "message is required" });
456
+ return;
457
+ }
458
+ const trimmedName = name.trim();
459
+ const trimmedSchedule = schedule.trim();
460
+ const trimmedMessage = message.trim();
461
+ try {
462
+ new CronerInstance(trimmedSchedule);
463
+ }
464
+ catch {
465
+ res.status(400).json({ error: "Invalid cron schedule expression" });
466
+ return;
467
+ }
468
+ if (timezone) {
469
+ try {
470
+ new CronerInstance("0 0 * * *", { timezone });
471
+ }
472
+ catch {
473
+ res.status(400).json({ error: "Invalid timezone" });
474
+ return;
475
+ }
476
+ }
477
+ const [existing] = await sql `
478
+ SELECT id FROM cron_jobs WHERE name = ${trimmedName} AND session_name = ${session_name}
479
+ `;
480
+ if (existing) {
481
+ res.status(409).json({ error: `Cron job "${trimmedName}" already exists for this session` });
482
+ return;
483
+ }
484
+ try {
485
+ const [inserted] = await sql `
486
+ INSERT INTO cron_jobs (name, session_name, schedule, timezone, message)
487
+ VALUES (${trimmedName}, ${session_name}, ${trimmedSchedule}, ${timezone ?? null}, ${trimmedMessage})
488
+ RETURNING id, name, session_name, schedule, timezone, message, enabled, created_at, last_run
489
+ `;
490
+ const cronJobrow = inserted;
491
+ scheduler.addCronJob(cronJobrow);
492
+ const nextRun = cronJobrow.enabled ? (() => {
493
+ try {
494
+ const options = {};
495
+ if (cronJobrow.timezone)
496
+ options.timezone = cronJobrow.timezone;
497
+ const cronJob = new CronerInstance(cronJobrow.schedule, options);
498
+ const next = cronJob.nextRun();
499
+ return next ? next.toISOString() : null;
500
+ }
501
+ catch {
502
+ return null;
503
+ }
504
+ })() : null;
505
+ res.status(201).json({
506
+ ...cronJobrow,
507
+ next_run: nextRun,
508
+ last_run: cronJobrow?.last_run?.toISOString() ?? null,
509
+ created_at: cronJobrow.created_at.toISOString(),
510
+ });
511
+ }
512
+ catch (err) {
513
+ console.error("POST /crons error:", err);
514
+ res.status(500).json({ error: "Internal server error" });
515
+ }
516
+ });
517
+ router.delete("/crons/:id", async (req, res) => {
518
+ const id = parseInt(req.params.id, 10);
519
+ if (isNaN(id)) {
520
+ res.status(400).json({ error: "Invalid cron job id" });
521
+ return;
522
+ }
523
+ try {
524
+ const [job] = await sql `SELECT id, name FROM cron_jobs WHERE id = ${id}`;
525
+ if (!job) {
526
+ res.status(404).json({ error: "Cron job not found" });
527
+ return;
528
+ }
529
+ scheduler.removeCronJob(id);
530
+ await sql `DELETE FROM cron_jobs WHERE id = ${id}`;
531
+ res.json({ deleted: true, id, name: job.name });
532
+ }
533
+ catch (err) {
534
+ console.error("DELETE /crons/:id error:", err);
535
+ res.status(500).json({ error: "Internal server error" });
536
+ }
537
+ });
538
+ router.post("/crons/:id/enable", async (req, res) => {
539
+ const id = parseInt(req.params.id, 10);
540
+ if (isNaN(id)) {
541
+ res.status(400).json({ error: "Invalid cron job id" });
542
+ return;
543
+ }
544
+ try {
545
+ const [existing] = await sql `
546
+ SELECT id, name FROM cron_jobs WHERE id = ${id}
547
+ `;
548
+ if (!existing) {
549
+ res.status(404).json({ error: "Cron job not found" });
550
+ return;
551
+ }
552
+ await sql `UPDATE cron_jobs SET enabled = TRUE WHERE id = ${id}`;
553
+ const [updated] = await sql `
554
+ SELECT id, name, session_name, schedule, timezone, message, enabled, created_at, last_run
555
+ FROM cron_jobs WHERE id = ${id}
556
+ `;
557
+ if (updated) {
558
+ scheduler.enableCronJob(updated);
559
+ const nextRun = (() => {
560
+ try {
561
+ const options = {};
562
+ if (updated.timezone)
563
+ options.timezone = updated.timezone;
564
+ const cronJob = new CronerInstance(updated.schedule, options);
565
+ const next = cronJob.nextRun();
566
+ return next ? next.toISOString() : null;
567
+ }
568
+ catch {
569
+ return null;
570
+ }
571
+ })();
572
+ res.json({
573
+ ...updated,
574
+ next_run: nextRun,
575
+ last_run: updated.last_run?.toISOString() ?? null,
576
+ created_at: updated.created_at.toISOString(),
577
+ });
578
+ }
579
+ else {
580
+ res.status(404).json({ error: "Cron job not found" });
581
+ }
582
+ }
583
+ catch (err) {
584
+ console.error("POST /crons/:id/enable error:", err);
585
+ res.status(500).json({ error: "Internal server error" });
586
+ }
587
+ });
588
+ router.post("/crons/:id/disable", async (req, res) => {
589
+ const id = parseInt(req.params.id, 10);
590
+ if (isNaN(id)) {
591
+ res.status(400).json({ error: "Invalid cron job id" });
592
+ return;
593
+ }
594
+ try {
595
+ const [job] = await sql `
596
+ SELECT id, name FROM cron_jobs WHERE id = ${id}
597
+ `;
598
+ if (!job) {
599
+ res.status(404).json({ error: "Cron job not found" });
600
+ return;
601
+ }
602
+ await sql `UPDATE cron_jobs SET enabled = FALSE WHERE id = ${id}`;
603
+ scheduler.disableCronJob(id);
604
+ const [updated] = await sql `
605
+ SELECT id, name, session_name, schedule, timezone, message, enabled, created_at, last_run
606
+ FROM cron_jobs WHERE id = ${id}
607
+ `;
608
+ res.json({
609
+ ...updated,
610
+ next_run: null,
611
+ last_run: updated?.last_run?.toISOString() ?? null,
612
+ created_at: updated?.created_at.toISOString() ?? null,
613
+ });
614
+ }
615
+ catch (err) {
616
+ console.error("POST /crons/:id/disable error:", err);
617
+ res.status(500).json({ error: "Internal server error" });
618
+ }
619
+ });
620
+ router.get("/crons/:id/history", async (req, res) => {
621
+ const id = parseInt(req.params.id, 10);
622
+ const limit = Math.min(parseInt(req.query.limit ?? "10", 10), 100);
623
+ if (isNaN(id)) {
624
+ res.status(400).json({ error: "Invalid cron job id" });
625
+ return;
626
+ }
627
+ try {
628
+ const rows = await sql `
629
+ SELECT id, cron_job_id, executed_at, success, error_message
630
+ FROM cron_history
631
+ WHERE cron_job_id = ${id}
632
+ ORDER BY executed_at DESC
633
+ LIMIT ${limit}
634
+ `;
635
+ res.json(rows.map((r) => ({
636
+ ...r,
637
+ executed_at: r.executed_at.toISOString(),
638
+ })));
639
+ }
640
+ catch (err) {
641
+ console.error("GET /crons/:id/history error:", err);
642
+ res.status(500).json({ error: "Internal server error" });
643
+ }
644
+ });
197
645
  return router;
198
646
  }
199
- // Separate unauthenticated router for worker endpoints
200
- export function createWorkerRouter(sql) {
647
+ export function createWorkerRouter(sql, opencode, serverUrl) {
201
648
  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
649
  router.get("/worker/clock-in", async (req, res) => {
205
650
  const { code } = req.query;
206
651
  if (!code || typeof code !== "string") {
@@ -217,12 +662,520 @@ export function createWorkerRouter(sql) {
217
662
  return;
218
663
  }
219
664
  const session = rows[0];
665
+ const token = `${session.agent_code}@<server-url>`;
666
+ const name = session.name;
667
+ const humanConfig = await sql `SELECT value FROM config WHERE key = 'human_name'`;
668
+ const humanName = humanConfig[0]?.value ?? "your human manager";
669
+ const humanDescConfig = await sql `SELECT value FROM config WHERE key = 'human_description'`;
670
+ const humanDescription = humanDescConfig[0]?.value ?? "";
671
+ const message = [
672
+ `╔══════════════════════════════════════════════════════╗`,
673
+ `║ WELCOME TO THE AGENT OFFICE ║`,
674
+ `╚══════════════════════════════════════════════════════╝`,
675
+ ``,
676
+ `You are now clocked in.`,
677
+ ` Name: ${name}`,
678
+ ...(session.mode ? [` Mode: ${session.mode}`] : []),
679
+ ` Human manager: ${humanName} — the human who created your`,
680
+ ` session, assigns your work, and is your`,
681
+ ` primary point of contact for questions,`,
682
+ ` updates, and decisions.`,
683
+ ...(humanDescription ? [
684
+ ` "${humanDescription}"`,
685
+ ] : []),
686
+ ``,
687
+ `The agent-office CLI is your PRIMARY means of communicating`,
688
+ `with your human manager (${humanName}) and your coworkers.`,
689
+ `Use it to send and receive messages, and to discover who`,
690
+ `else is working.`,
691
+ ``,
692
+ `════════════════════════════════════════════════════════`,
693
+ ` AVAILABLE COMMANDS`,
694
+ `════════════════════════════════════════════════════════`,
695
+ ``,
696
+ ` List your coworkers`,
697
+ ` agent-office worker list-coworkers \\`,
698
+ ` ${token}`,
699
+ ``,
700
+ ` Send a message to your manager or a coworker`,
701
+ ` agent-office worker send-message \\`,
702
+ ` --name <recipient-name> \\`,
703
+ ` --body "Your message here" \\`,
704
+ ` ${token}`,
705
+ ``,
706
+ ` Send a message to multiple recipients at once`,
707
+ ` agent-office worker send-message \\`,
708
+ ` --name alice --name bob \\`,
709
+ ` --body "Your message here" \\`,
710
+ ` ${token}`,
711
+ ``,
712
+ ` Manage scheduled tasks (optional)`,
713
+ ` agent-office worker cron \\`,
714
+ ` ${token}`,
715
+ ``,
716
+ `════════════════════════════════════════════════════════`,
717
+ ` ⚠ IMPORTANT: YOUR SESSIONS ARE PRIVATE`,
718
+ `════════════════════════════════════════════════════════`,
719
+ ``,
720
+ ` Nobody — not ${humanName}, not your coworkers — can see`,
721
+ ` anything you think, reason, or write inside this session.`,
722
+ ` Your work is completely private until you explicitly send`,
723
+ ` a message using the send-message command.`,
724
+ ``,
725
+ ` This means:`,
726
+ ` • You must send a message to report progress or ask`,
727
+ ` for help. Silence looks like inaction.`,
728
+ ` • Finishing a task without messaging ${humanName}`,
729
+ ` means they will never know it is done.`,
730
+ ` • If you are blocked or uncertain, send a message —`,
731
+ ` nobody will know otherwise.`,
732
+ ``,
733
+ `════════════════════════════════════════════════════════`,
734
+ ` TIPS`,
735
+ `════════════════════════════════════════════════════════`,
736
+ ``,
737
+ ` - Run list-coworkers to discover who is available and`,
738
+ ` what their names are before sending messages.`,
739
+ ` - Messages you send are delivered directly into the`,
740
+ ` recipient's active session — they will see them`,
741
+ ` immediately.`,
742
+ ` - Your human manager is ${humanName}. They can send you`,
743
+ ` messages at any time and those will appear here in`,
744
+ ` your session just like this one. You can reach them`,
745
+ ` by sending a message to --name ${humanName}.`,
746
+ ` - Optional: Set up recurring scheduled tasks with cron`,
747
+ ` jobs. Run 'agent-office worker cron list ${token}' to`,
748
+ ` get started.`,
749
+ ``,
750
+ ].join("\n");
220
751
  res.json({
221
752
  ok: true,
222
753
  name: session.name,
223
754
  session_id: session.session_id,
224
- 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.`,
755
+ message,
225
756
  });
226
757
  });
758
+ router.get("/worker/list-coworkers", async (req, res) => {
759
+ const { code } = req.query;
760
+ if (!code || typeof code !== "string") {
761
+ res.status(400).json({ error: "code query parameter is required" });
762
+ return;
763
+ }
764
+ const rows = await sql `
765
+ SELECT id, name, session_id, agent_code, created_at
766
+ FROM sessions
767
+ WHERE agent_code = ${code}
768
+ `;
769
+ if (rows.length === 0) {
770
+ res.status(401).json({ error: "Invalid agent code" });
771
+ return;
772
+ }
773
+ const session = rows[0];
774
+ try {
775
+ const sessions = await sql `SELECT name FROM sessions WHERE name != ${session.name}`;
776
+ const config = await sql `SELECT value FROM config WHERE key = 'human_name'`;
777
+ const humanName = config[0]?.value ?? "Human";
778
+ const workers = sessions.map((s) => s.name);
779
+ workers.push(humanName);
780
+ res.json(workers);
781
+ }
782
+ catch (err) {
783
+ console.error("GET /worker/list-coworkers error:", err);
784
+ res.status(500).json({ error: "Internal server error" });
785
+ }
786
+ });
787
+ router.post("/worker/send-message", async (req, res) => {
788
+ const { code } = req.query;
789
+ const { to, body } = req.body;
790
+ if (!code || typeof code !== "string") {
791
+ res.status(400).json({ error: "code query parameter is required" });
792
+ return;
793
+ }
794
+ const rows = await sql `
795
+ SELECT id, name, session_id, agent_code, created_at
796
+ FROM sessions
797
+ WHERE agent_code = ${code}
798
+ `;
799
+ if (rows.length === 0) {
800
+ res.status(401).json({ error: "Invalid agent code" });
801
+ return;
802
+ }
803
+ const session = rows[0];
804
+ if (!to || !Array.isArray(to) || to.length === 0) {
805
+ res.status(400).json({ error: "to must be a non-empty array of recipient names" });
806
+ return;
807
+ }
808
+ if (!body || typeof body !== "string" || !body.trim()) {
809
+ res.status(400).json({ error: "body is required" });
810
+ return;
811
+ }
812
+ const trimmedBody = body.trim();
813
+ const sessions = await sql `SELECT name, session_id FROM sessions`;
814
+ const sessionMap = new Map(sessions.map((s) => [s.name, s.session_id]));
815
+ const config = await sql `SELECT value FROM config WHERE key = 'human_name'`;
816
+ const humanName = config[0]?.value ?? "Human";
817
+ const validRecipients = [];
818
+ for (const recipient of to) {
819
+ if (typeof recipient !== "string" || !recipient.trim())
820
+ continue;
821
+ const r = recipient.trim();
822
+ if (sessionMap.has(r) || r === humanName) {
823
+ validRecipients.push(r);
824
+ }
825
+ }
826
+ if (validRecipients.length === 0) {
827
+ res.status(400).json({ error: "No valid recipients found" });
828
+ return;
829
+ }
830
+ const results = [];
831
+ for (const recipient of validRecipients) {
832
+ let injected = false;
833
+ const [msgRow] = await sql `
834
+ INSERT INTO messages (from_name, to_name, body)
835
+ VALUES (${session.name}, ${recipient}, ${trimmedBody})
836
+ RETURNING id, from_name, to_name, body, read, injected, created_at
837
+ `;
838
+ if (sessionMap.has(recipient)) {
839
+ const recipientSessionId = sessionMap.get(recipient);
840
+ const msgId = msgRow.id;
841
+ injected = true;
842
+ opencode.app.providers().then((providers) => {
843
+ const defaultEntry = Object.entries(providers.default)[0];
844
+ if (!defaultEntry)
845
+ return;
846
+ const injectText = `[Message from "${session.name}"]: ${trimmedBody}${MAIL_INJECTION_BLURB}`;
847
+ return opencode.session.chat(recipientSessionId, {
848
+ modelID: defaultEntry[0],
849
+ providerID: defaultEntry[1],
850
+ parts: [{ type: "text", text: injectText }],
851
+ }).then(() => sql `UPDATE messages SET injected = TRUE WHERE id = ${msgId}`);
852
+ }).catch((err) => {
853
+ console.warn(`Warning: could not inject message into session ${recipient}:`, err);
854
+ });
855
+ }
856
+ results.push({ to: recipient, messageId: msgRow.id, injected });
857
+ }
858
+ res.status(201).json({ ok: true, results });
859
+ });
860
+ // ── Worker Cron Endpoints (authenticated via agent_code in query) ───────────
861
+ router.get("/worker/crons", async (req, res) => {
862
+ const { code } = req.query;
863
+ if (!code || typeof code !== "string") {
864
+ res.status(400).json({ error: "code query parameter is required" });
865
+ return;
866
+ }
867
+ const session = await sql `
868
+ SELECT name FROM sessions WHERE agent_code = ${code}
869
+ `;
870
+ if (session.length === 0) {
871
+ res.status(401).json({ error: "Invalid agent code" });
872
+ return;
873
+ }
874
+ const sessionName = session[0].name;
875
+ try {
876
+ const rows = await sql `
877
+ SELECT id, name, session_name, schedule, timezone, message, enabled, created_at, last_run
878
+ FROM cron_jobs
879
+ WHERE session_name = ${sessionName}
880
+ ORDER BY name
881
+ `;
882
+ const jobs = rows.map((job) => {
883
+ let nextRun = null;
884
+ if (job.enabled) {
885
+ try {
886
+ const options = {};
887
+ if (job.timezone)
888
+ options.timezone = job.timezone;
889
+ const cronJob = new CronerInstance(job.schedule, options);
890
+ const next = cronJob.nextRun();
891
+ nextRun = next ? next.toISOString() : null;
892
+ }
893
+ catch { }
894
+ }
895
+ return {
896
+ ...job,
897
+ next_run: nextRun,
898
+ last_run: job.last_run ? job.last_run.toISOString() : null,
899
+ created_at: job.created_at.toISOString(),
900
+ };
901
+ });
902
+ res.json(jobs);
903
+ }
904
+ catch (err) {
905
+ console.error("GET /worker/crons error:", err);
906
+ res.status(500).json({ error: "Internal server error" });
907
+ }
908
+ });
909
+ router.post("/worker/crons", async (req, res) => {
910
+ const { code } = req.query;
911
+ const { name, schedule, message, timezone } = req.body;
912
+ if (!code || typeof code !== "string") {
913
+ res.status(400).json({ error: "code query parameter is required" });
914
+ return;
915
+ }
916
+ const session = await sql `
917
+ SELECT name FROM sessions WHERE agent_code = ${code}
918
+ `;
919
+ if (session.length === 0) {
920
+ res.status(401).json({ error: "Invalid agent code" });
921
+ return;
922
+ }
923
+ if (!name || typeof name !== "string" || !name.trim()) {
924
+ res.status(400).json({ error: "name is required" });
925
+ return;
926
+ }
927
+ if (!schedule || typeof schedule !== "string" || !schedule.trim()) {
928
+ res.status(400).json({ error: "schedule is required" });
929
+ return;
930
+ }
931
+ if (!message || typeof message !== "string" || !message.trim()) {
932
+ res.status(400).json({ error: "message is required" });
933
+ return;
934
+ }
935
+ const trimmedName = name.trim();
936
+ const trimmedSchedule = schedule.trim();
937
+ const trimmedMessage = message.trim();
938
+ const sessionName = session[0].name;
939
+ try {
940
+ new CronerInstance(trimmedSchedule);
941
+ }
942
+ catch {
943
+ res.status(400).json({ error: "Invalid cron schedule expression" });
944
+ return;
945
+ }
946
+ if (timezone) {
947
+ try {
948
+ new CronerInstance("0 0 * * *", { timezone });
949
+ }
950
+ catch {
951
+ res.status(400).json({ error: "Invalid timezone" });
952
+ return;
953
+ }
954
+ }
955
+ const [existing] = await sql `
956
+ SELECT id FROM cron_jobs WHERE name = ${trimmedName} AND session_name = ${sessionName}
957
+ `;
958
+ if (existing) {
959
+ res.status(409).json({ error: `Cron job "${trimmedName}" already exists` });
960
+ return;
961
+ }
962
+ try {
963
+ const [inserted] = await sql `
964
+ INSERT INTO cron_jobs (name, session_name, schedule, timezone, message)
965
+ VALUES (${trimmedName}, ${sessionName}, ${trimmedSchedule}, ${timezone ?? null}, ${trimmedMessage})
966
+ RETURNING id, name, session_name, schedule, timezone, message, enabled, created_at, last_run
967
+ `;
968
+ const cronJobrow = inserted;
969
+ const nextRun = cronJobrow.enabled ? (() => {
970
+ try {
971
+ const options = {};
972
+ if (cronJobrow.timezone)
973
+ options.timezone = cronJobrow.timezone;
974
+ const cronJob = new CronerInstance(cronJobrow.schedule, options);
975
+ const next = cronJob.nextRun();
976
+ return next ? next.toISOString() : null;
977
+ }
978
+ catch {
979
+ return null;
980
+ }
981
+ })() : null;
982
+ res.status(201).json({
983
+ ...cronJobrow,
984
+ next_run: nextRun,
985
+ last_run: cronJobrow?.last_run?.toISOString() ?? null,
986
+ created_at: cronJobrow.created_at.toISOString(),
987
+ });
988
+ }
989
+ catch (err) {
990
+ console.error("POST /worker/crons error:", err);
991
+ res.status(500).json({ error: "Internal server error" });
992
+ }
993
+ });
994
+ router.delete("/worker/crons/:id", async (req, res) => {
995
+ const { code } = req.query;
996
+ const id = parseInt(req.params.id, 10);
997
+ if (!code || typeof code !== "string") {
998
+ res.status(400).json({ error: "code query parameter is required" });
999
+ return;
1000
+ }
1001
+ if (isNaN(id)) {
1002
+ res.status(400).json({ error: "Invalid cron job id" });
1003
+ return;
1004
+ }
1005
+ const session = await sql `
1006
+ SELECT name FROM sessions WHERE agent_code = ${code}
1007
+ `;
1008
+ if (session.length === 0) {
1009
+ res.status(401).json({ error: "Invalid agent code" });
1010
+ return;
1011
+ }
1012
+ const sessionName = session[0].name;
1013
+ try {
1014
+ const [job] = await sql `
1015
+ SELECT id, name FROM cron_jobs WHERE id = ${id} AND session_name = ${sessionName}
1016
+ `;
1017
+ if (!job) {
1018
+ res.status(404).json({ error: "Cron job not found or not owned by you" });
1019
+ return;
1020
+ }
1021
+ await sql `DELETE FROM cron_jobs WHERE id = ${id}`;
1022
+ res.json({ deleted: true, id, name: job.name });
1023
+ }
1024
+ catch (err) {
1025
+ console.error("DELETE /worker/crons/:id error:", err);
1026
+ res.status(500).json({ error: "Internal server error" });
1027
+ }
1028
+ });
1029
+ router.post("/worker/crons/:id/enable", async (req, res) => {
1030
+ const { code } = req.query;
1031
+ const id = parseInt(req.params.id, 10);
1032
+ if (!code || typeof code !== "string") {
1033
+ res.status(400).json({ error: "code query parameter is required" });
1034
+ return;
1035
+ }
1036
+ if (isNaN(id)) {
1037
+ res.status(400).json({ error: "Invalid cron job id" });
1038
+ return;
1039
+ }
1040
+ const session = await sql `
1041
+ SELECT name FROM sessions WHERE agent_code = ${code}
1042
+ `;
1043
+ if (session.length === 0) {
1044
+ res.status(401).json({ error: "Invalid agent code" });
1045
+ return;
1046
+ }
1047
+ const sessionName = session[0].name;
1048
+ try {
1049
+ const [existing] = await sql `
1050
+ SELECT id FROM cron_jobs WHERE id = ${id} AND session_name = ${sessionName}
1051
+ `;
1052
+ if (!existing) {
1053
+ res.status(404).json({ error: "Cron job not found or not owned by you" });
1054
+ return;
1055
+ }
1056
+ await sql `UPDATE cron_jobs SET enabled = TRUE WHERE id = ${id}`;
1057
+ const [updated] = await sql `
1058
+ SELECT id, name, session_name, schedule, timezone, message, enabled, created_at, last_run
1059
+ FROM cron_jobs WHERE id = ${id}
1060
+ `;
1061
+ if (updated) {
1062
+ const nextRun = (() => {
1063
+ try {
1064
+ const options = {};
1065
+ if (updated.timezone)
1066
+ options.timezone = updated.timezone;
1067
+ const cronJob = new CronerInstance(updated.schedule, options);
1068
+ const next = cronJob.nextRun();
1069
+ return next ? next.toISOString() : null;
1070
+ }
1071
+ catch {
1072
+ return null;
1073
+ }
1074
+ })();
1075
+ res.json({
1076
+ ...updated,
1077
+ next_run: nextRun,
1078
+ last_run: updated.last_run?.toISOString() ?? null,
1079
+ created_at: updated.created_at.toISOString(),
1080
+ });
1081
+ }
1082
+ else {
1083
+ res.status(404).json({ error: "Cron job not found" });
1084
+ }
1085
+ }
1086
+ catch (err) {
1087
+ console.error("POST /worker/crons/:id/enable error:", err);
1088
+ res.status(500).json({ error: "Internal server error" });
1089
+ }
1090
+ });
1091
+ router.post("/worker/crons/:id/disable", async (req, res) => {
1092
+ const { code } = req.query;
1093
+ const id = parseInt(req.params.id, 10);
1094
+ if (!code || typeof code !== "string") {
1095
+ res.status(400).json({ error: "code query parameter is required" });
1096
+ return;
1097
+ }
1098
+ if (isNaN(id)) {
1099
+ res.status(400).json({ error: "Invalid cron job id" });
1100
+ return;
1101
+ }
1102
+ const session = await sql `
1103
+ SELECT name FROM sessions WHERE agent_code = ${code}
1104
+ `;
1105
+ if (session.length === 0) {
1106
+ res.status(401).json({ error: "Invalid agent code" });
1107
+ return;
1108
+ }
1109
+ const sessionName = session[0].name;
1110
+ try {
1111
+ const [job] = await sql `
1112
+ SELECT id FROM cron_jobs WHERE id = ${id} AND session_name = ${sessionName}
1113
+ `;
1114
+ if (!job) {
1115
+ res.status(404).json({ error: "Cron job not found or not owned by you" });
1116
+ return;
1117
+ }
1118
+ await sql `UPDATE cron_jobs SET enabled = FALSE WHERE id = ${id}`;
1119
+ const [updated] = await sql `
1120
+ SELECT id, name, session_name, schedule, timezone, message, enabled, created_at, last_run
1121
+ FROM cron_jobs WHERE id = ${id}
1122
+ `;
1123
+ res.json({
1124
+ ...updated,
1125
+ next_run: null,
1126
+ last_run: updated?.last_run?.toISOString() ?? null,
1127
+ created_at: updated?.created_at.toISOString() ?? null,
1128
+ });
1129
+ }
1130
+ catch (err) {
1131
+ console.error("POST /worker/crons/:id/disable error:", err);
1132
+ res.status(500).json({ error: "Internal server error" });
1133
+ }
1134
+ });
1135
+ router.get("/worker/crons/:id/history", async (req, res) => {
1136
+ const { code } = req.query;
1137
+ const id = parseInt(req.params.id, 10);
1138
+ const limit = Math.min(parseInt(req.query.limit ?? "10", 10), 100);
1139
+ if (!code || typeof code !== "string") {
1140
+ res.status(400).json({ error: "code query parameter is required" });
1141
+ return;
1142
+ }
1143
+ if (isNaN(id)) {
1144
+ res.status(400).json({ error: "Invalid cron job id" });
1145
+ return;
1146
+ }
1147
+ const session = await sql `
1148
+ SELECT name FROM sessions WHERE agent_code = ${code}
1149
+ `;
1150
+ if (session.length === 0) {
1151
+ res.status(401).json({ error: "Invalid agent code" });
1152
+ return;
1153
+ }
1154
+ const sessionName = session[0].name;
1155
+ try {
1156
+ const [job] = await sql `
1157
+ SELECT id FROM cron_jobs WHERE id = ${id} AND session_name = ${sessionName}
1158
+ `;
1159
+ if (!job) {
1160
+ res.status(404).json({ error: "Cron job not found or not owned by you" });
1161
+ return;
1162
+ }
1163
+ const rows = await sql `
1164
+ SELECT id, cron_job_id, executed_at, success, error_message
1165
+ FROM cron_history
1166
+ WHERE cron_job_id = ${id}
1167
+ ORDER BY executed_at DESC
1168
+ LIMIT ${limit}
1169
+ `;
1170
+ res.json(rows.map((r) => ({
1171
+ ...r,
1172
+ executed_at: r.executed_at.toISOString(),
1173
+ })));
1174
+ }
1175
+ catch (err) {
1176
+ console.error("GET /worker/crons/:id/history error:", err);
1177
+ res.status(500).json({ error: "Internal server error" });
1178
+ }
1179
+ });
227
1180
  return router;
228
1181
  }