agent-office-cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-office-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Run and manage AI agent sessions locally, with optional relay to agentoffice.top",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -3,11 +3,13 @@ const DEFAULT_LAN_HOST = "0.0.0.0";
3
3
  const DEFAULT_PORT = 8765;
4
4
  const DEFAULT_SERVER_URL = `http://${DEFAULT_HOST}:${DEFAULT_PORT}`;
5
5
  const LOG_LIMIT = 1000;
6
+ const TERMINAL_REPLAY_LIMIT = 128 * 1024;
6
7
 
7
8
  module.exports = {
8
9
  DEFAULT_HOST,
9
10
  DEFAULT_LAN_HOST,
10
11
  DEFAULT_PORT,
11
12
  DEFAULT_SERVER_URL,
12
- LOG_LIMIT
13
+ LOG_LIMIT,
14
+ TERMINAL_REPLAY_LIMIT
13
15
  };
@@ -1,6 +1,32 @@
1
1
  const { GenericProvider } = require("./generic");
2
2
  const { findManagedCodexSessionFile, summarizeCodexSession } = require("./codex-transcript");
3
3
 
4
+ function activeOverlayPatch(session, nextLifecycleState) {
5
+ if (!session || !["approval", "attention"].includes(session.displayState)) {
6
+ return null;
7
+ }
8
+
9
+ if (nextLifecycleState === "idle") {
10
+ return {
11
+ displayState: "idle",
12
+ displayZone: "idle-zone",
13
+ meta: {
14
+ overlayState: null,
15
+ overlayUpdatedAt: null
16
+ }
17
+ };
18
+ }
19
+
20
+ return {
21
+ displayState: session.displayState,
22
+ displayZone: session.displayZone,
23
+ meta: {
24
+ overlayState: session.displayState,
25
+ overlayUpdatedAt: session.updatedAt || null
26
+ }
27
+ };
28
+ }
29
+
4
30
  class CodexProvider extends GenericProvider {
5
31
  constructor() {
6
32
  super("codex");
@@ -25,7 +51,14 @@ class CodexProvider extends GenericProvider {
25
51
  if (text.includes("approval") || text.includes("press enter") || text.includes("confirm")) {
26
52
  return "approval";
27
53
  }
28
- if (text.includes("error") || text.includes("failed") || text.includes("panic")) {
54
+ if (
55
+ text.includes("network error") ||
56
+ text.includes("connection timeout") ||
57
+ text.includes("timed out") ||
58
+ text.includes("error") ||
59
+ text.includes("failed") ||
60
+ text.includes("panic")
61
+ ) {
29
62
  return "attention";
30
63
  }
31
64
  return null;
@@ -50,7 +83,8 @@ class CodexProvider extends GenericProvider {
50
83
  };
51
84
 
52
85
  const previousPath = session.meta && session.meta.codexSessionPath;
53
- const previousState = session.displayState;
86
+ const overlay = activeOverlayPatch(session, summary.state);
87
+ const previousState = session.state;
54
88
  const previousCursor = session.meta && session.meta.codexTranscriptCursor;
55
89
  const previousLifecycle = session.meta && session.meta.codexLastLifecycle;
56
90
  const lifecycleAdvanced = Boolean(summary.lastTimestamp && summary.lastTimestamp !== previousCursor);
@@ -68,20 +102,32 @@ class CodexProvider extends GenericProvider {
68
102
  return {
69
103
  session: metaChanged
70
104
  ? {
71
- meta: nextMeta
105
+ meta: nextMeta
72
106
  }
73
107
  : null,
74
108
  state: summary.state,
75
- patch: summary.state ? { status: "running" } : null,
76
- eventName: lifecycleAdvanced && summary.lastLifecycle ? `codex_${summary.lastLifecycle}` : null,
77
- meta: lifecycleAdvanced
109
+ patch: summary.state
78
110
  ? {
79
- codexSessionPath: matched.path,
80
- codexSessionId: matched.sessionMeta && matched.sessionMeta.id,
81
- turnId: summary.lastTurnId || null,
82
- lastAgentMessage: summary.lastAgentMessage || null
111
+ status: "running",
112
+ ...(overlay
113
+ ? {
114
+ displayState: overlay.displayState,
115
+ displayZone: overlay.displayZone
116
+ }
117
+ : {})
83
118
  }
84
- : null
119
+ : null,
120
+ eventName: lifecycleAdvanced && summary.lastLifecycle ? `codex_${summary.lastLifecycle}` : null,
121
+ meta:
122
+ lifecycleAdvanced || overlay
123
+ ? {
124
+ codexSessionPath: matched.path,
125
+ codexSessionId: matched.sessionMeta && matched.sessionMeta.id,
126
+ turnId: summary.lastTurnId || null,
127
+ lastAgentMessage: summary.lastAgentMessage || null,
128
+ ...(overlay ? overlay.meta : {})
129
+ }
130
+ : null
85
131
  };
86
132
  }
87
133
  }
@@ -0,0 +1,74 @@
1
+ const fs = require("node:fs");
2
+ const os = require("node:os");
3
+ const path = require("node:path");
4
+ const test = require("node:test");
5
+ const assert = require("node:assert/strict");
6
+
7
+ const { CodexProvider } = require("./codex");
8
+
9
+ function writeTranscriptFile(entries) {
10
+ const dirPath = fs.mkdtempSync(path.join(os.tmpdir(), "codex-transcript-"));
11
+ const filePath = path.join(dirPath, "session.jsonl");
12
+ fs.writeFileSync(
13
+ filePath,
14
+ `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
15
+ "utf8"
16
+ );
17
+ return filePath;
18
+ }
19
+
20
+ test("reconcileSession keeps approval display overlay when transcript advances to working", () => {
21
+ const transcriptPath = writeTranscriptFile([
22
+ {
23
+ type: "session_meta",
24
+ payload: {
25
+ id: "codex-session-1",
26
+ cwd: process.cwd(),
27
+ timestamp: "2026-03-18T10:00:00.000Z"
28
+ }
29
+ },
30
+ {
31
+ timestamp: "2026-03-18T10:00:01.000Z",
32
+ type: "event_msg",
33
+ payload: {
34
+ type: "task_started",
35
+ turn_id: "turn-1"
36
+ }
37
+ }
38
+ ]);
39
+
40
+ const provider = new CodexProvider();
41
+ const session = provider.createSession({
42
+ cwd: process.cwd(),
43
+ title: "Codex",
44
+ command: "codex",
45
+ meta: {
46
+ codexSessionPath: transcriptPath,
47
+ codexTranscriptCursor: null,
48
+ codexLastLifecycle: null
49
+ }
50
+ });
51
+ session.status = "running";
52
+ session.state = "working";
53
+ session.displayState = "approval";
54
+ session.displayZone = "approval-zone";
55
+ session.updatedAt = "2026-03-18T10:00:02.000Z";
56
+
57
+ const result = provider.reconcileSession(session, { sessions: [session] });
58
+
59
+ assert.ok(result);
60
+ assert.equal(result.state, "working");
61
+ assert.equal(result.patch.displayState, "approval");
62
+ assert.equal(result.patch.displayZone, "approval-zone");
63
+ });
64
+
65
+ test("classifyOutput can raise attention for transcript-backed sessions", () => {
66
+ const provider = new CodexProvider();
67
+ const nextState = provider.classifyOutput("network error: connection timed out", {
68
+ meta: {
69
+ codexSessionPath: "/tmp/mock-codex.jsonl"
70
+ }
71
+ });
72
+
73
+ assert.equal(nextState, "attention");
74
+ });
@@ -1,9 +1,11 @@
1
+ const { displayZoneFor } = require("./state");
2
+
1
3
  const CONTRACT_VERSION = 1;
2
4
 
3
5
  function sessionLifecycle(session) {
4
6
  const status = session.status || "registered";
5
7
  const displayState = session.displayState || session.state || "idle";
6
- const displayZone = session.displayZone || "working-zone";
8
+ const displayZone = session.displayZone || displayZoneFor(displayState);
7
9
 
8
10
  return {
9
11
  status,
@@ -0,0 +1,27 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+
4
+ const { toPublicSession } = require("./session-contract");
5
+
6
+ test("toPublicSession derives displayZone from displayState when zone is missing", () => {
7
+ const session = toPublicSession({
8
+ sessionId: "sess_3",
9
+ provider: "codex",
10
+ title: "Codex",
11
+ command: "codex",
12
+ cwd: process.cwd(),
13
+ mode: "managed",
14
+ transport: "tmux",
15
+ state: "working",
16
+ displayState: "approval",
17
+ status: "running",
18
+ createdAt: "2026-03-18T00:00:00.000Z",
19
+ updatedAt: "2026-03-18T00:00:01.000Z",
20
+ meta: {}
21
+ });
22
+
23
+ assert.equal(session.state, "working");
24
+ assert.equal(session.displayState, "approval");
25
+ assert.equal(session.displayZone, "approval-zone");
26
+ assert.equal(session.lifecycle.displayZone, "approval-zone");
27
+ });
@@ -1,7 +1,7 @@
1
1
  const { EventEmitter } = require("node:events");
2
2
  const os = require("node:os");
3
3
  const crypto = require("node:crypto");
4
- const { LOG_LIMIT } = require("../config");
4
+ const { LOG_LIMIT, TERMINAL_REPLAY_LIMIT } = require("../config");
5
5
  const { displayZoneFor } = require("../state");
6
6
  const { toPublicSession, toSessionSummary } = require("../session-contract");
7
7
 
@@ -13,10 +13,14 @@ function cloneSession(session) {
13
13
  return toPublicSession(session);
14
14
  }
15
15
 
16
+ function applyDisplay(session, nextDisplayState, nextDisplayZone = null) {
17
+ session.displayState = nextDisplayState;
18
+ session.displayZone = nextDisplayZone || displayZoneFor(nextDisplayState);
19
+ }
20
+
16
21
  function applyState(session, nextState) {
17
22
  session.state = nextState;
18
- session.displayState = nextState;
19
- session.displayZone = displayZoneFor(nextState);
23
+ applyDisplay(session, nextState);
20
24
  }
21
25
 
22
26
  function createSessionStore() {
@@ -26,6 +30,7 @@ function createSessionStore() {
26
30
  function buildSession(payload) {
27
31
  const sessionId = payload.sessionId || `sess_${crypto.randomBytes(5).toString("hex")}`;
28
32
  const state = payload.state || "idle";
33
+ const displayState = payload.displayState || state;
29
34
  const createdAt = payload.createdAt || isoNow();
30
35
  const updatedAt = payload.updatedAt || createdAt;
31
36
 
@@ -38,8 +43,8 @@ function createSessionStore() {
38
43
  mode: payload.mode || "managed",
39
44
  transport: payload.transport || "pty",
40
45
  state,
41
- displayState: payload.displayState || state,
42
- displayZone: payload.displayZone || displayZoneFor(state),
46
+ displayState,
47
+ displayZone: payload.displayZone || displayZoneFor(displayState),
43
48
  status: payload.status || "registered",
44
49
  createdAt,
45
50
  updatedAt,
@@ -48,7 +53,8 @@ function createSessionStore() {
48
53
  host: payload.host || os.hostname(),
49
54
  meta: { ...(payload.meta || {}) },
50
55
  logs: [...(payload.logs || [])],
51
- events: [...(payload.events || [])]
56
+ events: [...(payload.events || [])],
57
+ terminalReplay: String(payload.terminalReplay || "")
52
58
  };
53
59
  }
54
60
 
@@ -86,10 +92,10 @@ function createSessionStore() {
86
92
  if (payload.state) {
87
93
  applyState(existing, payload.state);
88
94
  }
89
- if (payload.displayState && !payload.state) {
90
- existing.displayState = payload.displayState;
95
+ if (payload.displayState) {
96
+ applyDisplay(existing, payload.displayState, payload.displayZone);
91
97
  }
92
- if (payload.displayZone && !payload.state) {
98
+ if (payload.displayZone && !payload.displayState) {
93
99
  existing.displayZone = payload.displayZone;
94
100
  }
95
101
  if (payload.status) {
@@ -110,9 +116,9 @@ function createSessionStore() {
110
116
  session.updatedAt = isoNow();
111
117
  Object.assign(session, patch);
112
118
  if (patch.displayState) {
113
- session.displayState = patch.displayState;
119
+ applyDisplay(session, patch.displayState, patch.displayZone);
114
120
  }
115
- if (patch.displayZone) {
121
+ if (patch.displayZone && !patch.displayState) {
116
122
  session.displayZone = patch.displayZone;
117
123
  }
118
124
  emitUpdate(sessionId);
@@ -146,6 +152,10 @@ function createSessionStore() {
146
152
  const lines = String(chunk).replace(/\r/g, "").split("\n").filter(Boolean);
147
153
  session.logs.push(...lines);
148
154
  session.logs = session.logs.slice(-LOG_LIMIT);
155
+ session.terminalReplay = `${session.terminalReplay || ""}${String(chunk)}`;
156
+ if (session.terminalReplay.length > TERMINAL_REPLAY_LIMIT) {
157
+ session.terminalReplay = session.terminalReplay.slice(-TERMINAL_REPLAY_LIMIT);
158
+ }
149
159
  session.lastOutputAt = isoNow();
150
160
  session.updatedAt = session.lastOutputAt;
151
161
  // Terminal output does not change session state — skip session:update broadcast.
@@ -164,13 +174,11 @@ function createSessionStore() {
164
174
  Object.assign(session, patch);
165
175
  if (patch.state) {
166
176
  applyState(session, patch.state);
167
- } else {
168
- if (patch.displayState) {
169
- session.displayState = patch.displayState;
170
- }
171
- if (patch.displayZone) {
172
- session.displayZone = patch.displayZone;
173
- }
177
+ }
178
+ if (patch.displayState) {
179
+ applyDisplay(session, patch.displayState, patch.displayZone);
180
+ } else if (patch.displayZone) {
181
+ session.displayZone = patch.displayZone;
174
182
  }
175
183
  emitUpdate(sessionId);
176
184
  return session;
@@ -186,6 +194,11 @@ function createSessionStore() {
186
194
  return session ? toSessionSummary(session) : null;
187
195
  }
188
196
 
197
+ function getTerminalReplay(sessionId) {
198
+ const session = sessions.get(sessionId);
199
+ return session ? session.terminalReplay || "" : "";
200
+ }
201
+
189
202
  function listSessions() {
190
203
  return [...sessions.values()]
191
204
  .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
@@ -221,6 +234,7 @@ function createSessionStore() {
221
234
  markExit,
222
235
  getSession,
223
236
  getSessionSummary,
237
+ getTerminalReplay,
224
238
  listSessions,
225
239
  listSessionSummaries,
226
240
  removeSession
@@ -0,0 +1,68 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+
4
+ const { createSessionStore } = require("./session-store");
5
+
6
+ test("setSessionState keeps lifecycle state and applies display override", () => {
7
+ const store = createSessionStore();
8
+ store.upsertSession({
9
+ sessionId: "sess_1",
10
+ provider: "codex",
11
+ title: "Codex",
12
+ command: "codex",
13
+ cwd: process.cwd(),
14
+ state: "idle",
15
+ status: "running"
16
+ });
17
+
18
+ const next = store.setSessionState("sess_1", "working", {
19
+ status: "running",
20
+ displayState: "approval",
21
+ displayZone: "approval-zone"
22
+ });
23
+
24
+ assert.equal(next.state, "working");
25
+ assert.equal(next.displayState, "approval");
26
+ assert.equal(next.displayZone, "approval-zone");
27
+ assert.equal(next.status, "running");
28
+ });
29
+
30
+ test("setSessionState derives displayZone from displayState override when zone is omitted", () => {
31
+ const store = createSessionStore();
32
+ store.upsertSession({
33
+ sessionId: "sess_2",
34
+ provider: "codex",
35
+ title: "Codex",
36
+ command: "codex",
37
+ cwd: process.cwd(),
38
+ state: "idle",
39
+ status: "running"
40
+ });
41
+
42
+ const next = store.setSessionState("sess_2", "working", {
43
+ status: "running",
44
+ displayState: "attention"
45
+ });
46
+
47
+ assert.equal(next.state, "working");
48
+ assert.equal(next.displayState, "attention");
49
+ assert.equal(next.displayZone, "attention-zone");
50
+ });
51
+
52
+ test("appendOutput keeps a raw terminal replay buffer for fast reconnects", () => {
53
+ const store = createSessionStore();
54
+ store.upsertSession({
55
+ sessionId: "sess_3",
56
+ provider: "generic",
57
+ title: "Shell",
58
+ command: "bash",
59
+ cwd: process.cwd(),
60
+ state: "working",
61
+ status: "running"
62
+ });
63
+
64
+ store.appendOutput("sess_3", "line one\r\n");
65
+ store.appendOutput("sess_3", "line two\n");
66
+
67
+ assert.equal(store.getTerminalReplay("sess_3"), "line one\r\nline two\n");
68
+ });
@@ -0,0 +1,26 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const test = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+
6
+ test("root postinstall points at the live ensure-node-pty module", () => {
7
+ const rootDir = path.resolve(__dirname, "../../../../");
8
+ const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
9
+ const postinstall = packageJson.scripts && packageJson.scripts.postinstall;
10
+
11
+ assert.match(postinstall, /packages\/cli\/src\/runtime\/ensure-node-pty/);
12
+ });
13
+
14
+ test("service packages no longer depend on @agent-office/core", () => {
15
+ const rootDir = path.resolve(__dirname, "../../../../");
16
+ const apiPackageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "services/api/package.json"), "utf8"));
17
+ const relayPackageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "services/relay/package.json"), "utf8"));
18
+
19
+ assert.ok(!("@agent-office/core" in (apiPackageJson.dependencies || {})));
20
+ assert.ok(!("@agent-office/core" in (relayPackageJson.dependencies || {})));
21
+ });
22
+
23
+ test("legacy packages/core package has been removed", () => {
24
+ const rootDir = path.resolve(__dirname, "../../../../");
25
+ assert.equal(fs.existsSync(path.join(rootDir, "packages/core")), false);
26
+ });
@@ -1,7 +1,7 @@
1
1
  const crypto = require("node:crypto");
2
2
  const os = require("node:os");
3
3
  const pty = require("node-pty");
4
- const { getProvider } = require("../core");
4
+ const { displayZoneFor, getProvider } = require("../core");
5
5
  const {
6
6
  AGENTOFFICE_TMUX_PREFIX,
7
7
  attachClient,
@@ -350,20 +350,21 @@ function createPtyManager({ store }) {
350
350
  }
351
351
 
352
352
  const provider = getProvider(session.provider);
353
- const reconcileResult = provider.reconcileSession(session, { sessions: currentSessions });
354
- // Only run screen-based classifyOutput when the session has no transcript-driven state.
355
- // For providers like Codex that use a transcript (codexSessionPath set), classifyOutput
356
- // on the raw terminal text is unreliable and fights the transcript state machine, causing
357
- // the session to flicker between "attention" and "idle" every polling tick.
358
- const hasTranscriptState = Boolean(session.meta && session.meta.codexSessionPath);
359
- if (!hasTranscriptState && (!reconcileResult || !reconcileResult.state)) {
360
- const screen = await capturePane(runtime.tmuxSession);
361
- const nextState = runtime.provider.classifyOutput(screen, store.getSession(session.sessionId));
362
- if (nextState && nextState !== session.displayState) {
363
- store.setSessionState(session.sessionId, nextState, { status: "running" });
364
- }
353
+ const screen = await capturePane(runtime.tmuxSession);
354
+ const latestSession = store.getSession(session.sessionId) || session;
355
+ const overlayState = runtime.provider.classifyOutput(screen, latestSession);
356
+
357
+ if (overlayState && overlayState !== latestSession.displayState) {
358
+ store.setSessionState(session.sessionId, latestSession.state || "working", {
359
+ status: "running",
360
+ displayState: overlayState,
361
+ displayZone: displayZoneFor(overlayState)
362
+ });
365
363
  }
366
- applyProviderReconcile(session, reconcileResult);
364
+
365
+ const reconciledSession = store.getSession(session.sessionId) || session;
366
+ const reconcileResult = provider.reconcileSession(reconciledSession, { sessions: currentSessions });
367
+ applyProviderReconcile(reconciledSession, reconcileResult);
367
368
  continue;
368
369
  }
369
370
 
@@ -502,18 +503,25 @@ function createPtyManager({ store }) {
502
503
 
503
504
  let attachedClient = null;
504
505
  let tmuxStreamStarted = false;
506
+ let pendingCols = 120;
507
+ let pendingRows = 32;
505
508
 
506
509
  async function startTmuxStream(cols, rows) {
507
510
  if (tmuxStreamStarted || !entry || entry.transport !== "tmux") {
508
511
  return;
509
512
  }
510
513
  tmuxStreamStarted = true;
514
+ pendingCols = cols;
515
+ pendingRows = rows;
511
516
  try {
512
517
  const snapshot = await capturePane(entry.tmuxSession);
513
518
  if (snapshot && ws.readyState === 1) {
514
519
  ws.send(JSON.stringify({ type: "terminal:data", data: `${snapshot}\r\n` }));
515
520
  }
516
521
  attachedClient = attachClient(entry.tmuxSession, { cwd: entry.cwd, cols, rows });
522
+ if (pendingCols !== cols || pendingRows !== rows) {
523
+ attachedClient.resize(pendingCols, pendingRows);
524
+ }
517
525
  attachedClient.onData((chunk) => {
518
526
  if (ws.readyState === 1) {
519
527
  ws.send(JSON.stringify({ type: "terminal:data", data: chunk }));
@@ -531,7 +539,12 @@ function createPtyManager({ store }) {
531
539
 
532
540
  // For non-tmux transports that had immediate setup, keep original behavior
533
541
  if (entry && entry.transport === "pty") {
534
- // PTY sessions stream via broadcastTerminal, no per-client attach needed
542
+ const replay = store.getTerminalReplay(sessionId);
543
+ if (replay) {
544
+ ws.send(JSON.stringify({ type: "terminal:data", data: replay }));
545
+ }
546
+ } else if (entry && entry.transport === "tmux") {
547
+ void startTmuxStream(120, 32);
535
548
  }
536
549
 
537
550
  ws.on("message", async (raw) => {
@@ -552,6 +565,8 @@ function createPtyManager({ store }) {
552
565
  if (message.type === "resize") {
553
566
  const cols = Number(message.cols || 120);
554
567
  const rows = Number(message.rows || 32);
568
+ pendingCols = cols;
569
+ pendingRows = rows;
555
570
  if (runtime.transport === "pty") {
556
571
  runtime.pty.resize(cols, rows);
557
572
  } else if (!tmuxStreamStarted) {
package/src/server.js CHANGED
@@ -1,4 +1,7 @@
1
+ const fs = require("node:fs");
1
2
  const http = require("node:http");
3
+ const os = require("node:os");
4
+ const path = require("node:path");
2
5
  const express = require("express");
3
6
  const { WebSocketServer } = require("ws");
4
7
  const auth = require("./auth");
@@ -112,6 +115,20 @@ function createAppServer({ host, port, store, ptyManager }) {
112
115
  res.json({ ok: true });
113
116
  });
114
117
 
118
+ // Returns home dir + subdirectories of a given path (for working directory picker)
119
+ app.get("/api/dirs", (req, res) => {
120
+ const home = os.homedir();
121
+ const target = String(req.query.path || home);
122
+ let entries = [];
123
+ try {
124
+ entries = fs.readdirSync(target, { withFileTypes: true })
125
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
126
+ .map((e) => path.join(target, e.name))
127
+ .slice(0, 50);
128
+ } catch { /* unreadable dir */ }
129
+ res.json({ home, path: target, dirs: entries });
130
+ });
131
+
115
132
  app.get("/api/sessions", (_req, res) => {
116
133
  res.json({ sessions: store.listSessionSummaries() });
117
134
  });