bosun 0.41.0 → 0.41.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.env.example +8 -0
  2. package/README.md +20 -0
  3. package/agent/agent-event-bus.mjs +248 -6
  4. package/agent/agent-pool.mjs +125 -28
  5. package/agent/agent-work-analyzer.mjs +8 -16
  6. package/agent/retry-queue.mjs +164 -0
  7. package/bosun.config.example.json +25 -0
  8. package/bosun.schema.json +825 -183
  9. package/cli.mjs +59 -5
  10. package/config/config.mjs +130 -3
  11. package/infra/monitor.mjs +693 -67
  12. package/infra/runtime-accumulator.mjs +376 -84
  13. package/infra/session-tracker.mjs +82 -25
  14. package/lib/codebase-audit.mjs +133 -18
  15. package/package.json +23 -4
  16. package/server/setup-web-server.mjs +25 -0
  17. package/server/ui-server.mjs +248 -29
  18. package/setup.mjs +27 -24
  19. package/shell/codex-shell.mjs +34 -3
  20. package/shell/copilot-shell.mjs +50 -8
  21. package/task/msg-hub.mjs +193 -0
  22. package/task/pipeline.mjs +544 -0
  23. package/task/task-cli.mjs +38 -2
  24. package/task/task-executor-pipeline.mjs +143 -0
  25. package/task/task-executor.mjs +36 -27
  26. package/telegram/get-telegram-chat-id.mjs +57 -47
  27. package/ui/components/workspace-switcher.js +7 -7
  28. package/ui/demo-defaults.js +15694 -10573
  29. package/ui/modules/settings-schema.js +2 -0
  30. package/ui/modules/state.js +54 -57
  31. package/ui/modules/voice-client-sdk.js +375 -36
  32. package/ui/modules/voice-client.js +140 -31
  33. package/ui/setup.html +68 -2
  34. package/ui/styles/components.css +57 -0
  35. package/ui/styles.css +201 -1
  36. package/ui/tabs/dashboard.js +74 -0
  37. package/ui/tabs/logs.js +10 -0
  38. package/ui/tabs/settings.js +178 -99
  39. package/ui/tabs/tasks.js +31 -1
  40. package/ui/tabs/telemetry.js +34 -0
  41. package/ui/tabs/workflow-canvas-utils.mjs +8 -1
  42. package/ui/tabs/workflows.js +532 -275
  43. package/voice/voice-agents-sdk.mjs +1 -1
  44. package/voice/voice-relay.mjs +6 -6
  45. package/workflow/declarative-workflows.mjs +145 -0
  46. package/workflow/msg-hub.mjs +237 -0
  47. package/workflow/pipeline-workflows.mjs +287 -0
  48. package/workflow/pipeline.mjs +828 -315
  49. package/workflow/workflow-cli.mjs +128 -0
  50. package/workflow/workflow-engine.mjs +329 -17
  51. package/workflow/workflow-nodes/custom-loader.mjs +250 -0
  52. package/workflow/workflow-nodes.mjs +1955 -223
  53. package/workflow/workflow-templates.mjs +26 -8
  54. package/workflow-templates/agents.mjs +0 -1
  55. package/workflow-templates/bosun-native.mjs +212 -2
  56. package/workflow-templates/continuation-loop.mjs +339 -0
  57. package/workflow-templates/github.mjs +516 -40
  58. package/workflow-templates/planning.mjs +446 -17
  59. package/workflow-templates/reliability.mjs +65 -12
  60. package/workflow-templates/task-batch.mjs +24 -8
  61. package/workflow-templates/task-lifecycle.mjs +83 -6
  62. package/workspace/context-cache.mjs +66 -18
  63. package/workspace/workspace-manager.mjs +2 -1
  64. package/workflow-templates/issue-continuation.mjs +0 -243
@@ -1,94 +1,340 @@
1
1
  /**
2
- * runtime-accumulator.mjs — Persists runtime and session tokens across restarts
2
+ * runtime-accumulator.mjs — Persists runtime and completed session totals across restarts.
3
3
  *
4
- * Tracks:
5
- * - Total runtime milliseconds across all monitor restarts
6
- * - Completed session tokens for continuity
7
- * - Total cost in USD
8
- * - Session history
4
+ * Source of truth for completed-session lifetime stats is an append-only JSONL log written
5
+ * synchronously on every terminal session completion. A compact snapshot file is also kept as a
6
+ * best-effort cache for faster startup and legacy compatibility, but restart recovery never
7
+ * depends on the snapshot being current.
9
8
  *
10
9
  * @module runtime-accumulator
11
10
  */
12
11
 
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import {
13
+ closeSync,
14
+ existsSync,
15
+ fsyncSync,
16
+ mkdirSync,
17
+ openSync,
18
+ readFileSync,
19
+ unlinkSync,
20
+ writeFileSync,
21
+ writeSync,
22
+ } from "node:fs";
14
23
  import { resolve, dirname } from "node:path";
15
24
  import { fileURLToPath } from "node:url";
16
25
 
17
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
- const CACHE_DIR = resolve(__dirname, "..", ".cache");
19
- const RUNTIME_FILE = resolve(CACHE_DIR, "runtime-accumulator.json");
27
+ const DEFAULT_CACHE_DIR = resolve(__dirname, "..", ".cache");
28
+ const SNAPSHOT_FILE_NAME = "runtime-accumulator.json";
29
+ const SESSION_LOG_FILE_NAME = "session-accumulator.jsonl";
30
+ const MAX_SESSION_TOKENS = 100;
31
+ const MAX_COMPLETED_SESSIONS = 500;
32
+
33
+ let _cacheDir = DEFAULT_CACHE_DIR;
34
+ let _runtimeFile = resolve(_cacheDir, SNAPSHOT_FILE_NAME);
35
+ let _sessionLogFile = resolve(_cacheDir, SESSION_LOG_FILE_NAME);
20
36
 
21
37
  const DEFAULT_STATE = {
22
38
  runtimeMs: 0,
23
39
  totalCostUsd: 0,
24
40
  sessionTokens: [],
25
41
  completedSessions: [],
42
+ taskLifetimeTotals: {},
26
43
  lastUpdated: null,
27
44
  startedAt: null,
28
45
  };
29
46
 
30
- let _state = { ...DEFAULT_STATE };
47
+ let _state = cloneDefaultState();
31
48
  let _initialized = false;
32
49
  let _lastSaveTime = 0;
50
+ let _saveHooksInstalled = false;
51
+ let _seenSessionKeys = new Set();
52
+
33
53
  const SAVE_INTERVAL_MS = 5000;
54
+ const SESSION_ACCUMULATION_LISTENERS = new Set();
34
55
 
35
- function loadState() {
36
- if (_initialized) return;
37
- _initialized = true;
56
+ function cloneDefaultState() {
57
+ return {
58
+ ...DEFAULT_STATE,
59
+ sessionTokens: [],
60
+ completedSessions: [],
61
+ taskLifetimeTotals: {},
62
+ };
63
+ }
64
+
65
+ function configureCachePaths(cacheDir = DEFAULT_CACHE_DIR) {
66
+ _cacheDir = resolve(cacheDir || DEFAULT_CACHE_DIR);
67
+ _runtimeFile = resolve(_cacheDir, SNAPSHOT_FILE_NAME);
68
+ _sessionLogFile = resolve(_cacheDir, SESSION_LOG_FILE_NAME);
69
+ }
38
70
 
71
+ function ensureCacheDir() {
39
72
  try {
40
- mkdirSync(CACHE_DIR, { recursive: true });
41
- } catch { /* best effort */ }
73
+ mkdirSync(_cacheDir, { recursive: true });
74
+ } catch {
75
+ /* best effort */
76
+ }
77
+ }
78
+
79
+ function toFiniteNumber(value, fallback = 0) {
80
+ const parsed = Number(value);
81
+ return Number.isFinite(parsed) ? parsed : fallback;
82
+ }
83
+
84
+ function syncGlobals() {
85
+ globalThis.__bosun_runtimeMs = _state.runtimeMs;
86
+ globalThis.__bosun_totalCostUsd = _state.totalCostUsd;
87
+ globalThis.__bosun_sessionTokens = getSessionTokens;
88
+ }
89
+
90
+ function normalizeCompletedSession(session = {}) {
91
+ const taskId = String(session.taskId || session.id || session.sessionId || "").trim();
92
+ const endedAt = toFiniteNumber(session.endedAt, Date.now());
93
+ const startedAt = toFiniteNumber(session.startedAt, endedAt);
94
+ const inputTokens = toFiniteNumber(
95
+ session.inputTokens ?? session.prompt_tokens ?? session.promptTokens ?? session.input_tokens,
96
+ 0,
97
+ );
98
+ const outputTokens = toFiniteNumber(
99
+ session.outputTokens ?? session.completion_tokens ?? session.completionTokens ?? session.output_tokens,
100
+ 0,
101
+ );
102
+ const tokenCount = toFiniteNumber(
103
+ session.tokenCount ?? session.totalTokens ?? session.total_tokens ?? session.tokens ?? (inputTokens + outputTokens),
104
+ inputTokens + outputTokens,
105
+ );
106
+ const durationMs = Math.max(
107
+ 0,
108
+ toFiniteNumber(session.durationMs, endedAt > startedAt ? endedAt - startedAt : 0),
109
+ );
110
+ const costUsd = Math.max(
111
+ 0,
112
+ toFiniteNumber(session.costUsd ?? session.cost_usd ?? session.cost ?? session.total_cost ?? session.usd, 0),
113
+ );
114
+ const stableId = String(session.id || session.sessionId || `${taskId || "session"}:${startedAt}:${endedAt}`).trim();
115
+ const sessionKey = String(
116
+ session.sessionKey || `${taskId || "task"}:${stableId}:${startedAt}:${endedAt}`,
117
+ ).trim();
42
118
 
119
+ return {
120
+ type: "completed_session",
121
+ sessionKey,
122
+ id: stableId,
123
+ taskId,
124
+ taskTitle: String(session.taskTitle || "").trim() || null,
125
+ executor: String(session.executor || "").trim() || null,
126
+ model: String(session.model || "").trim() || null,
127
+ startedAt,
128
+ endedAt,
129
+ durationMs,
130
+ status: String(session.status || "completed").trim() || "completed",
131
+ tokenCount,
132
+ inputTokens,
133
+ outputTokens,
134
+ costUsd,
135
+ recordedAt: String(session.recordedAt || new Date().toISOString()),
136
+ };
137
+ }
138
+
139
+ function buildSessionDedupKey(record) {
140
+ return String(record?.sessionKey || "").trim();
141
+ }
142
+
143
+ function cloneLifetimeTotals(totals) {
144
+ if (!totals) return null;
145
+ return { ...totals };
146
+ }
147
+
148
+ function applyCompletedSessionRecord(record) {
149
+ if (!record?.taskId) return null;
150
+ const dedupKey = buildSessionDedupKey(record);
151
+ if (!dedupKey || _seenSessionKeys.has(dedupKey)) {
152
+ return record.taskId ? cloneLifetimeTotals(_state.taskLifetimeTotals[record.taskId]) : null;
153
+ }
154
+ _seenSessionKeys.add(dedupKey);
155
+
156
+ _state.completedSessions.push({ ...record });
157
+ if (_state.completedSessions.length > MAX_COMPLETED_SESSIONS) {
158
+ _state.completedSessions = _state.completedSessions.slice(-MAX_COMPLETED_SESSIONS);
159
+ }
160
+
161
+ _state.sessionTokens.push({
162
+ id: record.id,
163
+ expiresAt: Date.now() + 24 * 60 * 60 * 1000,
164
+ });
165
+ if (_state.sessionTokens.length > MAX_SESSION_TOKENS) {
166
+ _state.sessionTokens = _state.sessionTokens.slice(-MAX_SESSION_TOKENS);
167
+ }
168
+
169
+ const currentTotals = _state.taskLifetimeTotals[record.taskId] || {
170
+ taskId: record.taskId,
171
+ taskTitle: record.taskTitle,
172
+ attemptsCount: 0,
173
+ tokenCount: 0,
174
+ inputTokens: 0,
175
+ outputTokens: 0,
176
+ durationMs: 0,
177
+ lastSessionId: null,
178
+ lastSessionEndedAt: null,
179
+ lastStatus: null,
180
+ updatedAt: null,
181
+ };
182
+
183
+ const nextTotals = {
184
+ ...currentTotals,
185
+ taskId: record.taskId,
186
+ taskTitle: record.taskTitle || currentTotals.taskTitle || null,
187
+ attemptsCount: currentTotals.attemptsCount + 1,
188
+ tokenCount: currentTotals.tokenCount + record.tokenCount,
189
+ inputTokens: currentTotals.inputTokens + record.inputTokens,
190
+ outputTokens: currentTotals.outputTokens + record.outputTokens,
191
+ durationMs: currentTotals.durationMs + record.durationMs,
192
+ lastSessionId: record.id,
193
+ lastSessionEndedAt: record.endedAt,
194
+ lastStatus: record.status,
195
+ updatedAt: record.recordedAt,
196
+ };
197
+ _state.taskLifetimeTotals[record.taskId] = nextTotals;
198
+
199
+ _state.runtimeMs += record.durationMs;
200
+ _state.totalCostUsd += record.costUsd;
201
+ _state.lastUpdated = record.recordedAt;
202
+ syncGlobals();
203
+ return cloneLifetimeTotals(nextTotals);
204
+ }
205
+
206
+ function loadLegacySnapshot() {
207
+ if (!existsSync(_runtimeFile)) return;
43
208
  try {
44
- if (existsSync(RUNTIME_FILE)) {
45
- const raw = readFileSync(RUNTIME_FILE, "utf8");
46
- const parsed = JSON.parse(raw);
47
- if (parsed && typeof parsed === "object") {
48
- _state = {
49
- ...DEFAULT_STATE,
50
- ...parsed,
51
- sessionTokens: Array.isArray(parsed.sessionTokens) ? parsed.sessionTokens : [],
52
- completedSessions: Array.isArray(parsed.completedSessions) ? parsed.completedSessions : [],
53
- };
209
+ const raw = readFileSync(_runtimeFile, "utf8");
210
+ const parsed = JSON.parse(raw);
211
+ if (!parsed || typeof parsed !== "object") return;
212
+
213
+ _state.startedAt = parsed.startedAt || _state.startedAt;
214
+ _state.lastUpdated = parsed.lastUpdated || _state.lastUpdated;
215
+ _state.sessionTokens = Array.isArray(parsed.sessionTokens) ? parsed.sessionTokens : [];
216
+
217
+ const completedSessions = Array.isArray(parsed.completedSessions)
218
+ ? parsed.completedSessions
219
+ : Array.isArray(parsed.sessions)
220
+ ? parsed.sessions
221
+ : [];
222
+ for (const session of completedSessions) {
223
+ applyCompletedSessionRecord(normalizeCompletedSession(session));
224
+ }
225
+
226
+ if (parsed.taskLifetimeTotals && typeof parsed.taskLifetimeTotals === "object") {
227
+ for (const [taskId, totals] of Object.entries(parsed.taskLifetimeTotals)) {
228
+ if (!_state.taskLifetimeTotals[taskId]) {
229
+ _state.taskLifetimeTotals[taskId] = { ...totals };
230
+ }
54
231
  }
55
232
  }
56
233
  } catch (err) {
57
- console.warn(`[runtime-accumulator] failed to load state: ${err.message}`);
234
+ console.warn(`[runtime-accumulator] failed to load snapshot: ${err.message}`);
58
235
  }
236
+ }
59
237
 
238
+ function loadSessionLog() {
239
+ if (!existsSync(_sessionLogFile)) return;
240
+ try {
241
+ const raw = readFileSync(_sessionLogFile, "utf8");
242
+ for (const line of raw.split(/\r?\n/)) {
243
+ const trimmed = line.trim();
244
+ if (!trimmed) continue;
245
+ try {
246
+ const parsed = JSON.parse(trimmed);
247
+ if (!parsed || typeof parsed !== "object") continue;
248
+ applyCompletedSessionRecord(normalizeCompletedSession(parsed));
249
+ } catch {
250
+ // Ignore malformed lines to preserve append-only recovery.
251
+ }
252
+ }
253
+ } catch (err) {
254
+ console.warn(`[runtime-accumulator] failed to load session log: ${err.message}`);
255
+ }
256
+ }
257
+
258
+ function loadState() {
259
+ if (_initialized) return;
260
+ _initialized = true;
261
+ ensureCacheDir();
262
+ loadLegacySnapshot();
263
+ loadSessionLog();
60
264
  _state.startedAt = _state.startedAt || Date.now();
265
+ syncGlobals();
61
266
  }
62
267
 
63
- function saveState() {
268
+ function saveState(force = false) {
64
269
  const now = Date.now();
65
- if (now - _lastSaveTime < SAVE_INTERVAL_MS) return;
270
+ if (!force && now - _lastSaveTime < SAVE_INTERVAL_MS) return;
66
271
  _lastSaveTime = now;
67
272
 
68
273
  try {
69
- mkdirSync(CACHE_DIR, { recursive: true });
274
+ ensureCacheDir();
70
275
  const payload = {
71
276
  runtimeMs: _state.runtimeMs,
72
277
  totalCostUsd: _state.totalCostUsd,
73
- sessionTokens: _state.sessionTokens.slice(-100),
74
- completedSessions: _state.completedSessions.slice(-500),
75
- lastUpdated: new Date().toISOString(),
278
+ sessionTokens: _state.sessionTokens.slice(-MAX_SESSION_TOKENS),
279
+ completedSessions: _state.completedSessions.slice(-MAX_COMPLETED_SESSIONS),
280
+ taskLifetimeTotals: _state.taskLifetimeTotals,
281
+ lastUpdated: _state.lastUpdated,
76
282
  startedAt: _state.startedAt,
77
283
  };
78
- writeFileSync(RUNTIME_FILE, JSON.stringify(payload, null, 2), "utf8");
284
+ writeFileSync(_runtimeFile, JSON.stringify(payload, null, 2), "utf8");
79
285
  } catch (err) {
80
286
  console.warn(`[runtime-accumulator] failed to save state: ${err.message}`);
81
287
  }
82
288
  }
83
289
 
290
+ function appendCompletedSessionRecord(record) {
291
+ ensureCacheDir();
292
+ const fd = openSync(_sessionLogFile, "a");
293
+ try {
294
+ const line = `${JSON.stringify(record)}\n`;
295
+ writeSync(fd, line, undefined, "utf8");
296
+ fsyncSync(fd);
297
+ } finally {
298
+ closeSync(fd);
299
+ }
300
+ }
301
+
302
+ function emitSessionAccumulated(taskId, session, totals) {
303
+ if (!taskId || SESSION_ACCUMULATION_LISTENERS.size === 0) return;
304
+ const payload = {
305
+ type: "session-accumulated",
306
+ taskId,
307
+ session: { ...session },
308
+ totals: cloneLifetimeTotals(totals),
309
+ ts: Date.now(),
310
+ };
311
+ for (const listener of SESSION_ACCUMULATION_LISTENERS) {
312
+ try {
313
+ listener(payload);
314
+ } catch {
315
+ /* best effort */
316
+ }
317
+ }
318
+ }
319
+
320
+ export function addSessionAccumulationListener(listener) {
321
+ if (typeof listener !== "function") return () => {};
322
+ SESSION_ACCUMULATION_LISTENERS.add(listener);
323
+ return () => SESSION_ACCUMULATION_LISTENERS.delete(listener);
324
+ }
325
+
84
326
  export function initRuntimeAccumulator() {
85
327
  loadState();
86
328
  _state.startedAt = Date.now();
329
+ syncGlobals();
87
330
 
88
- setInterval(saveState, SAVE_INTERVAL_MS);
89
- process.on("exit", saveState);
90
- process.on("SIGINT", saveState);
91
- process.on("SIGTERM", saveState);
331
+ if (!_saveHooksInstalled) {
332
+ _saveHooksInstalled = true;
333
+ setInterval(() => saveState(false), SAVE_INTERVAL_MS).unref?.();
334
+ process.on("exit", () => saveState(true));
335
+ process.on("SIGINT", () => saveState(true));
336
+ process.on("SIGTERM", () => saveState(true));
337
+ }
92
338
 
93
339
  return {
94
340
  runtimeMs: _state.runtimeMs,
@@ -104,71 +350,106 @@ export function getRuntimeStats() {
104
350
  totalCostUsd: _state.totalCostUsd,
105
351
  sessionCount: _state.completedSessions.length,
106
352
  startedAt: _state.startedAt,
353
+ lastUpdated: _state.lastUpdated,
107
354
  };
108
355
  }
109
356
 
110
357
  export function addRuntime(ms) {
111
358
  loadState();
112
- _state.runtimeMs += Number(ms) || 0;
113
- saveState();
359
+ _state.runtimeMs += Math.max(0, toFiniteNumber(ms, 0));
360
+ _state.lastUpdated = new Date().toISOString();
361
+ syncGlobals();
362
+ saveState(false);
114
363
  }
115
364
 
116
365
  export function addCost(usd) {
117
366
  loadState();
118
- _state.totalCostUsd += Number(usd) || 0;
119
- saveState();
367
+ _state.totalCostUsd += Math.max(0, toFiniteNumber(usd, 0));
368
+ _state.lastUpdated = new Date().toISOString();
369
+ syncGlobals();
370
+ saveState(false);
120
371
  }
121
372
 
122
373
  export function addCompletedSession(session) {
123
374
  loadState();
124
- const token = {
125
- id: session.id || session.sessionId,
126
- taskId: session.taskId,
127
- taskTitle: session.taskTitle,
128
- executor: session.executor,
129
- model: session.model,
130
- startedAt: session.startedAt,
131
- endedAt: session.endedAt || Date.now(),
132
- durationMs: session.durationMs || (session.endedAt ? session.endedAt - session.startedAt : 0),
133
- costUsd: session.costUsd || 0,
134
- status: session.status || "completed",
135
- };
136
- _state.completedSessions.push(token);
137
-
138
- if (token.costUsd) {
139
- _state.totalCostUsd += token.costUsd;
375
+ const record = normalizeCompletedSession(session);
376
+ if (!record.taskId) return record;
377
+ const dedupKey = buildSessionDedupKey(record);
378
+ if (dedupKey && _seenSessionKeys.has(dedupKey)) {
379
+ return record;
140
380
  }
141
- if (token.durationMs) {
142
- _state.runtimeMs += token.durationMs;
143
- }
144
-
145
- _state.sessionTokens.push({
146
- id: token.id,
147
- expiresAt: Date.now() + 24 * 60 * 60 * 1000,
148
- });
149
381
 
150
- saveState();
151
- return token;
382
+ appendCompletedSessionRecord(record);
383
+ const totals = applyCompletedSessionRecord(record);
384
+ saveState(true);
385
+ emitSessionAccumulated(record.taskId, record, totals);
386
+ return record;
152
387
  }
153
388
 
154
389
  export function getSessionTokens() {
155
390
  loadState();
156
391
  const now = Date.now();
157
392
  return _state.sessionTokens
158
- .filter((t) => t.expiresAt > now)
159
- .map((t) => t.id);
393
+ .filter((token) => toFiniteNumber(token?.expiresAt, 0) > now)
394
+ .map((token) => token.id);
160
395
  }
161
396
 
162
397
  export function getCompletedSessions(limit = 100) {
163
398
  loadState();
164
- return _state.completedSessions.slice(-limit);
399
+ return _state.completedSessions
400
+ .slice(-Math.max(0, Number(limit) || 0))
401
+ .map((entry) => ({ ...entry }));
402
+ }
403
+
404
+ export function getTaskLifetimeTotals(taskId) {
405
+ loadState();
406
+ const normalizedTaskId = String(taskId || "").trim();
407
+ if (!normalizedTaskId) return null;
408
+ const totals = _state.taskLifetimeTotals[normalizedTaskId];
409
+ if (!totals) {
410
+ return {
411
+ taskId: normalizedTaskId,
412
+ taskTitle: null,
413
+ attemptsCount: 0,
414
+ tokenCount: 0,
415
+ inputTokens: 0,
416
+ outputTokens: 0,
417
+ durationMs: 0,
418
+ lastSessionId: null,
419
+ lastSessionEndedAt: null,
420
+ lastStatus: null,
421
+ updatedAt: null,
422
+ };
423
+ }
424
+ return cloneLifetimeTotals(totals);
425
+ }
426
+
427
+ export function withTaskLifetimeTotals(task) {
428
+ if (!task || typeof task !== "object") return task;
429
+ const taskId = String(task.id || task.taskId || "").trim();
430
+ if (!taskId) return task;
431
+ const lifetimeTotals = getTaskLifetimeTotals(taskId);
432
+ return {
433
+ ...task,
434
+ lifetimeTotals,
435
+ meta: {
436
+ ...(task.meta || {}),
437
+ lifetimeTotals,
438
+ },
439
+ };
165
440
  }
166
441
 
167
442
  export function clearCompletedSessions() {
168
443
  loadState();
169
- _state.completedSessions = [];
170
- _state.sessionTokens = [];
171
- saveState();
444
+ _state = cloneDefaultState();
445
+ _seenSessionKeys = new Set();
446
+ try {
447
+ unlinkSync(_sessionLogFile);
448
+ } catch {
449
+ /* best effort */
450
+ }
451
+ syncGlobals();
452
+ saveState(true);
172
453
  }
173
454
 
174
455
  export function exportRuntimeData() {
@@ -179,28 +460,39 @@ export function exportRuntimeData() {
179
460
  sessionCount: _state.completedSessions.length,
180
461
  startedAt: _state.startedAt,
181
462
  lastUpdated: _state.lastUpdated,
182
- sessions: _state.completedSessions,
463
+ taskLifetimeTotals: { ..._state.taskLifetimeTotals },
464
+ sessions: _state.completedSessions.map((entry) => ({ ...entry })),
183
465
  };
184
466
  }
185
467
 
186
468
  export function importRuntimeData(data) {
187
469
  if (!data || typeof data !== "object") return false;
188
-
189
470
  loadState();
190
-
471
+ if (Array.isArray(data.sessions)) {
472
+ for (const session of data.sessions) {
473
+ addCompletedSession(session);
474
+ }
475
+ }
191
476
  if (typeof data.runtimeMs === "number") {
192
477
  _state.runtimeMs = Math.max(_state.runtimeMs, data.runtimeMs);
193
478
  }
194
479
  if (typeof data.totalCostUsd === "number") {
195
480
  _state.totalCostUsd = Math.max(_state.totalCostUsd, data.totalCostUsd);
196
481
  }
197
- if (Array.isArray(data.sessions)) {
198
- for (const session of data.sessions) {
199
- _state.completedSessions.push(session);
200
- }
201
- _state.completedSessions = _state.completedSessions.slice(-500);
202
- }
203
-
204
- saveState();
482
+ syncGlobals();
483
+ saveState(true);
205
484
  return true;
206
485
  }
486
+
487
+ export function getSessionAccumulatorLogPath() {
488
+ return _sessionLogFile;
489
+ }
490
+
491
+ export function _resetRuntimeAccumulatorForTests(options = {}) {
492
+ configureCachePaths(options.cacheDir || DEFAULT_CACHE_DIR);
493
+ _state = cloneDefaultState();
494
+ _initialized = false;
495
+ _lastSaveTime = 0;
496
+ _seenSessionKeys = new Set();
497
+ syncGlobals();
498
+ }