@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.
Files changed (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. 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
+ }