agent-office-cli 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/package.json +28 -0
- package/src/auth.js +178 -0
- package/src/core/config.js +13 -0
- package/src/core/index.js +36 -0
- package/src/core/providers/base.js +38 -0
- package/src/core/providers/claude-transcript.js +126 -0
- package/src/core/providers/claude.js +199 -0
- package/src/core/providers/codex-transcript.js +210 -0
- package/src/core/providers/codex.js +91 -0
- package/src/core/providers/generic.js +40 -0
- package/src/core/providers/index.js +17 -0
- package/src/core/session-contract.js +98 -0
- package/src/core/state.js +23 -0
- package/src/core/store/session-store.js +232 -0
- package/src/index.js +348 -0
- package/src/runtime/cli-helpers.js +90 -0
- package/src/runtime/ensure-node-pty.js +49 -0
- package/src/runtime/index.js +54 -0
- package/src/runtime/pty-manager.js +598 -0
- package/src/runtime/session-registry.js +74 -0
- package/src/runtime/tmux.js +152 -0
- package/src/server.js +208 -0
- package/src/tunnel.js +224 -0
- package/src/web/index.js +7 -0
- package/src/web/public/app.js +713 -0
- package/src/web/public/dashboard.html +245 -0
- package/src/web/public/index.html +84 -0
- package/src/web/public/login.css +833 -0
- package/src/web/public/login.html +28 -0
- package/src/web/public/office.html +22 -0
- package/src/web/public/register.html +316 -0
- package/src/web/public/styles.css +988 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const os = require("node:os");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
|
|
5
|
+
const CODEX_SESSIONS_ROOT = path.join(os.homedir(), ".codex", "sessions");
|
|
6
|
+
|
|
7
|
+
function readJsonLinesHead(filePath, maxLines = 8) {
|
|
8
|
+
try {
|
|
9
|
+
const fd = fs.openSync(filePath, "r");
|
|
10
|
+
const buffer = Buffer.alloc(1048576);
|
|
11
|
+
const length = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
12
|
+
fs.closeSync(fd);
|
|
13
|
+
const text = buffer.toString("utf8", 0, length);
|
|
14
|
+
return text
|
|
15
|
+
.split("\n")
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.slice(0, maxLines)
|
|
18
|
+
.map((line) => {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(line);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readTranscriptTail(filePath, bytes = 1048576) {
|
|
32
|
+
try {
|
|
33
|
+
const stats = fs.statSync(filePath);
|
|
34
|
+
const start = Math.max(0, stats.size - bytes);
|
|
35
|
+
const length = stats.size - start;
|
|
36
|
+
const fd = fs.openSync(filePath, "r");
|
|
37
|
+
const buffer = Buffer.alloc(length);
|
|
38
|
+
fs.readSync(fd, buffer, 0, length, start);
|
|
39
|
+
fs.closeSync(fd);
|
|
40
|
+
return buffer.toString("utf8");
|
|
41
|
+
} catch {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractRecentEntries(filePath, count = 1000) {
|
|
47
|
+
const text = readTranscriptTail(filePath);
|
|
48
|
+
if (!text) {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return text
|
|
53
|
+
.split("\n")
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.slice(-count)
|
|
56
|
+
.map((line) => {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(line);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function listSessionFiles(root = CODEX_SESSIONS_ROOT) {
|
|
67
|
+
const files = [];
|
|
68
|
+
|
|
69
|
+
function walk(dirPath) {
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
73
|
+
} catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
const nextPath = path.join(dirPath, entry.name);
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
walk(nextPath);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const stats = fs.statSync(nextPath);
|
|
88
|
+
files.push({ path: nextPath, mtimeMs: stats.mtimeMs });
|
|
89
|
+
} catch {
|
|
90
|
+
// Ignore disappearing files from concurrent Codex writes.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
walk(root);
|
|
96
|
+
files.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
|
97
|
+
return files;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readSessionMeta(filePath) {
|
|
101
|
+
const entries = readJsonLinesHead(filePath);
|
|
102
|
+
const sessionMeta = entries.find((entry) => entry.type === "session_meta");
|
|
103
|
+
return sessionMeta ? sessionMeta.payload || null : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function summarizeCodexSession(filePath) {
|
|
107
|
+
const entries = extractRecentEntries(filePath);
|
|
108
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
109
|
+
const entry = entries[index];
|
|
110
|
+
const payload = entry.payload || {};
|
|
111
|
+
|
|
112
|
+
if (entry.type === "event_msg") {
|
|
113
|
+
if (payload.type === "task_started") {
|
|
114
|
+
return {
|
|
115
|
+
state: "working",
|
|
116
|
+
lastLifecycle: "task_started",
|
|
117
|
+
lastTimestamp: entry.timestamp || null,
|
|
118
|
+
lastTurnId: payload.turn_id || null,
|
|
119
|
+
lastAgentMessage: null
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (payload.type === "task_complete") {
|
|
124
|
+
return {
|
|
125
|
+
state: "idle",
|
|
126
|
+
lastLifecycle: "task_complete",
|
|
127
|
+
lastTimestamp: entry.timestamp || null,
|
|
128
|
+
lastTurnId: payload.turn_id || null,
|
|
129
|
+
lastAgentMessage: payload.last_agent_message || null
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (payload.type === "turn_aborted") {
|
|
134
|
+
return {
|
|
135
|
+
state: "idle",
|
|
136
|
+
lastLifecycle: "turn_aborted",
|
|
137
|
+
lastTimestamp: entry.timestamp || null,
|
|
138
|
+
lastTurnId: payload.turn_id || null,
|
|
139
|
+
lastAgentMessage: null
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
state: null,
|
|
147
|
+
lastLifecycle: null,
|
|
148
|
+
lastTimestamp: null,
|
|
149
|
+
lastTurnId: null,
|
|
150
|
+
lastAgentMessage: null
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function findManagedCodexSessionFile(session, allSessions = []) {
|
|
155
|
+
const linkedPath = session.meta && session.meta.codexSessionPath;
|
|
156
|
+
if (linkedPath && fs.existsSync(linkedPath)) {
|
|
157
|
+
return {
|
|
158
|
+
path: linkedPath,
|
|
159
|
+
sessionMeta: readSessionMeta(linkedPath)
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const assignedPaths = new Set(
|
|
164
|
+
allSessions
|
|
165
|
+
.filter((entry) => entry.sessionId !== session.sessionId)
|
|
166
|
+
.map((entry) => entry.meta && entry.meta.codexSessionPath)
|
|
167
|
+
.filter(Boolean)
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const sessionStartMs = Date.parse((session.meta && session.meta.managedStartedAt) || session.createdAt || session.updatedAt || 0);
|
|
171
|
+
const candidates = listSessionFiles()
|
|
172
|
+
.filter((entry) => !assignedPaths.has(entry.path))
|
|
173
|
+
.slice(0, 80)
|
|
174
|
+
.map((entry) => ({
|
|
175
|
+
...entry,
|
|
176
|
+
sessionMeta: readSessionMeta(entry.path)
|
|
177
|
+
}))
|
|
178
|
+
.filter((entry) => entry.sessionMeta && entry.sessionMeta.cwd === session.cwd)
|
|
179
|
+
.map((entry) => {
|
|
180
|
+
const metaTimestampMs = Date.parse(entry.sessionMeta.timestamp || 0);
|
|
181
|
+
const effectiveTimeMs = Number.isFinite(metaTimestampMs) && metaTimestampMs > 0 ? metaTimestampMs : entry.mtimeMs;
|
|
182
|
+
const deltaMs = Math.abs(effectiveTimeMs - sessionStartMs);
|
|
183
|
+
const score =
|
|
184
|
+
(entry.mtimeMs >= sessionStartMs - 15000 ? 60 : 0) +
|
|
185
|
+
Math.max(0, 40 - Math.floor(deltaMs / 30000));
|
|
186
|
+
return {
|
|
187
|
+
...entry,
|
|
188
|
+
score,
|
|
189
|
+
deltaMs,
|
|
190
|
+
effectiveTimeMs
|
|
191
|
+
};
|
|
192
|
+
})
|
|
193
|
+
.filter((entry) => entry.score > 0)
|
|
194
|
+
.sort((left, right) => right.score - left.score || right.effectiveTimeMs - left.effectiveTimeMs);
|
|
195
|
+
|
|
196
|
+
if (candidates.length === 0) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
path: candidates[0].path,
|
|
202
|
+
sessionMeta: candidates[0].sessionMeta
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
CODEX_SESSIONS_ROOT,
|
|
208
|
+
findManagedCodexSessionFile,
|
|
209
|
+
summarizeCodexSession
|
|
210
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const { GenericProvider } = require("./generic");
|
|
2
|
+
const { findManagedCodexSessionFile, summarizeCodexSession } = require("./codex-transcript");
|
|
3
|
+
|
|
4
|
+
class CodexProvider extends GenericProvider {
|
|
5
|
+
constructor() {
|
|
6
|
+
super("codex");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
createSession(payload) {
|
|
10
|
+
return {
|
|
11
|
+
...super.createSession(payload),
|
|
12
|
+
meta: {
|
|
13
|
+
managedStartedAt: new Date().toISOString(),
|
|
14
|
+
codexSessionPath: null,
|
|
15
|
+
codexSessionId: null,
|
|
16
|
+
codexTranscriptCursor: null,
|
|
17
|
+
codexLastLifecycle: null,
|
|
18
|
+
...(payload.meta || {})
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
classifyOutput(chunk) {
|
|
24
|
+
const text = String(chunk).toLowerCase();
|
|
25
|
+
if (text.includes("approval") || text.includes("press enter") || text.includes("confirm")) {
|
|
26
|
+
return "approval";
|
|
27
|
+
}
|
|
28
|
+
if (text.includes("error") || text.includes("failed") || text.includes("panic")) {
|
|
29
|
+
return "attention";
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
reconcileSession(session, context = {}) {
|
|
35
|
+
if (session.status === "exited") {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const matched = findManagedCodexSessionFile(session, context.sessions || []);
|
|
40
|
+
if (!matched || !matched.path) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const summary = summarizeCodexSession(matched.path);
|
|
45
|
+
const nextMeta = {
|
|
46
|
+
codexSessionPath: matched.path,
|
|
47
|
+
codexSessionId: matched.sessionMeta && matched.sessionMeta.id,
|
|
48
|
+
codexTranscriptCursor: summary.lastTimestamp || null,
|
|
49
|
+
codexLastLifecycle: summary.lastLifecycle || null
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const previousPath = session.meta && session.meta.codexSessionPath;
|
|
53
|
+
const previousState = session.displayState;
|
|
54
|
+
const previousCursor = session.meta && session.meta.codexTranscriptCursor;
|
|
55
|
+
const previousLifecycle = session.meta && session.meta.codexLastLifecycle;
|
|
56
|
+
const lifecycleAdvanced = Boolean(summary.lastTimestamp && summary.lastTimestamp !== previousCursor);
|
|
57
|
+
const metaChanged =
|
|
58
|
+
previousPath !== nextMeta.codexSessionPath ||
|
|
59
|
+
(session.meta && session.meta.codexSessionId) !== nextMeta.codexSessionId ||
|
|
60
|
+
previousCursor !== nextMeta.codexTranscriptCursor ||
|
|
61
|
+
previousLifecycle !== nextMeta.codexLastLifecycle;
|
|
62
|
+
const stateChanged = Boolean(summary.state && summary.state !== previousState);
|
|
63
|
+
|
|
64
|
+
if (!metaChanged && !stateChanged && !lifecycleAdvanced) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
session: metaChanged
|
|
70
|
+
? {
|
|
71
|
+
meta: nextMeta
|
|
72
|
+
}
|
|
73
|
+
: null,
|
|
74
|
+
state: summary.state,
|
|
75
|
+
patch: summary.state ? { status: "running" } : null,
|
|
76
|
+
eventName: lifecycleAdvanced && summary.lastLifecycle ? `codex_${summary.lastLifecycle}` : null,
|
|
77
|
+
meta: lifecycleAdvanced
|
|
78
|
+
? {
|
|
79
|
+
codexSessionPath: matched.path,
|
|
80
|
+
codexSessionId: matched.sessionMeta && matched.sessionMeta.id,
|
|
81
|
+
turnId: summary.lastTurnId || null,
|
|
82
|
+
lastAgentMessage: summary.lastAgentMessage || null
|
|
83
|
+
}
|
|
84
|
+
: null
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
CodexProvider
|
|
91
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const { BaseProvider } = require("./base");
|
|
2
|
+
|
|
3
|
+
const ATTENTION_PATTERNS = [
|
|
4
|
+
"traceback",
|
|
5
|
+
"fatal",
|
|
6
|
+
"command not found",
|
|
7
|
+
"permission denied",
|
|
8
|
+
"access denied",
|
|
9
|
+
"exception"
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const WAITING_PATTERNS = [
|
|
13
|
+
"approve",
|
|
14
|
+
"approval",
|
|
15
|
+
"waiting for input",
|
|
16
|
+
"confirm",
|
|
17
|
+
"press enter",
|
|
18
|
+
"continue?"
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
class GenericProvider extends BaseProvider {
|
|
22
|
+
constructor(name = "generic") {
|
|
23
|
+
super(name);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
classifyOutput(chunk) {
|
|
27
|
+
const text = String(chunk).toLowerCase();
|
|
28
|
+
if (WAITING_PATTERNS.some((pattern) => text.includes(pattern))) {
|
|
29
|
+
return "approval";
|
|
30
|
+
}
|
|
31
|
+
if (ATTENTION_PATTERNS.some((pattern) => text.includes(pattern))) {
|
|
32
|
+
return "attention";
|
|
33
|
+
}
|
|
34
|
+
return "working";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
GenericProvider
|
|
40
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const { ClaudeProvider } = require("./claude");
|
|
2
|
+
const { CodexProvider } = require("./codex");
|
|
3
|
+
const { GenericProvider } = require("./generic");
|
|
4
|
+
|
|
5
|
+
const providers = {
|
|
6
|
+
claude: new ClaudeProvider(),
|
|
7
|
+
codex: new CodexProvider(),
|
|
8
|
+
generic: new GenericProvider("generic")
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function getProvider(name) {
|
|
12
|
+
return providers[name] || new GenericProvider(name || "generic");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
getProvider
|
|
17
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const CONTRACT_VERSION = 1;
|
|
2
|
+
|
|
3
|
+
function sessionLifecycle(session) {
|
|
4
|
+
const status = session.status || "registered";
|
|
5
|
+
const displayState = session.displayState || session.state || "idle";
|
|
6
|
+
const displayZone = session.displayZone || "working-zone";
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
status,
|
|
10
|
+
state: session.state || displayState,
|
|
11
|
+
displayState,
|
|
12
|
+
displayZone,
|
|
13
|
+
visibleInOffice: !["completed", "exited"].includes(status)
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function toPublicSession(session) {
|
|
18
|
+
if (!session) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const lifecycle = sessionLifecycle(session);
|
|
23
|
+
const meta = { ...(session.meta || {}) };
|
|
24
|
+
const runtime = {
|
|
25
|
+
pid: session.pid || null,
|
|
26
|
+
host: session.host || null,
|
|
27
|
+
transport: session.transport,
|
|
28
|
+
hasTerminal: ["pty", "tmux"].includes(session.transport),
|
|
29
|
+
tmuxSession: meta.tmuxSession || null,
|
|
30
|
+
attachCommand: meta.localAttachCommand || null
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
contractVersion: CONTRACT_VERSION,
|
|
35
|
+
sessionId: session.sessionId,
|
|
36
|
+
provider: session.provider,
|
|
37
|
+
title: session.title,
|
|
38
|
+
command: session.command,
|
|
39
|
+
cwd: session.cwd,
|
|
40
|
+
mode: session.mode,
|
|
41
|
+
transport: session.transport,
|
|
42
|
+
state: lifecycle.state,
|
|
43
|
+
displayState: lifecycle.displayState,
|
|
44
|
+
displayZone: lifecycle.displayZone,
|
|
45
|
+
status: lifecycle.status,
|
|
46
|
+
visibleInOffice: lifecycle.visibleInOffice,
|
|
47
|
+
lifecycle,
|
|
48
|
+
timestamps: {
|
|
49
|
+
createdAt: session.createdAt,
|
|
50
|
+
updatedAt: session.updatedAt,
|
|
51
|
+
lastOutputAt: session.lastOutputAt || null
|
|
52
|
+
},
|
|
53
|
+
runtime,
|
|
54
|
+
createdAt: session.createdAt,
|
|
55
|
+
updatedAt: session.updatedAt,
|
|
56
|
+
lastOutputAt: session.lastOutputAt || null,
|
|
57
|
+
pid: runtime.pid,
|
|
58
|
+
host: runtime.host,
|
|
59
|
+
meta,
|
|
60
|
+
logs: [...(session.logs || [])],
|
|
61
|
+
events: [...(session.events || [])]
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toSessionSummary(session) {
|
|
66
|
+
const publicSession = toPublicSession(session);
|
|
67
|
+
if (!publicSession) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
contractVersion: CONTRACT_VERSION,
|
|
73
|
+
sessionId: publicSession.sessionId,
|
|
74
|
+
provider: publicSession.provider,
|
|
75
|
+
title: publicSession.title,
|
|
76
|
+
mode: publicSession.mode,
|
|
77
|
+
transport: publicSession.transport,
|
|
78
|
+
state: publicSession.state,
|
|
79
|
+
displayState: publicSession.displayState,
|
|
80
|
+
displayZone: publicSession.displayZone,
|
|
81
|
+
status: publicSession.status,
|
|
82
|
+
visibleInOffice: publicSession.visibleInOffice,
|
|
83
|
+
lifecycle: publicSession.lifecycle,
|
|
84
|
+
timestamps: publicSession.timestamps,
|
|
85
|
+
runtime: {
|
|
86
|
+
host: publicSession.runtime.host,
|
|
87
|
+
hasTerminal: publicSession.runtime.hasTerminal
|
|
88
|
+
},
|
|
89
|
+
createdAt: publicSession.createdAt,
|
|
90
|
+
updatedAt: publicSession.updatedAt
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
CONTRACT_VERSION,
|
|
96
|
+
toPublicSession,
|
|
97
|
+
toSessionSummary
|
|
98
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const DISPLAY_STATES = {
|
|
2
|
+
IDLE: "idle",
|
|
3
|
+
WORKING: "working",
|
|
4
|
+
APPROVAL: "approval",
|
|
5
|
+
ATTENTION: "attention"
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const DISPLAY_ZONES = {
|
|
9
|
+
idle: "idle-zone",
|
|
10
|
+
working: "working-zone",
|
|
11
|
+
approval: "approval-zone",
|
|
12
|
+
attention: "attention-zone"
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function displayZoneFor(state) {
|
|
16
|
+
return DISPLAY_ZONES[state] || DISPLAY_ZONES.working;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = {
|
|
20
|
+
DISPLAY_STATES,
|
|
21
|
+
DISPLAY_ZONES,
|
|
22
|
+
displayZoneFor
|
|
23
|
+
};
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
const { EventEmitter } = require("node:events");
|
|
2
|
+
const os = require("node:os");
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const { LOG_LIMIT } = require("../config");
|
|
5
|
+
const { displayZoneFor } = require("../state");
|
|
6
|
+
const { toPublicSession, toSessionSummary } = require("../session-contract");
|
|
7
|
+
|
|
8
|
+
function isoNow() {
|
|
9
|
+
return new Date().toISOString();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function cloneSession(session) {
|
|
13
|
+
return toPublicSession(session);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function applyState(session, nextState) {
|
|
17
|
+
session.state = nextState;
|
|
18
|
+
session.displayState = nextState;
|
|
19
|
+
session.displayZone = displayZoneFor(nextState);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createSessionStore() {
|
|
23
|
+
const sessions = new Map();
|
|
24
|
+
const emitter = new EventEmitter();
|
|
25
|
+
|
|
26
|
+
function buildSession(payload) {
|
|
27
|
+
const sessionId = payload.sessionId || `sess_${crypto.randomBytes(5).toString("hex")}`;
|
|
28
|
+
const state = payload.state || "idle";
|
|
29
|
+
const createdAt = payload.createdAt || isoNow();
|
|
30
|
+
const updatedAt = payload.updatedAt || createdAt;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
sessionId,
|
|
34
|
+
provider: payload.provider || "generic",
|
|
35
|
+
title: payload.title || `${payload.provider || "worker"} session`,
|
|
36
|
+
command: payload.command || "",
|
|
37
|
+
cwd: payload.cwd || process.cwd(),
|
|
38
|
+
mode: payload.mode || "managed",
|
|
39
|
+
transport: payload.transport || "pty",
|
|
40
|
+
state,
|
|
41
|
+
displayState: payload.displayState || state,
|
|
42
|
+
displayZone: payload.displayZone || displayZoneFor(state),
|
|
43
|
+
status: payload.status || "registered",
|
|
44
|
+
createdAt,
|
|
45
|
+
updatedAt,
|
|
46
|
+
lastOutputAt: payload.lastOutputAt || null,
|
|
47
|
+
pid: payload.pid || null,
|
|
48
|
+
host: payload.host || os.hostname(),
|
|
49
|
+
meta: { ...(payload.meta || {}) },
|
|
50
|
+
logs: [...(payload.logs || [])],
|
|
51
|
+
events: [...(payload.events || [])]
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function emitUpdate(sessionId) {
|
|
56
|
+
emitter.emit("session:update", getSession(sessionId));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function upsertSession(payload) {
|
|
60
|
+
const sessionId = payload.sessionId || `sess_${crypto.randomBytes(5).toString("hex")}`;
|
|
61
|
+
const existing = sessions.get(sessionId);
|
|
62
|
+
|
|
63
|
+
if (!existing) {
|
|
64
|
+
const created = buildSession({ ...payload, sessionId });
|
|
65
|
+
sessions.set(sessionId, created);
|
|
66
|
+
emitUpdate(sessionId);
|
|
67
|
+
return created;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
Object.assign(existing, {
|
|
71
|
+
provider: payload.provider || existing.provider,
|
|
72
|
+
title: payload.title || existing.title,
|
|
73
|
+
command: payload.command || existing.command,
|
|
74
|
+
cwd: payload.cwd || existing.cwd,
|
|
75
|
+
mode: payload.mode || existing.mode,
|
|
76
|
+
transport: payload.transport || existing.transport,
|
|
77
|
+
pid: payload.pid === undefined ? existing.pid : payload.pid,
|
|
78
|
+
host: payload.host || existing.host,
|
|
79
|
+
meta: payload.meta ? { ...existing.meta, ...payload.meta } : existing.meta,
|
|
80
|
+
updatedAt: payload.updatedAt || isoNow()
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (payload.lastOutputAt !== undefined) {
|
|
84
|
+
existing.lastOutputAt = payload.lastOutputAt;
|
|
85
|
+
}
|
|
86
|
+
if (payload.state) {
|
|
87
|
+
applyState(existing, payload.state);
|
|
88
|
+
}
|
|
89
|
+
if (payload.displayState && !payload.state) {
|
|
90
|
+
existing.displayState = payload.displayState;
|
|
91
|
+
}
|
|
92
|
+
if (payload.displayZone && !payload.state) {
|
|
93
|
+
existing.displayZone = payload.displayZone;
|
|
94
|
+
}
|
|
95
|
+
if (payload.status) {
|
|
96
|
+
existing.status = payload.status;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
emitUpdate(sessionId);
|
|
100
|
+
return existing;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function setSessionState(sessionId, nextState, patch = {}) {
|
|
104
|
+
const session = sessions.get(sessionId);
|
|
105
|
+
if (!session) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
applyState(session, nextState);
|
|
110
|
+
session.updatedAt = isoNow();
|
|
111
|
+
Object.assign(session, patch);
|
|
112
|
+
if (patch.displayState) {
|
|
113
|
+
session.displayState = patch.displayState;
|
|
114
|
+
}
|
|
115
|
+
if (patch.displayZone) {
|
|
116
|
+
session.displayZone = patch.displayZone;
|
|
117
|
+
}
|
|
118
|
+
emitUpdate(sessionId);
|
|
119
|
+
return session;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function addEvent(sessionId, eventName, patch = {}) {
|
|
123
|
+
const session = sessions.get(sessionId);
|
|
124
|
+
if (!session) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
session.events.push({
|
|
129
|
+
name: eventName,
|
|
130
|
+
state: session.state,
|
|
131
|
+
timestamp: isoNow(),
|
|
132
|
+
meta: patch.meta || {}
|
|
133
|
+
});
|
|
134
|
+
session.events = session.events.slice(-80);
|
|
135
|
+
session.updatedAt = isoNow();
|
|
136
|
+
emitUpdate(sessionId);
|
|
137
|
+
return session;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function appendOutput(sessionId, chunk) {
|
|
141
|
+
const session = sessions.get(sessionId);
|
|
142
|
+
if (!session) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const lines = String(chunk).replace(/\r/g, "").split("\n").filter(Boolean);
|
|
147
|
+
session.logs.push(...lines);
|
|
148
|
+
session.logs = session.logs.slice(-LOG_LIMIT);
|
|
149
|
+
session.lastOutputAt = isoNow();
|
|
150
|
+
session.updatedAt = session.lastOutputAt;
|
|
151
|
+
// Terminal output does not change session state — skip session:update broadcast.
|
|
152
|
+
// Terminal data is forwarded directly by pty-manager via broadcastTerminal.
|
|
153
|
+
return session;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function markExit(sessionId, patch = {}) {
|
|
157
|
+
const session = sessions.get(sessionId);
|
|
158
|
+
if (!session) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
session.pid = null;
|
|
163
|
+
session.updatedAt = isoNow();
|
|
164
|
+
Object.assign(session, patch);
|
|
165
|
+
if (patch.state) {
|
|
166
|
+
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
|
+
}
|
|
174
|
+
}
|
|
175
|
+
emitUpdate(sessionId);
|
|
176
|
+
return session;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getSession(sessionId) {
|
|
180
|
+
const session = sessions.get(sessionId);
|
|
181
|
+
return session ? cloneSession(session) : null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function getSessionSummary(sessionId) {
|
|
185
|
+
const session = sessions.get(sessionId);
|
|
186
|
+
return session ? toSessionSummary(session) : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function listSessions() {
|
|
190
|
+
return [...sessions.values()]
|
|
191
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
|
192
|
+
.map(cloneSession);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function listSessionSummaries() {
|
|
196
|
+
return [...sessions.values()]
|
|
197
|
+
.filter((session) => !["completed", "exited"].includes(session.status))
|
|
198
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
|
199
|
+
.map((session) => toSessionSummary(session));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function removeSession(sessionId) {
|
|
203
|
+
const session = sessions.get(sessionId);
|
|
204
|
+
if (!session) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
sessions.delete(sessionId);
|
|
208
|
+
emitter.emit("session:remove", {
|
|
209
|
+
sessionId,
|
|
210
|
+
session: cloneSession(session)
|
|
211
|
+
});
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
emitter,
|
|
217
|
+
upsertSession,
|
|
218
|
+
setSessionState,
|
|
219
|
+
addEvent,
|
|
220
|
+
appendOutput,
|
|
221
|
+
markExit,
|
|
222
|
+
getSession,
|
|
223
|
+
getSessionSummary,
|
|
224
|
+
listSessions,
|
|
225
|
+
listSessionSummaries,
|
|
226
|
+
removeSession
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
createSessionStore
|
|
232
|
+
};
|