agent-office-cli 0.0.1 → 0.1.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/package.json +1 -1
- package/src/core/providers/codex.js +57 -11
- package/src/core/providers/codex.test.js +74 -0
- package/src/core/session-contract.js +3 -1
- package/src/core/session-contract.test.js +27 -0
- package/src/core/store/session-store.js +19 -16
- package/src/core/store/session-store.test.js +50 -0
- package/src/runtime/postinstall-path.test.js +26 -0
- package/src/runtime/pty-manager.js +16 -4
- package/src/server.js +17 -4
- package/src/web/index.js +0 -7
- package/src/web/public/app.js +0 -713
- package/src/web/public/dashboard.html +0 -245
- package/src/web/public/index.html +0 -84
- package/src/web/public/login.css +0 -833
- package/src/web/public/login.html +0 -28
- package/src/web/public/office.html +0 -22
- package/src/web/public/register.html +0 -316
- package/src/web/public/styles.css +0 -988
package/package.json
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
-
|
|
105
|
+
meta: nextMeta
|
|
72
106
|
}
|
|
73
107
|
: null,
|
|
74
108
|
state: summary.state,
|
|
75
|
-
patch: summary.state
|
|
76
|
-
eventName: lifecycleAdvanced && summary.lastLifecycle ? `codex_${summary.lastLifecycle}` : null,
|
|
77
|
-
meta: lifecycleAdvanced
|
|
109
|
+
patch: summary.state
|
|
78
110
|
? {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 ||
|
|
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
|
+
});
|
|
@@ -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
|
|
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
|
|
42
|
-
displayZone: payload.displayZone || displayZoneFor(
|
|
46
|
+
displayState,
|
|
47
|
+
displayZone: payload.displayZone || displayZoneFor(displayState),
|
|
43
48
|
status: payload.status || "registered",
|
|
44
49
|
createdAt,
|
|
45
50
|
updatedAt,
|
|
@@ -86,10 +91,10 @@ function createSessionStore() {
|
|
|
86
91
|
if (payload.state) {
|
|
87
92
|
applyState(existing, payload.state);
|
|
88
93
|
}
|
|
89
|
-
if (payload.displayState
|
|
90
|
-
existing.displayState
|
|
94
|
+
if (payload.displayState) {
|
|
95
|
+
applyDisplay(existing, payload.displayState, payload.displayZone);
|
|
91
96
|
}
|
|
92
|
-
if (payload.displayZone && !payload.
|
|
97
|
+
if (payload.displayZone && !payload.displayState) {
|
|
93
98
|
existing.displayZone = payload.displayZone;
|
|
94
99
|
}
|
|
95
100
|
if (payload.status) {
|
|
@@ -110,9 +115,9 @@ function createSessionStore() {
|
|
|
110
115
|
session.updatedAt = isoNow();
|
|
111
116
|
Object.assign(session, patch);
|
|
112
117
|
if (patch.displayState) {
|
|
113
|
-
session.displayState
|
|
118
|
+
applyDisplay(session, patch.displayState, patch.displayZone);
|
|
114
119
|
}
|
|
115
|
-
if (patch.displayZone) {
|
|
120
|
+
if (patch.displayZone && !patch.displayState) {
|
|
116
121
|
session.displayZone = patch.displayZone;
|
|
117
122
|
}
|
|
118
123
|
emitUpdate(sessionId);
|
|
@@ -164,13 +169,11 @@ function createSessionStore() {
|
|
|
164
169
|
Object.assign(session, patch);
|
|
165
170
|
if (patch.state) {
|
|
166
171
|
applyState(session, patch.state);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
session.displayZone = patch.displayZone;
|
|
173
|
-
}
|
|
172
|
+
}
|
|
173
|
+
if (patch.displayState) {
|
|
174
|
+
applyDisplay(session, patch.displayState, patch.displayZone);
|
|
175
|
+
} else if (patch.displayZone) {
|
|
176
|
+
session.displayZone = patch.displayZone;
|
|
174
177
|
}
|
|
175
178
|
emitUpdate(sessionId);
|
|
176
179
|
return session;
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
});
|
|
@@ -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,
|
|
@@ -349,11 +349,23 @@ function createPtyManager({ store }) {
|
|
|
349
349
|
}
|
|
350
350
|
}
|
|
351
351
|
|
|
352
|
+
const provider = getProvider(session.provider);
|
|
352
353
|
const screen = await capturePane(runtime.tmuxSession);
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
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
|
+
});
|
|
356
363
|
}
|
|
364
|
+
|
|
365
|
+
const reconciledSession = store.getSession(session.sessionId) || session;
|
|
366
|
+
const reconcileResult = provider.reconcileSession(reconciledSession, { sessions: currentSessions });
|
|
367
|
+
applyProviderReconcile(reconciledSession, reconcileResult);
|
|
368
|
+
continue;
|
|
357
369
|
}
|
|
358
370
|
|
|
359
371
|
const provider = getProvider(session.provider);
|
package/src/server.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
1
2
|
const http = require("node:http");
|
|
3
|
+
const os = require("node:os");
|
|
2
4
|
const path = require("node:path");
|
|
3
5
|
const express = require("express");
|
|
4
6
|
const { WebSocketServer } = require("ws");
|
|
5
|
-
const { STATIC_DIR } = require("./web");
|
|
6
7
|
const auth = require("./auth");
|
|
7
8
|
|
|
8
9
|
function createAppServer({ host, port, store, ptyManager }) {
|
|
@@ -37,7 +38,7 @@ function createAppServer({ host, port, store, ptyManager }) {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
function sendOfficeShell(res) {
|
|
40
|
-
res.
|
|
41
|
+
res.status(404).json({ error: "no_web_ui" });
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
app.use((req, res, next) => {
|
|
@@ -110,12 +111,24 @@ function createAppServer({ host, port, store, ptyManager }) {
|
|
|
110
111
|
sendOfficeShell(res);
|
|
111
112
|
});
|
|
112
113
|
|
|
113
|
-
app.use(express.static(STATIC_DIR, { index: false }));
|
|
114
|
-
|
|
115
114
|
app.get("/api/health", (_req, res) => {
|
|
116
115
|
res.json({ ok: true });
|
|
117
116
|
});
|
|
118
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
|
+
|
|
119
132
|
app.get("/api/sessions", (_req, res) => {
|
|
120
133
|
res.json({ sessions: store.listSessionSummaries() });
|
|
121
134
|
});
|