@virtengine/openfleet 0.25.0
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/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-tracker.mjs — Captures the last N agent messages for review handoff.
|
|
3
|
+
*
|
|
4
|
+
* When an agent completes (DONE/idle), the session tracker provides the last 10
|
|
5
|
+
* messages as context for the reviewer agent, including both agent outputs and
|
|
6
|
+
* tool calls/results.
|
|
7
|
+
*
|
|
8
|
+
* Supports disk persistence: each session is stored as a JSON file in
|
|
9
|
+
* `logs/sessions/<sessionId>.json` and auto-loaded on init.
|
|
10
|
+
*
|
|
11
|
+
* @module session-tracker
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { resolve, dirname } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const SESSIONS_DIR = resolve(__dirname, "logs", "sessions");
|
|
20
|
+
|
|
21
|
+
const TAG = "[session-tracker]";
|
|
22
|
+
|
|
23
|
+
/** Default: keep last 10 messages per task session. */
|
|
24
|
+
const DEFAULT_MAX_MESSAGES = 10;
|
|
25
|
+
|
|
26
|
+
/** Default: keep a larger history for manual/primary chat sessions. */
|
|
27
|
+
const DEFAULT_CHAT_MAX_MESSAGES = 2000;
|
|
28
|
+
|
|
29
|
+
/** Maximum characters per message entry to prevent memory bloat. */
|
|
30
|
+
const MAX_MESSAGE_CHARS = 2000;
|
|
31
|
+
|
|
32
|
+
/** Maximum total sessions to keep in memory. */
|
|
33
|
+
const MAX_SESSIONS = 50;
|
|
34
|
+
|
|
35
|
+
function resolveSessionMaxMessages(type, metadata, explicitMax, fallbackMax) {
|
|
36
|
+
if (Number.isFinite(explicitMax)) {
|
|
37
|
+
return explicitMax > 0 ? explicitMax : 0;
|
|
38
|
+
}
|
|
39
|
+
if (Number.isFinite(metadata?.maxMessages)) {
|
|
40
|
+
return metadata.maxMessages > 0 ? metadata.maxMessages : 0;
|
|
41
|
+
}
|
|
42
|
+
const normalizedType = String(type || "").toLowerCase();
|
|
43
|
+
if (["primary", "manual", "chat"].includes(normalizedType)) {
|
|
44
|
+
return DEFAULT_CHAT_MAX_MESSAGES;
|
|
45
|
+
}
|
|
46
|
+
return fallbackMax;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Message Types ───────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {Object} SessionMessage
|
|
53
|
+
* @property {string} type - "agent_message"|"tool_call"|"tool_result"|"error"|"system"
|
|
54
|
+
* @property {string} content - Truncated content
|
|
55
|
+
* @property {string} timestamp - ISO timestamp
|
|
56
|
+
* @property {Object} [meta] - Optional metadata (tool name, etc.)
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {Object} SessionRecord
|
|
61
|
+
* @property {string} taskId
|
|
62
|
+
* @property {string} taskTitle
|
|
63
|
+
* @property {number} startedAt
|
|
64
|
+
* @property {number|null} endedAt
|
|
65
|
+
* @property {SessionMessage[]} messages
|
|
66
|
+
* @property {number} totalEvents - Total events received (before truncation)
|
|
67
|
+
* @property {string} status - "active"|"completed"|"idle"|"failed"
|
|
68
|
+
* @property {number} lastActivityAt - Timestamp of last event
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/** Debounce interval for disk writes (ms). */
|
|
72
|
+
const FLUSH_INTERVAL_MS = 2000;
|
|
73
|
+
|
|
74
|
+
// ── SessionTracker Class ────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export class SessionTracker {
|
|
77
|
+
/** @type {Map<string, SessionRecord>} taskId → session record */
|
|
78
|
+
#sessions = new Map();
|
|
79
|
+
|
|
80
|
+
/** @type {number} */
|
|
81
|
+
#maxMessages;
|
|
82
|
+
|
|
83
|
+
/** @type {number} idle threshold (ms) — 2 minutes without events = idle */
|
|
84
|
+
#idleThresholdMs;
|
|
85
|
+
|
|
86
|
+
/** @type {string|null} directory for session JSON files */
|
|
87
|
+
#persistDir;
|
|
88
|
+
|
|
89
|
+
/** @type {Set<string>} session IDs with pending disk writes */
|
|
90
|
+
#dirty = new Set();
|
|
91
|
+
|
|
92
|
+
/** @type {ReturnType<typeof setInterval>|null} */
|
|
93
|
+
#flushTimer = null;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {Object} [options]
|
|
97
|
+
* @param {number} [options.maxMessages=10]
|
|
98
|
+
* @param {number} [options.idleThresholdMs=120000]
|
|
99
|
+
* @param {string|null} [options.persistDir] — null disables persistence
|
|
100
|
+
*/
|
|
101
|
+
constructor(options = {}) {
|
|
102
|
+
this.#maxMessages = options.maxMessages ?? DEFAULT_MAX_MESSAGES;
|
|
103
|
+
this.#idleThresholdMs = options.idleThresholdMs ?? 180_000; // 3 minutes — gives agents breathing room
|
|
104
|
+
this.#persistDir = options.persistDir !== undefined ? options.persistDir : null;
|
|
105
|
+
|
|
106
|
+
if (this.#persistDir) {
|
|
107
|
+
this.#ensureDir();
|
|
108
|
+
this.#loadFromDisk();
|
|
109
|
+
this.#flushTimer = setInterval(() => this.#flushDirty(), FLUSH_INTERVAL_MS);
|
|
110
|
+
if (this.#flushTimer.unref) this.#flushTimer.unref();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Start tracking a new session for a task.
|
|
116
|
+
* If a session already exists, it's replaced.
|
|
117
|
+
*
|
|
118
|
+
* @param {string} taskId
|
|
119
|
+
* @param {string} taskTitle
|
|
120
|
+
*/
|
|
121
|
+
startSession(taskId, taskTitle) {
|
|
122
|
+
// Evict oldest sessions if at capacity
|
|
123
|
+
if (this.#sessions.size >= MAX_SESSIONS && !this.#sessions.has(taskId)) {
|
|
124
|
+
const oldest = [...this.#sessions.entries()]
|
|
125
|
+
.sort((a, b) => a[1].startedAt - b[1].startedAt)
|
|
126
|
+
.slice(0, Math.ceil(MAX_SESSIONS / 4));
|
|
127
|
+
for (const [id] of oldest) {
|
|
128
|
+
this.#sessions.delete(id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.#sessions.set(taskId, {
|
|
133
|
+
taskId,
|
|
134
|
+
taskTitle,
|
|
135
|
+
id: taskId,
|
|
136
|
+
type: "task",
|
|
137
|
+
maxMessages: this.#maxMessages,
|
|
138
|
+
startedAt: Date.now(),
|
|
139
|
+
createdAt: new Date().toISOString(),
|
|
140
|
+
lastActiveAt: new Date().toISOString(),
|
|
141
|
+
endedAt: null,
|
|
142
|
+
messages: [],
|
|
143
|
+
totalEvents: 0,
|
|
144
|
+
turnCount: 0,
|
|
145
|
+
status: "active",
|
|
146
|
+
lastActivityAt: Date.now(),
|
|
147
|
+
metadata: {},
|
|
148
|
+
});
|
|
149
|
+
this.#markDirty(taskId);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Record an agent SDK event for a task session.
|
|
154
|
+
* Call this from the `onEvent` callback inside `execWithRetry`.
|
|
155
|
+
*
|
|
156
|
+
* Normalizes events from all 3 SDKs:
|
|
157
|
+
* - Codex: { type: "item.completed"|"item.created", item: {...} }
|
|
158
|
+
* - Copilot: { type: "message"|"tool_call"|"tool_result", ... }
|
|
159
|
+
* - Claude: { type: "content_block_delta"|"message_stop", ... }
|
|
160
|
+
*
|
|
161
|
+
* Also supports direct message objects: { role, content, timestamp, turnIndex }
|
|
162
|
+
*
|
|
163
|
+
* Auto-creates sessions for unknown taskIds when the event carries enough info.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} taskId
|
|
166
|
+
* @param {Object} event - Raw SDK event or direct message object
|
|
167
|
+
*/
|
|
168
|
+
recordEvent(taskId, event) {
|
|
169
|
+
let session = this.#sessions.get(taskId);
|
|
170
|
+
|
|
171
|
+
// Auto-create session if it doesn't exist yet
|
|
172
|
+
if (!session) {
|
|
173
|
+
if (event && (event.role || event.type)) {
|
|
174
|
+
this.#autoCreateSession(taskId, event);
|
|
175
|
+
session = this.#sessions.get(taskId);
|
|
176
|
+
}
|
|
177
|
+
if (!session) return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
session.totalEvents++;
|
|
181
|
+
session.lastActivityAt = Date.now();
|
|
182
|
+
session.lastActiveAt = new Date().toISOString();
|
|
183
|
+
|
|
184
|
+
const maxMessages =
|
|
185
|
+
session.maxMessages === null || session.maxMessages === undefined
|
|
186
|
+
? this.#maxMessages
|
|
187
|
+
: session.maxMessages;
|
|
188
|
+
|
|
189
|
+
// Direct message format (role/content)
|
|
190
|
+
if (event && event.role && event.content !== undefined) {
|
|
191
|
+
const msg = {
|
|
192
|
+
role: event.role,
|
|
193
|
+
content: String(event.content).slice(0, MAX_MESSAGE_CHARS),
|
|
194
|
+
timestamp: event.timestamp || new Date().toISOString(),
|
|
195
|
+
turnIndex: event.turnIndex ?? session.turnCount,
|
|
196
|
+
};
|
|
197
|
+
session.turnCount++;
|
|
198
|
+
session.messages.push(msg);
|
|
199
|
+
if (Number.isFinite(maxMessages) && maxMessages > 0) {
|
|
200
|
+
while (session.messages.length > maxMessages) session.messages.shift();
|
|
201
|
+
}
|
|
202
|
+
this.#markDirty(taskId);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const msg = this.#normalizeEvent(event);
|
|
207
|
+
if (!msg) {
|
|
208
|
+
this.#markDirty(taskId);
|
|
209
|
+
return; // Skip uninteresting events — still update timestamp
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Push to ring buffer (keep only last N)
|
|
213
|
+
session.messages.push(msg);
|
|
214
|
+
if (Number.isFinite(maxMessages) && maxMessages > 0) {
|
|
215
|
+
while (session.messages.length > maxMessages) session.messages.shift();
|
|
216
|
+
}
|
|
217
|
+
this.#markDirty(taskId);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Mark a session as completed.
|
|
222
|
+
* @param {string} taskId
|
|
223
|
+
* @param {"completed"|"failed"|"idle"} [status="completed"]
|
|
224
|
+
*/
|
|
225
|
+
endSession(taskId, status = "completed") {
|
|
226
|
+
const session = this.#sessions.get(taskId);
|
|
227
|
+
if (!session) return;
|
|
228
|
+
|
|
229
|
+
session.endedAt = Date.now();
|
|
230
|
+
session.status = status;
|
|
231
|
+
this.#markDirty(taskId);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get the last N messages for a task session.
|
|
236
|
+
* @param {string} taskId
|
|
237
|
+
* @param {number} [n] - defaults to maxMessages
|
|
238
|
+
* @returns {SessionMessage[]}
|
|
239
|
+
*/
|
|
240
|
+
getLastMessages(taskId, n) {
|
|
241
|
+
const session = this.#sessions.get(taskId);
|
|
242
|
+
if (!session) return [];
|
|
243
|
+
const count = n ?? this.#maxMessages;
|
|
244
|
+
return session.messages.slice(-count);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get a formatted summary of the last N messages.
|
|
249
|
+
* This is the string that gets passed to the review agent.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} taskId
|
|
252
|
+
* @param {number} [n]
|
|
253
|
+
* @returns {string}
|
|
254
|
+
*/
|
|
255
|
+
getMessageSummary(taskId, n) {
|
|
256
|
+
const messages = this.getLastMessages(taskId, n);
|
|
257
|
+
if (messages.length === 0) return "(no session messages recorded)";
|
|
258
|
+
|
|
259
|
+
const session = this.#sessions.get(taskId);
|
|
260
|
+
const header = [
|
|
261
|
+
`Session: ${session?.taskTitle || taskId}`,
|
|
262
|
+
`Total events: ${session?.totalEvents ?? 0}`,
|
|
263
|
+
`Duration: ${session ? Math.round((Date.now() - session.startedAt) / 1000) : 0}s`,
|
|
264
|
+
`Status: ${session?.status ?? "unknown"}`,
|
|
265
|
+
`--- Last ${messages.length} messages ---`,
|
|
266
|
+
].join("\n");
|
|
267
|
+
|
|
268
|
+
const lines = messages.map((msg) => {
|
|
269
|
+
const ts = new Date(msg.timestamp).toISOString().slice(11, 19);
|
|
270
|
+
const prefix = this.#typePrefix(msg.type || msg.role || "unknown");
|
|
271
|
+
const meta = msg.meta?.toolName ? ` [${msg.meta.toolName}]` : "";
|
|
272
|
+
return `[${ts}] ${prefix}${meta}: ${msg.content}`;
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return `${header}\n${lines.join("\n")}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Check if a session appears to be idle (no events for > idleThreshold).
|
|
280
|
+
* @param {string} taskId
|
|
281
|
+
* @returns {boolean}
|
|
282
|
+
*/
|
|
283
|
+
isSessionIdle(taskId) {
|
|
284
|
+
const session = this.#sessions.get(taskId);
|
|
285
|
+
if (!session || session.status !== "active") return false;
|
|
286
|
+
return Date.now() - session.lastActivityAt > this.#idleThresholdMs;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Get detailed progress status for a running session.
|
|
291
|
+
* Returns a structured assessment of agent progress suitable for mid-execution monitoring.
|
|
292
|
+
*
|
|
293
|
+
* @param {string} taskId
|
|
294
|
+
* @returns {{ status: "active"|"idle"|"stalled"|"not_found"|"ended", idleMs: number, totalEvents: number, lastEventType: string|null, hasEdits: boolean, hasCommits: boolean, elapsedMs: number, recommendation: "none"|"continue"|"nudge"|"abort" }}
|
|
295
|
+
*/
|
|
296
|
+
getProgressStatus(taskId) {
|
|
297
|
+
const session = this.#sessions.get(taskId);
|
|
298
|
+
if (!session) {
|
|
299
|
+
return {
|
|
300
|
+
status: "not_found", idleMs: 0, totalEvents: 0,
|
|
301
|
+
lastEventType: null, hasEdits: false, hasCommits: false,
|
|
302
|
+
elapsedMs: 0, recommendation: "none",
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (session.status !== "active") {
|
|
307
|
+
return {
|
|
308
|
+
status: "ended", idleMs: 0, totalEvents: session.totalEvents,
|
|
309
|
+
lastEventType: session.messages.at(-1)?.type ?? null,
|
|
310
|
+
hasEdits: false, hasCommits: false,
|
|
311
|
+
elapsedMs: (session.endedAt || Date.now()) - session.startedAt,
|
|
312
|
+
recommendation: "none",
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const now = Date.now();
|
|
317
|
+
const idleMs = now - session.lastActivityAt;
|
|
318
|
+
const elapsedMs = now - session.startedAt;
|
|
319
|
+
|
|
320
|
+
// Check if agent has done any meaningful edits or commits
|
|
321
|
+
const hasEdits = session.messages.some((m) => {
|
|
322
|
+
if (m.type !== "tool_call") return false;
|
|
323
|
+
const c = (m.content || "").toLowerCase();
|
|
324
|
+
return c.includes("write") || c.includes("edit") || c.includes("create") ||
|
|
325
|
+
c.includes("replace") || c.includes("patch") || c.includes("append");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const hasCommits = session.messages.some((m) => {
|
|
329
|
+
if (m.type !== "tool_call") return false;
|
|
330
|
+
const c = (m.content || "").toLowerCase();
|
|
331
|
+
return c.includes("git commit") || c.includes("git push");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Determine status — check stalled FIRST (it's the stricter condition)
|
|
335
|
+
let status = "active";
|
|
336
|
+
if (idleMs > this.#idleThresholdMs * 2) {
|
|
337
|
+
status = "stalled";
|
|
338
|
+
} else if (idleMs > this.#idleThresholdMs) {
|
|
339
|
+
status = "idle";
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Determine recommendation
|
|
343
|
+
let recommendation = "none";
|
|
344
|
+
if (status === "stalled") {
|
|
345
|
+
recommendation = "abort";
|
|
346
|
+
} else if (status === "idle") {
|
|
347
|
+
// If agent was idle but had some activity, try CONTINUE
|
|
348
|
+
recommendation = session.totalEvents > 0 ? "continue" : "nudge";
|
|
349
|
+
} else if (elapsedMs > 30 * 60_000 && session.totalEvents < 5) {
|
|
350
|
+
// 30 min with < 5 events — agent is stalled even if not technically idle
|
|
351
|
+
recommendation = "continue";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
status, idleMs, totalEvents: session.totalEvents,
|
|
356
|
+
lastEventType: session.messages.at(-1)?.type ?? null,
|
|
357
|
+
hasEdits, hasCommits, elapsedMs, recommendation,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Get all active sessions (for watchdog scanning).
|
|
363
|
+
* @returns {Array<{ taskId: string, taskTitle: string, idleMs: number, totalEvents: number, elapsedMs: number }>}
|
|
364
|
+
*/
|
|
365
|
+
getActiveSessions() {
|
|
366
|
+
const result = [];
|
|
367
|
+
const now = Date.now();
|
|
368
|
+
for (const [taskId, session] of this.#sessions) {
|
|
369
|
+
if (session.status !== "active") continue;
|
|
370
|
+
result.push({
|
|
371
|
+
taskId,
|
|
372
|
+
taskTitle: session.taskTitle,
|
|
373
|
+
idleMs: now - session.lastActivityAt,
|
|
374
|
+
totalEvents: session.totalEvents,
|
|
375
|
+
elapsedMs: now - session.startedAt,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Get the full session record.
|
|
383
|
+
* @param {string} taskId
|
|
384
|
+
* @returns {SessionRecord|null}
|
|
385
|
+
*/
|
|
386
|
+
getSession(taskId) {
|
|
387
|
+
return this.#sessions.get(taskId) ?? null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Remove a session from tracking (after review handoff).
|
|
392
|
+
* @param {string} taskId
|
|
393
|
+
*/
|
|
394
|
+
removeSession(taskId) {
|
|
395
|
+
this.#sessions.delete(taskId);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Get stats about tracked sessions.
|
|
400
|
+
* @returns {{ active: number, completed: number, total: number }}
|
|
401
|
+
*/
|
|
402
|
+
getStats() {
|
|
403
|
+
let active = 0;
|
|
404
|
+
let completed = 0;
|
|
405
|
+
for (const session of this.#sessions.values()) {
|
|
406
|
+
if (session.status === "active") active++;
|
|
407
|
+
else completed++;
|
|
408
|
+
}
|
|
409
|
+
return { active, completed, total: this.#sessions.size };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── Persistence API ─────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Create a new session with explicit options.
|
|
416
|
+
* @param {{ id: string, type?: string, taskId?: string, metadata?: Object }} opts
|
|
417
|
+
*/
|
|
418
|
+
createSession({ id, type = "manual", taskId, metadata = {}, maxMessages }) {
|
|
419
|
+
const now = new Date().toISOString();
|
|
420
|
+
const resolvedMax = resolveSessionMaxMessages(
|
|
421
|
+
type,
|
|
422
|
+
metadata,
|
|
423
|
+
maxMessages,
|
|
424
|
+
this.#maxMessages,
|
|
425
|
+
);
|
|
426
|
+
const session = {
|
|
427
|
+
id,
|
|
428
|
+
taskId: taskId || id,
|
|
429
|
+
taskTitle: metadata.title || id,
|
|
430
|
+
type,
|
|
431
|
+
status: "active",
|
|
432
|
+
createdAt: now,
|
|
433
|
+
lastActiveAt: now,
|
|
434
|
+
startedAt: Date.now(),
|
|
435
|
+
endedAt: null,
|
|
436
|
+
messages: [],
|
|
437
|
+
totalEvents: 0,
|
|
438
|
+
turnCount: 0,
|
|
439
|
+
lastActivityAt: Date.now(),
|
|
440
|
+
metadata,
|
|
441
|
+
maxMessages: resolvedMax,
|
|
442
|
+
};
|
|
443
|
+
this.#sessions.set(id, session);
|
|
444
|
+
this.#markDirty(id);
|
|
445
|
+
this.#flushDirty(); // immediate write for create
|
|
446
|
+
return session;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* List all sessions (metadata only, no full messages).
|
|
451
|
+
* Sorted by lastActiveAt descending.
|
|
452
|
+
* @returns {Array<Object>}
|
|
453
|
+
*/
|
|
454
|
+
listAllSessions() {
|
|
455
|
+
const list = [];
|
|
456
|
+
for (const s of this.#sessions.values()) {
|
|
457
|
+
list.push({
|
|
458
|
+
id: s.id || s.taskId,
|
|
459
|
+
taskId: s.taskId,
|
|
460
|
+
title: s.taskTitle || s.title || null,
|
|
461
|
+
type: s.type || "task",
|
|
462
|
+
status: s.status,
|
|
463
|
+
turnCount: s.turnCount || 0,
|
|
464
|
+
createdAt: s.createdAt || new Date(s.startedAt).toISOString(),
|
|
465
|
+
lastActiveAt: s.lastActiveAt || new Date(s.lastActivityAt).toISOString(),
|
|
466
|
+
preview: this.#lastMessagePreview(s),
|
|
467
|
+
lastMessage: this.#lastMessagePreview(s),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
list.sort((a, b) => (b.lastActiveAt || "").localeCompare(a.lastActiveAt || ""));
|
|
471
|
+
return list;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Get full session including all messages, read from disk if needed.
|
|
476
|
+
* @param {string} sessionId
|
|
477
|
+
* @returns {Object|null}
|
|
478
|
+
*/
|
|
479
|
+
getSessionMessages(sessionId) {
|
|
480
|
+
const session = this.#sessions.get(sessionId);
|
|
481
|
+
if (!session) return null;
|
|
482
|
+
return { ...session };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Get a session by id (alias for getSession with id lookup).
|
|
487
|
+
* @param {string} sessionId
|
|
488
|
+
* @returns {Object|null}
|
|
489
|
+
*/
|
|
490
|
+
getSessionById(sessionId) {
|
|
491
|
+
return this.#sessions.get(sessionId) ?? null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Update session status.
|
|
496
|
+
* @param {string} sessionId
|
|
497
|
+
* @param {string} status
|
|
498
|
+
*/
|
|
499
|
+
updateSessionStatus(sessionId, status) {
|
|
500
|
+
const session = this.#sessions.get(sessionId);
|
|
501
|
+
if (!session) return;
|
|
502
|
+
session.status = status;
|
|
503
|
+
if (status === "completed" || status === "archived") {
|
|
504
|
+
session.endedAt = Date.now();
|
|
505
|
+
}
|
|
506
|
+
this.#markDirty(sessionId);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Flush all dirty sessions to disk immediately.
|
|
511
|
+
*/
|
|
512
|
+
flush() {
|
|
513
|
+
this.#flushDirty();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Stop the flush timer (for cleanup).
|
|
518
|
+
*/
|
|
519
|
+
destroy() {
|
|
520
|
+
if (this.#flushTimer) {
|
|
521
|
+
clearInterval(this.#flushTimer);
|
|
522
|
+
this.#flushTimer = null;
|
|
523
|
+
}
|
|
524
|
+
this.#flushDirty();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Merge any on-disk session updates into memory.
|
|
529
|
+
* Useful when another process writes session files.
|
|
530
|
+
*/
|
|
531
|
+
refreshFromDisk() {
|
|
532
|
+
if (!this.#persistDir) return;
|
|
533
|
+
this.#ensureDir();
|
|
534
|
+
let files = [];
|
|
535
|
+
try {
|
|
536
|
+
files = readdirSync(this.#persistDir).filter((f) => f.endsWith(".json"));
|
|
537
|
+
} catch {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
for (const file of files) {
|
|
541
|
+
const filePath = resolve(this.#persistDir, file);
|
|
542
|
+
try {
|
|
543
|
+
const raw = readFileSync(filePath, "utf8");
|
|
544
|
+
const data = JSON.parse(raw || "{}");
|
|
545
|
+
const sessionId = String(data.id || data.taskId || "").trim();
|
|
546
|
+
if (!sessionId) continue;
|
|
547
|
+
const lastActiveAt =
|
|
548
|
+
Date.parse(data.lastActiveAt || "") ||
|
|
549
|
+
Date.parse(data.updatedAt || "") ||
|
|
550
|
+
0;
|
|
551
|
+
const existing = this.#sessions.get(sessionId);
|
|
552
|
+
const existingLast =
|
|
553
|
+
existing?.lastActivityAt ||
|
|
554
|
+
Date.parse(existing?.lastActiveAt || "") ||
|
|
555
|
+
0;
|
|
556
|
+
if (existing && existingLast >= lastActiveAt) {
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
this.#sessions.set(sessionId, {
|
|
560
|
+
taskId: data.taskId || sessionId,
|
|
561
|
+
taskTitle: data.title || data.taskTitle || null,
|
|
562
|
+
id: sessionId,
|
|
563
|
+
type: data.type || "task",
|
|
564
|
+
startedAt: Date.parse(data.createdAt || "") || Date.now(),
|
|
565
|
+
createdAt: data.createdAt || new Date().toISOString(),
|
|
566
|
+
lastActiveAt: data.lastActiveAt || data.updatedAt || new Date().toISOString(),
|
|
567
|
+
endedAt: data.endedAt || null,
|
|
568
|
+
messages: Array.isArray(data.messages) ? data.messages : [],
|
|
569
|
+
totalEvents: Array.isArray(data.messages) ? data.messages.length : 0,
|
|
570
|
+
turnCount: data.turnCount || 0,
|
|
571
|
+
status: data.status || "active",
|
|
572
|
+
lastActivityAt: lastActiveAt || Date.now(),
|
|
573
|
+
metadata: data.metadata || {},
|
|
574
|
+
});
|
|
575
|
+
} catch {
|
|
576
|
+
/* ignore corrupt session file */
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── Private helpers ───────────────────────────────────────────────────────
|
|
582
|
+
|
|
583
|
+
/** Auto-create a session when recordEvent is called for an unknown taskId. */
|
|
584
|
+
#autoCreateSession(taskId, event) {
|
|
585
|
+
const type = event._sessionType || "task";
|
|
586
|
+
this.createSession({
|
|
587
|
+
id: taskId,
|
|
588
|
+
type,
|
|
589
|
+
taskId,
|
|
590
|
+
metadata: { autoCreated: true },
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Get preview text from last message */
|
|
595
|
+
#lastMessagePreview(session) {
|
|
596
|
+
const last = session.messages?.at(-1);
|
|
597
|
+
if (!last) return "";
|
|
598
|
+
const content = last.content || "";
|
|
599
|
+
return content.slice(0, 100);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
#markDirty(sessionId) {
|
|
603
|
+
if (this.#persistDir) {
|
|
604
|
+
this.#dirty.add(sessionId);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
#ensureDir() {
|
|
609
|
+
if (this.#persistDir && !existsSync(this.#persistDir)) {
|
|
610
|
+
mkdirSync(this.#persistDir, { recursive: true });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
#sessionFilePath(sessionId) {
|
|
615
|
+
// Sanitize sessionId for filesystem safety
|
|
616
|
+
const safe = String(sessionId).replace(/[^a-zA-Z0-9_\-\.]/g, "_");
|
|
617
|
+
return resolve(this.#persistDir, `${safe}.json`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
#flushDirty() {
|
|
621
|
+
if (!this.#persistDir || this.#dirty.size === 0) return;
|
|
622
|
+
this.#ensureDir();
|
|
623
|
+
for (const sessionId of this.#dirty) {
|
|
624
|
+
const session = this.#sessions.get(sessionId);
|
|
625
|
+
if (!session) continue;
|
|
626
|
+
try {
|
|
627
|
+
const filePath = this.#sessionFilePath(sessionId);
|
|
628
|
+
const data = {
|
|
629
|
+
id: session.id || session.taskId,
|
|
630
|
+
taskId: session.taskId,
|
|
631
|
+
title: session.taskTitle || session.title || null,
|
|
632
|
+
taskTitle: session.taskTitle || null,
|
|
633
|
+
type: session.type || "task",
|
|
634
|
+
status: session.status,
|
|
635
|
+
createdAt: session.createdAt || new Date(session.startedAt).toISOString(),
|
|
636
|
+
lastActiveAt: session.lastActiveAt || new Date(session.lastActivityAt).toISOString(),
|
|
637
|
+
turnCount: session.turnCount || 0,
|
|
638
|
+
messages: session.messages || [],
|
|
639
|
+
metadata: session.metadata || {},
|
|
640
|
+
};
|
|
641
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
642
|
+
} catch (err) {
|
|
643
|
+
// Silently ignore write errors — disk persistence is best-effort
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
this.#dirty.clear();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
#loadFromDisk() {
|
|
650
|
+
if (!this.#persistDir || !existsSync(this.#persistDir)) return;
|
|
651
|
+
try {
|
|
652
|
+
const files = readdirSync(this.#persistDir).filter((f) => f.endsWith(".json"));
|
|
653
|
+
for (const file of files) {
|
|
654
|
+
try {
|
|
655
|
+
const raw = readFileSync(resolve(this.#persistDir, file), "utf8");
|
|
656
|
+
const data = JSON.parse(raw);
|
|
657
|
+
if (!data.id && !data.taskId) continue;
|
|
658
|
+
const id = data.id || data.taskId;
|
|
659
|
+
if (this.#sessions.has(id)) continue; // don't overwrite in-memory
|
|
660
|
+
this.#sessions.set(id, {
|
|
661
|
+
id,
|
|
662
|
+
taskId: data.taskId || id,
|
|
663
|
+
taskTitle: data.metadata?.title || id,
|
|
664
|
+
type: data.type || "task",
|
|
665
|
+
status: data.status || "completed",
|
|
666
|
+
createdAt: data.createdAt || new Date().toISOString(),
|
|
667
|
+
lastActiveAt: data.lastActiveAt || new Date().toISOString(),
|
|
668
|
+
startedAt: data.createdAt ? new Date(data.createdAt).getTime() : Date.now(),
|
|
669
|
+
endedAt: data.status !== "active" ? Date.now() : null,
|
|
670
|
+
messages: data.messages || [],
|
|
671
|
+
totalEvents: (data.messages || []).length,
|
|
672
|
+
turnCount: data.turnCount || 0,
|
|
673
|
+
lastActivityAt: data.lastActiveAt ? new Date(data.lastActiveAt).getTime() : Date.now(),
|
|
674
|
+
metadata: data.metadata || {},
|
|
675
|
+
});
|
|
676
|
+
} catch {
|
|
677
|
+
// Skip corrupt files
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
} catch {
|
|
681
|
+
// Directory read failed — proceed without disk data
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Normalize a raw SDK event into a SessionMessage.
|
|
687
|
+
* Returns null for events that shouldn't be tracked (noise).
|
|
688
|
+
*
|
|
689
|
+
* @param {Object} event
|
|
690
|
+
* @returns {SessionMessage|null}
|
|
691
|
+
* @private
|
|
692
|
+
*/
|
|
693
|
+
#normalizeEvent(event) {
|
|
694
|
+
if (!event || !event.type) return null;
|
|
695
|
+
|
|
696
|
+
const ts = new Date().toISOString();
|
|
697
|
+
|
|
698
|
+
// ── Codex SDK events ──
|
|
699
|
+
if (event.type === "item.completed" && event.item) {
|
|
700
|
+
const item = event.item;
|
|
701
|
+
|
|
702
|
+
if (item.type === "agent_message" && item.text) {
|
|
703
|
+
return {
|
|
704
|
+
type: "agent_message",
|
|
705
|
+
content: item.text.slice(0, MAX_MESSAGE_CHARS),
|
|
706
|
+
timestamp: ts,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (item.type === "function_call") {
|
|
711
|
+
return {
|
|
712
|
+
type: "tool_call",
|
|
713
|
+
content: `${item.name}(${(item.arguments || "").slice(0, 500)})`,
|
|
714
|
+
timestamp: ts,
|
|
715
|
+
meta: { toolName: item.name },
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (item.type === "function_call_output") {
|
|
720
|
+
return {
|
|
721
|
+
type: "tool_result",
|
|
722
|
+
content: (item.output || "").slice(0, MAX_MESSAGE_CHARS),
|
|
723
|
+
timestamp: ts,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return null; // Skip other item types
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ── Copilot SDK events ──
|
|
731
|
+
if (event.type === "message" && event.content) {
|
|
732
|
+
return {
|
|
733
|
+
type: "agent_message",
|
|
734
|
+
content: (typeof event.content === "string" ? event.content : JSON.stringify(event.content))
|
|
735
|
+
.slice(0, MAX_MESSAGE_CHARS),
|
|
736
|
+
timestamp: ts,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (event.type === "tool_call") {
|
|
741
|
+
return {
|
|
742
|
+
type: "tool_call",
|
|
743
|
+
content: `${event.name || event.tool || "tool"}(${(event.arguments || event.input || "").slice(0, 500)})`,
|
|
744
|
+
timestamp: ts,
|
|
745
|
+
meta: { toolName: event.name || event.tool },
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (event.type === "tool_result" || event.type === "tool_output") {
|
|
750
|
+
return {
|
|
751
|
+
type: "tool_result",
|
|
752
|
+
content: (event.output || event.result || "").slice(0, MAX_MESSAGE_CHARS),
|
|
753
|
+
timestamp: ts,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ── Claude SDK events ──
|
|
758
|
+
if (event.type === "content_block_delta" && event.delta?.text) {
|
|
759
|
+
return {
|
|
760
|
+
type: "agent_message",
|
|
761
|
+
content: event.delta.text.slice(0, MAX_MESSAGE_CHARS),
|
|
762
|
+
timestamp: ts,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (event.type === "message_stop" || event.type === "message_delta") {
|
|
767
|
+
return {
|
|
768
|
+
type: "system",
|
|
769
|
+
content: `${event.type}${event.delta?.stop_reason ? ` (${event.delta.stop_reason})` : ""}`,
|
|
770
|
+
timestamp: ts,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ── Error events (any SDK) ──
|
|
775
|
+
if (event.type === "error" || event.type === "stream_error") {
|
|
776
|
+
return {
|
|
777
|
+
type: "error",
|
|
778
|
+
content: (event.error?.message || event.message || JSON.stringify(event)).slice(0, MAX_MESSAGE_CHARS),
|
|
779
|
+
timestamp: ts,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Get a display prefix for a message type.
|
|
788
|
+
* @param {string} type
|
|
789
|
+
* @returns {string}
|
|
790
|
+
* @private
|
|
791
|
+
*/
|
|
792
|
+
#typePrefix(type) {
|
|
793
|
+
switch (type) {
|
|
794
|
+
case "agent_message": return "AGENT";
|
|
795
|
+
case "tool_call": return "TOOL";
|
|
796
|
+
case "tool_result": return "RESULT";
|
|
797
|
+
case "error": return "ERROR";
|
|
798
|
+
case "system": return "SYS";
|
|
799
|
+
case "user": return "USER";
|
|
800
|
+
case "assistant": return "ASSISTANT";
|
|
801
|
+
default: return type.toUpperCase();
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// ── Standalone exported functions (delegate to singleton) ───────────────────
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* List all sessions (metadata only).
|
|
810
|
+
* @returns {Array<Object>}
|
|
811
|
+
*/
|
|
812
|
+
export function listAllSessions() {
|
|
813
|
+
return getSessionTracker().listAllSessions();
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Get full session with all messages.
|
|
818
|
+
* @param {string} sessionId
|
|
819
|
+
* @returns {Object|null}
|
|
820
|
+
*/
|
|
821
|
+
export function getSessionMessages(sessionId) {
|
|
822
|
+
return getSessionTracker().getSessionMessages(sessionId);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Create a new session.
|
|
827
|
+
* @param {{ id: string, type?: string, taskId?: string, metadata?: Object }} opts
|
|
828
|
+
* @returns {Object}
|
|
829
|
+
*/
|
|
830
|
+
export async function createSession(opts) {
|
|
831
|
+
return getSessionTracker().createSession(opts);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Update session status.
|
|
836
|
+
* @param {string} sessionId
|
|
837
|
+
* @param {string} status
|
|
838
|
+
*/
|
|
839
|
+
export function updateSessionStatus(sessionId, status) {
|
|
840
|
+
return getSessionTracker().updateSessionStatus(sessionId, status);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Get a session by id.
|
|
845
|
+
* @param {string} sessionId
|
|
846
|
+
* @returns {Object|null}
|
|
847
|
+
*/
|
|
848
|
+
export function getSessionById(sessionId) {
|
|
849
|
+
return getSessionTracker().getSessionById(sessionId);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ── Singleton ───────────────────────────────────────────────────────────────
|
|
853
|
+
|
|
854
|
+
/** @type {SessionTracker|null} */
|
|
855
|
+
let _instance = null;
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Get or create the singleton SessionTracker.
|
|
859
|
+
* @param {Object} [options]
|
|
860
|
+
* @returns {SessionTracker}
|
|
861
|
+
*/
|
|
862
|
+
export function getSessionTracker(options) {
|
|
863
|
+
if (!_instance) {
|
|
864
|
+
_instance = new SessionTracker({
|
|
865
|
+
persistDir: SESSIONS_DIR,
|
|
866
|
+
...options,
|
|
867
|
+
});
|
|
868
|
+
console.log(`${TAG} initialized (maxMessages=${_instance.getStats ? DEFAULT_MAX_MESSAGES : "?"})`);
|
|
869
|
+
}
|
|
870
|
+
return _instance;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Create a standalone SessionTracker (for testing).
|
|
875
|
+
* @param {Object} [options]
|
|
876
|
+
* @returns {SessionTracker}
|
|
877
|
+
*/
|
|
878
|
+
export function createSessionTracker(options) {
|
|
879
|
+
return new SessionTracker(options);
|
|
880
|
+
}
|