@roj-ai/sdk 0.1.32 → 0.1.34

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 (53) hide show
  1. package/dist/bootstrap.d.ts +3 -0
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/bootstrap.js +5 -0
  4. package/dist/bootstrap.js.map +1 -1
  5. package/dist/core/sessions/session-manager.d.ts +3 -0
  6. package/dist/core/sessions/session-manager.d.ts.map +1 -1
  7. package/dist/core/sessions/session-manager.js +3 -1
  8. package/dist/core/sessions/session-manager.js.map +1 -1
  9. package/dist/core/system.d.ts +2 -0
  10. package/dist/core/system.d.ts.map +1 -1
  11. package/dist/core/system.js +2 -1
  12. package/dist/core/system.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/plugins/services/pid-registry.d.ts +72 -0
  16. package/dist/plugins/services/pid-registry.d.ts.map +1 -0
  17. package/dist/plugins/services/pid-registry.js +171 -0
  18. package/dist/plugins/services/pid-registry.js.map +1 -0
  19. package/dist/plugins/services/pid-registry.test.d.ts +2 -0
  20. package/dist/plugins/services/pid-registry.test.d.ts.map +1 -0
  21. package/dist/plugins/services/pid-registry.test.js +161 -0
  22. package/dist/plugins/services/pid-registry.test.js.map +1 -0
  23. package/dist/plugins/services/plugin.d.ts +3 -0
  24. package/dist/plugins/services/plugin.d.ts.map +1 -1
  25. package/dist/plugins/services/plugin.js +5 -1
  26. package/dist/plugins/services/plugin.js.map +1 -1
  27. package/dist/plugins/services/service.d.ts +4 -0
  28. package/dist/plugins/services/service.d.ts.map +1 -1
  29. package/dist/plugins/services/service.js +8 -0
  30. package/dist/plugins/services/service.js.map +1 -1
  31. package/dist/plugins/session-stats/index.d.ts +1 -1
  32. package/dist/plugins/session-stats/index.d.ts.map +1 -1
  33. package/dist/plugins/session-stats/plugin.d.ts +16 -0
  34. package/dist/plugins/session-stats/plugin.d.ts.map +1 -1
  35. package/dist/plugins/session-stats/plugin.js +29 -2
  36. package/dist/plugins/session-stats/plugin.js.map +1 -1
  37. package/dist/plugins/session-stats/plugin.test.d.ts +2 -0
  38. package/dist/plugins/session-stats/plugin.test.d.ts.map +1 -0
  39. package/dist/plugins/session-stats/plugin.test.js +145 -0
  40. package/dist/plugins/session-stats/plugin.test.js.map +1 -0
  41. package/dist/transport/http/middleware/error-handler.d.ts +1 -1
  42. package/package.json +2 -2
  43. package/src/bootstrap.ts +7 -0
  44. package/src/core/sessions/session-manager.ts +5 -1
  45. package/src/core/system.ts +4 -0
  46. package/src/index.ts +1 -1
  47. package/src/plugins/services/pid-registry.test.ts +180 -0
  48. package/src/plugins/services/pid-registry.ts +191 -0
  49. package/src/plugins/services/plugin.ts +8 -1
  50. package/src/plugins/services/service.ts +12 -0
  51. package/src/plugins/session-stats/index.ts +1 -1
  52. package/src/plugins/session-stats/plugin.test.ts +160 -0
  53. package/src/plugins/session-stats/plugin.ts +48 -2
@@ -0,0 +1,191 @@
1
+ /**
2
+ * ServicePidRegistry — on-disk record of the service processes this host has spawned,
3
+ * so a later agent can find and kill the ones nobody owns any more.
4
+ *
5
+ * The session projection cannot do this job. It remembers one pid per service type, so
6
+ * every start overwrites the previous generation, and its reconcile runs from
7
+ * `onSessionReady` — which fires lazily, when a session is loaded. A session nobody
8
+ * reopens after a restart therefore never reconciles at all, and its dev server keeps
9
+ * running until the box runs out of memory. That is not theoretical: a production
10
+ * sandbox on 2026-08-05 held four dev servers at ~500 MB each across three sessions on
11
+ * 4 GB with no swap, and the kernel OOM-killer fired repeatedly.
12
+ *
13
+ * A file per (session, service type) survives both an agent restart and a VM
14
+ * pause/resume, and is enumerable without knowing which sessions exist — which is what
15
+ * makes a host-wide sweep at boot possible.
16
+ */
17
+
18
+ import { join } from 'node:path'
19
+ import type { FileSystem } from '~/platform/fs.js'
20
+ import type { Logger } from '../../lib/logger/logger.js'
21
+ import { getProcessStartTime } from './service.js'
22
+
23
+ /**
24
+ * What we need in order to decide, later and from another process, whether a recorded
25
+ * pid may be killed.
26
+ *
27
+ * `pidStartTime` is /proc's start time in clock ticks: paired with the pid it identifies
28
+ * one process, so a mismatch means the kernel recycled the pid and killing would hit an
29
+ * unrelated process. `agentPid`/`agentStartTime` identify the agent that spawned it — a
30
+ * booting agent must not kill the services of another agent that is still running.
31
+ */
32
+ export interface ServicePidRecord {
33
+ sessionId: string
34
+ serviceType: string
35
+ pid: number
36
+ pidStartTime?: number
37
+ command: string
38
+ agentPid: number
39
+ agentStartTime?: number
40
+ }
41
+
42
+ const REGISTRY_DIR = 'service-pids'
43
+
44
+ export class ServicePidRegistry {
45
+ constructor(
46
+ private readonly fs: FileSystem,
47
+ private readonly logger: Logger,
48
+ /** Agent data directory. Never the session workspace — that is a git worktree. */
49
+ private readonly dataPath: string,
50
+ ) {}
51
+
52
+ private get root(): string {
53
+ return join(this.dataPath, REGISTRY_DIR)
54
+ }
55
+
56
+ private recordPath(sessionId: string, serviceType: string): string {
57
+ // Service types are plugin-configured identifiers (`astro-dev`, `cms-sidecar`) and
58
+ // session ids are uuids, so neither needs escaping to be a safe file name.
59
+ return join(this.root, `${sessionId}__${serviceType}.json`)
60
+ }
61
+
62
+ /**
63
+ * Record a freshly spawned service process. Best-effort: failing to record costs us a
64
+ * later sweep, so it must never take down the start that just succeeded.
65
+ */
66
+ async record(record: Omit<ServicePidRecord, 'agentPid' | 'agentStartTime'>): Promise<void> {
67
+ try {
68
+ await this.fs.mkdir(this.root, { recursive: true })
69
+ const full: ServicePidRecord = {
70
+ ...record,
71
+ agentPid: process.pid,
72
+ agentStartTime: await getProcessStartTime(this.fs, process.pid),
73
+ }
74
+ await this.fs.writeFile(this.recordPath(record.sessionId, record.serviceType), JSON.stringify(full))
75
+ } catch (error) {
76
+ this.logger.debug('Could not record service pid', { sessionId: record.sessionId, serviceType: record.serviceType, error })
77
+ }
78
+ }
79
+
80
+ /** Drop the record for a service whose process is gone. */
81
+ async forget(sessionId: string, serviceType: string): Promise<void> {
82
+ try {
83
+ await this.fs.unlink(this.recordPath(sessionId, serviceType))
84
+ } catch {
85
+ // Already gone, or never recorded.
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Kill every recorded service process that no live agent owns, and clear its record.
91
+ *
92
+ * Meant to run once at agent boot, before any session loads: at that moment this agent
93
+ * tracks nothing, so every survivor of a previous incarnation is by definition an
94
+ * orphan. Records left by an agent that is still running are skipped — during an
95
+ * overlapping restart the outgoing agent's services are still its own.
96
+ */
97
+ async sweepOrphans(): Promise<void> {
98
+ let files: string[]
99
+ try {
100
+ files = await this.fs.readdir(this.root)
101
+ } catch {
102
+ return // No registry yet — nothing has ever been spawned here.
103
+ }
104
+
105
+ for (const file of files) {
106
+ if (!file.endsWith('.json')) continue
107
+ const path = join(this.root, file)
108
+ const record = await this.readRecord(path)
109
+ if (!record) {
110
+ await this.fs.unlink(path).catch(() => {})
111
+ continue
112
+ }
113
+
114
+ if (await this.isAgentAlive(record)) {
115
+ this.logger.debug('Leaving service to its still-running agent', {
116
+ sessionId: record.sessionId,
117
+ serviceType: record.serviceType,
118
+ agentPid: record.agentPid,
119
+ })
120
+ continue
121
+ }
122
+
123
+ await this.killIfStillOurs(record)
124
+ await this.fs.unlink(path).catch(() => {})
125
+ }
126
+ }
127
+
128
+ private async readRecord(path: string): Promise<ServicePidRecord | null> {
129
+ try {
130
+ const parsed: unknown = JSON.parse(await this.fs.readFile(path, 'utf-8'))
131
+ if (typeof parsed !== 'object' || parsed === null) return null
132
+ const record = parsed as Partial<ServicePidRecord>
133
+ if (typeof record.pid !== 'number' || typeof record.sessionId !== 'string' || typeof record.serviceType !== 'string') {
134
+ return null
135
+ }
136
+ return {
137
+ sessionId: record.sessionId,
138
+ serviceType: record.serviceType,
139
+ pid: record.pid,
140
+ pidStartTime: record.pidStartTime,
141
+ command: typeof record.command === 'string' ? record.command : '',
142
+ agentPid: typeof record.agentPid === 'number' ? record.agentPid : 0,
143
+ agentStartTime: record.agentStartTime,
144
+ }
145
+ } catch {
146
+ return null
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Whether a live agent still owns this record. Ourselves counts: a record carrying our own
152
+ * pid describes a service this very process started, which the sweep must never touch —
153
+ * that is what makes the sweep safe to run at any time, not only before the first start.
154
+ */
155
+ private async isAgentAlive(record: ServicePidRecord): Promise<boolean> {
156
+ if (record.agentPid === process.pid) return true
157
+ if (record.agentPid === 0) return false
158
+ const currentStartTime = await getProcessStartTime(this.fs, record.agentPid)
159
+ if (currentStartTime === undefined) return false
160
+ return record.agentStartTime === undefined || currentStartTime === record.agentStartTime
161
+ }
162
+
163
+ private async killIfStillOurs(record: ServicePidRecord): Promise<void> {
164
+ const currentStartTime = await getProcessStartTime(this.fs, record.pid)
165
+ if (currentStartTime === undefined) return // Process is gone.
166
+ if (record.pidStartTime !== undefined && currentStartTime !== record.pidStartTime) {
167
+ this.logger.warn('PID reuse detected during orphan sweep — refusing to kill', {
168
+ sessionId: record.sessionId,
169
+ serviceType: record.serviceType,
170
+ pid: record.pid,
171
+ storedStartTime: record.pidStartTime,
172
+ currentStartTime,
173
+ })
174
+ return
175
+ }
176
+
177
+ try {
178
+ // Negative pid: services are spawned detached, so this takes the whole group —
179
+ // the `/bin/sh -c` wrapper and the runtime it exec'd underneath it.
180
+ process.kill(-record.pid, 'SIGKILL')
181
+ this.logger.info('Killed orphaned service process group', {
182
+ sessionId: record.sessionId,
183
+ serviceType: record.serviceType,
184
+ pid: record.pid,
185
+ command: record.command,
186
+ })
187
+ } catch (error) {
188
+ this.logger.debug('Orphaned service process already gone', { pid: record.pid, error })
189
+ }
190
+ }
191
+ }
@@ -8,6 +8,7 @@ import { Err, Ok } from '~/lib/utils/result.js'
8
8
  import type { PortPool } from './port-pool.js'
9
9
  import { buildServiceStatusMessage } from './prompt.js'
10
10
  import type { ServiceConfig, ServiceEntry } from './schema.js'
11
+ import type { ServicePidRegistry } from './pid-registry.js'
11
12
  import { getProcessStartTime, ServiceExecutor } from './service.js'
12
13
 
13
14
  export const serviceEvents = createEventsFactory({
@@ -33,6 +34,8 @@ export type ServiceStatusChangedEvent = (typeof serviceEvents)['Events']['servic
33
34
  export interface ServicePluginConfig {
34
35
  services: ServiceConfig[]
35
36
  portPool: PortPool
37
+ /** Durable pid record swept at agent boot. Absent for embedders without a data dir. */
38
+ pidRegistry?: ServicePidRegistry
36
39
  }
37
40
 
38
41
  /**
@@ -133,7 +136,11 @@ export const servicePlugin = definePlugin('services')
133
136
  },
134
137
  })
135
138
  .context(async (ctx, pluginConfig) => {
136
- const executor = new ServiceExecutor(ctx.logger, pluginConfig.portPool, { fs: ctx.platform.fs, process: ctx.platform.process })
139
+ const executor = new ServiceExecutor(ctx.logger, pluginConfig.portPool, {
140
+ fs: ctx.platform.fs,
141
+ process: ctx.platform.process,
142
+ pidRegistry: pluginConfig.pidRegistry,
143
+ })
137
144
  executor.onStatusChanged = (sessionId, serviceType, status, details) => {
138
145
  void ctx.emitEvent(serviceEvents.create('service_status_changed', {
139
146
  serviceType,
@@ -13,6 +13,7 @@ import { Err, Ok } from '~/lib/utils/result.js'
13
13
  import type { FileSystem } from '~/platform/fs.js'
14
14
  import type { ChildProcess, ProcessRunner } from '~/platform/process.js'
15
15
  import type { PortPool } from '~/plugins/services/port-pool.js'
16
+ import type { ServicePidRegistry } from '~/plugins/services/pid-registry.js'
16
17
  import type { ServiceConfig, ServiceStartArgs, ServiceStatus } from '~/plugins/services/schema.js'
17
18
  import type { ToolError } from '../../core/tools/executor.js'
18
19
  import type { Logger } from '../../lib/logger/logger.js'
@@ -89,6 +90,8 @@ const MAX_PORT_CONFLICT_RETRIES = 3
89
90
  export interface ServiceExecutorDeps {
90
91
  fs: FileSystem
91
92
  process: ProcessRunner
93
+ /** Durable pid record, swept at agent boot. Optional so embedders without a data dir still work. */
94
+ pidRegistry?: ServicePidRegistry
92
95
  }
93
96
 
94
97
  export class ServiceExecutor {
@@ -103,6 +106,7 @@ export class ServiceExecutor {
103
106
  private readonly portPool: PortPool
104
107
  private readonly fs: FileSystem
105
108
  private readonly processRunner: ProcessRunner
109
+ private readonly pidRegistry?: ServicePidRegistry
106
110
 
107
111
  /** Optional callback invoked on every service status change */
108
112
  onStatusChanged?: (
@@ -117,6 +121,7 @@ export class ServiceExecutor {
117
121
  this.portPool = portPool
118
122
  this.fs = deps.fs
119
123
  this.processRunner = deps.process
124
+ this.pidRegistry = deps.pidRegistry
120
125
  }
121
126
 
122
127
  private notifyStatusChanged(
@@ -354,6 +359,11 @@ export class ServiceExecutor {
354
359
  // "our process" from "an unrelated process that grabbed this PID after ours died"
355
360
  const pidStartTime = await getProcessStartTime(this.fs, child.pid)
356
361
 
362
+ // Durably record the process so a future agent can reap it if we die before the
363
+ // `close` handler gets to forget it. The session projection cannot serve that
364
+ // purpose — see ServicePidRegistry.
365
+ await this.pidRegistry?.record({ sessionId: String(sessionId), serviceType: config.type, pid: child.pid, pidStartTime, command })
366
+
357
367
  // Emit starting event with PID, port, resolved cwd/command, and start time.
358
368
  this.notifyStatusChanged(sessionId, config.type, 'starting', { port, pid: child.pid, pidStartTime, cwd, command })
359
369
 
@@ -512,6 +522,8 @@ export class ServiceExecutor {
512
522
  // Handle unexpected exit
513
523
  child.on('close', (code) => {
514
524
  clearReadinessTimers()
525
+ // The process is gone, so its durable record has nothing left to reap.
526
+ void this.pidRegistry?.forget(String(sessionId), config.type)
515
527
  // Flush remaining partial lines
516
528
  if (stdoutPartial) {
517
529
  processLine(stdoutPartial)
@@ -1,3 +1,3 @@
1
1
  export { sessionStatsPlugin } from './plugin.js'
2
- export type { SessionStatsState } from './plugin.js'
2
+ export type { AgentStats, ProviderStats, SessionStatsState } from './plugin.js'
3
3
  export { selectSessionStats } from './plugin.js'
@@ -0,0 +1,160 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { AgentId } from '~/core/agents/schema.js'
3
+ import { agentEvents } from '~/core/agents/state.js'
4
+ import { contextEvents } from '~/core/context/state.js'
5
+ import { withSessionId } from '~/core/events/test-helpers.js'
6
+ import type { DomainEvent } from '~/core/events/types.js'
7
+ import type { LLMMetrics } from '~/core/llm/state.js'
8
+ import { llmEvents } from '~/core/llm/state.js'
9
+ import { createApplyEvent } from '~/core/sessions/apply-event.js'
10
+ import { SessionId } from '~/core/sessions/schema.js'
11
+ import { createSessionState } from '~/core/sessions/state.js'
12
+ import { ToolCallId } from '~/core/tools/schema.js'
13
+ import { toolEvents } from '~/core/tools/state.js'
14
+ import { userChatEvents } from '~/plugins/user-chat/plugin.js'
15
+ import { ChatMessageId } from '~/plugins/user-chat/schema.js'
16
+ import { selectSessionStats, sessionStatsPlugin } from './plugin.js'
17
+
18
+ const sessionId = SessionId('session-stats-test')
19
+ const orchestrator = AgentId('orchestrator-1')
20
+ const worker = AgentId('worker-1')
21
+
22
+ const applyEvent = createApplyEvent([sessionStatsPlugin.create()])
23
+
24
+ const metrics = (overrides: Partial<LLMMetrics> = {}): LLMMetrics => ({
25
+ promptTokens: 100,
26
+ completionTokens: 20,
27
+ totalTokens: 120,
28
+ latencyMs: 10,
29
+ model: 'test-model',
30
+ provider: 'anthropic',
31
+ cost: 0.5,
32
+ ...overrides,
33
+ })
34
+
35
+ const spawn = (agentId: AgentId, definitionName: string) =>
36
+ withSessionId(sessionId, agentEvents.create('agent_spawned', { agentId, definitionName, parentId: null }))
37
+
38
+ const inference = (agentId: AgentId, m: LLMMetrics) =>
39
+ withSessionId(sessionId, llmEvents.create('inference_completed', {
40
+ agentId,
41
+ consumedMessageIds: [],
42
+ response: { content: 'ok', toolCalls: [] },
43
+ metrics: m,
44
+ }))
45
+
46
+ const reduce = (events: DomainEvent[]) => selectSessionStats(events.reduce(applyEvent, createSessionState(sessionId, 'test-preset', 0)))
47
+
48
+ describe('session-stats plugin', () => {
49
+ it('starts from zeroed counters', () => {
50
+ const stats = reduce([])
51
+ expect(stats.userMessages).toBe(0)
52
+ expect(stats.compactions).toBe(0)
53
+ expect(stats.cacheReadTokens).toBe(0)
54
+ expect(stats.cacheWriteTokens).toBe(0)
55
+ expect(stats.byAgent).toEqual({})
56
+ })
57
+
58
+ it('counts user chat messages', () => {
59
+ const stats = reduce([
60
+ spawn(orchestrator, 'orchestrator'),
61
+ withSessionId(sessionId, userChatEvents.create('user_chat_message_received', {
62
+ agentId: orchestrator,
63
+ messageId: ChatMessageId('m1'),
64
+ content: 'hello',
65
+ timestamp: 1,
66
+ })),
67
+ withSessionId(sessionId, userChatEvents.create('user_chat_message_received', {
68
+ agentId: orchestrator,
69
+ messageId: ChatMessageId('m2'),
70
+ content: 'again',
71
+ timestamp: 2,
72
+ })),
73
+ ])
74
+ expect(stats.userMessages).toBe(2)
75
+ })
76
+
77
+ it('counts context compactions', () => {
78
+ const stats = reduce([
79
+ spawn(orchestrator, 'orchestrator'),
80
+ withSessionId(sessionId, contextEvents.create('context_compacted', {
81
+ agentId: orchestrator,
82
+ compactedContent: 'summary',
83
+ newConversationHistory: [],
84
+ originalTokens: 1000,
85
+ compactedTokens: 100,
86
+ messagesRemoved: 8,
87
+ })),
88
+ ])
89
+ expect(stats.compactions).toBe(1)
90
+ })
91
+
92
+ it('sums cache read and write tokens across main and auxiliary inferences', () => {
93
+ const stats = reduce([
94
+ spawn(orchestrator, 'orchestrator'),
95
+ inference(orchestrator, metrics({ cachedTokens: 800, cacheWriteTokens: 200 })),
96
+ withSessionId(sessionId, llmEvents.create('auxiliary_inference_completed', {
97
+ agentId: orchestrator,
98
+ metrics: metrics({ cachedTokens: 50, cacheWriteTokens: 10 }),
99
+ })),
100
+ // Providers that report no cache usage must not break the totals.
101
+ inference(orchestrator, metrics()),
102
+ ])
103
+ expect(stats.cacheReadTokens).toBe(850)
104
+ expect(stats.cacheWriteTokens).toBe(210)
105
+ expect(stats.llmCalls).toBe(3)
106
+ })
107
+
108
+ it('breaks LLM usage down by agent definition name', () => {
109
+ const stats = reduce([
110
+ spawn(orchestrator, 'orchestrator'),
111
+ spawn(worker, 'coder'),
112
+ inference(orchestrator, metrics({ promptTokens: 100, completionTokens: 20, cost: 0.5 })),
113
+ inference(worker, metrics({ promptTokens: 300, completionTokens: 40, cost: 1.25 })),
114
+ inference(worker, metrics({ promptTokens: 200, completionTokens: 10, cost: 0.25 })),
115
+ withSessionId(sessionId, llmEvents.create('auxiliary_inference_completed', {
116
+ agentId: worker,
117
+ metrics: metrics({ promptTokens: 10, completionTokens: 1, cost: 0.05 }),
118
+ })),
119
+ ])
120
+ expect(stats.byAgent).toEqual({
121
+ orchestrator: { llmCalls: 1, promptTokens: 100, completionTokens: 20, totalCost: 0.5 },
122
+ coder: { llmCalls: 3, promptTokens: 510, completionTokens: 51, totalCost: 1.55 },
123
+ })
124
+ expect(stats.totalCost).toBeCloseTo(2.05, 10)
125
+ })
126
+
127
+ it('keeps existing counters working', () => {
128
+ const stats = reduce([
129
+ spawn(orchestrator, 'orchestrator'),
130
+ inference(orchestrator, metrics()),
131
+ withSessionId(sessionId, llmEvents.create('inference_failed', { agentId: orchestrator, error: 'boom' })),
132
+ withSessionId(sessionId, toolEvents.create('tool_started', {
133
+ agentId: orchestrator,
134
+ toolCallId: ToolCallId('tc-1'),
135
+ toolName: 'read_file',
136
+ input: {},
137
+ })),
138
+ withSessionId(sessionId, toolEvents.create('tool_failed', {
139
+ agentId: orchestrator,
140
+ toolCallId: ToolCallId('tc-1'),
141
+ error: 'ENOENT',
142
+ })),
143
+ ])
144
+ expect(stats.agentCount).toBe(1)
145
+ expect(stats.llmCalls).toBe(1)
146
+ expect(stats.llmErrors).toBe(1)
147
+ expect(stats.toolCalls).toBe(1)
148
+ expect(stats.toolErrors).toBe(1)
149
+ expect(stats.totalTokens).toBe(120)
150
+ expect(stats.promptTokens).toBe(100)
151
+ expect(stats.completionTokens).toBe(20)
152
+ expect(stats.byProvider.anthropic).toEqual({
153
+ llmCalls: 1,
154
+ totalTokens: 120,
155
+ promptTokens: 100,
156
+ completionTokens: 20,
157
+ totalCost: 0.5,
158
+ })
159
+ })
160
+ })
@@ -1,9 +1,11 @@
1
1
  import { agentEvents } from '~/core/agents/state.js'
2
+ import { contextEvents } from '~/core/context/state.js'
2
3
  import { llmEvents } from '~/core/llm/state.js'
3
4
  import { definePlugin } from '~/core/plugins/plugin-builder.js'
4
5
  import { selectPluginState } from '~/core/sessions/reducer.js'
5
6
  import type { SessionState } from '~/core/sessions/state.js'
6
7
  import { toolEvents } from '~/core/tools/state.js'
8
+ import { userChatEvents } from '~/plugins/user-chat/plugin.js'
7
9
 
8
10
  // ============================================================================
9
11
  // Session stats state
@@ -17,35 +19,57 @@ export interface ProviderStats {
17
19
  totalCost: number
18
20
  }
19
21
 
22
+ export interface AgentStats {
23
+ llmCalls: number
24
+ promptTokens: number
25
+ completionTokens: number
26
+ totalCost: number
27
+ }
28
+
20
29
  export interface SessionStatsState {
21
30
  totalTokens: number
22
31
  promptTokens: number
23
32
  completionTokens: number
33
+ /** Prompt tokens served from the provider cache (a subset of promptTokens) */
34
+ cacheReadTokens: number
35
+ /** Prompt tokens written to the provider cache */
36
+ cacheWriteTokens: number
24
37
  totalCost: number
25
38
  llmCalls: number
26
39
  llmErrors: number
27
40
  toolCalls: number
28
41
  toolErrors: number
42
+ /** Chat messages received from the user */
43
+ userMessages: number
44
+ /** Context compactions performed in this session */
45
+ compactions: number
29
46
  agentCount: number
30
47
  firstEventAt: number | null
31
48
  lastEventAt: number | null
32
49
  /** Per-provider breakdown of LLM usage */
33
50
  byProvider: Record<string, ProviderStats>
51
+ /** Per agent-definition breakdown of LLM usage (keyed by definition name, not agent id) */
52
+ byAgent: Record<string, AgentStats>
34
53
  }
35
54
 
36
55
  const createInitialStats = (): SessionStatsState => ({
37
56
  totalTokens: 0,
38
57
  promptTokens: 0,
39
58
  completionTokens: 0,
59
+ cacheReadTokens: 0,
60
+ cacheWriteTokens: 0,
40
61
  totalCost: 0,
41
62
  llmCalls: 0,
42
63
  llmErrors: 0,
43
64
  toolCalls: 0,
44
65
  toolErrors: 0,
66
+ userMessages: 0,
67
+ compactions: 0,
45
68
  agentCount: 0,
46
69
  firstEventAt: null,
47
70
  lastEventAt: null,
48
71
  byProvider: {},
72
+ byAgent: {},
49
73
  })
50
74
 
51
75
  const PLUGIN_STATE_KEY = 'sessionStats'
@@ -62,11 +86,11 @@ export function selectSessionStats(sessionState: SessionState): SessionStatsStat
62
86
  // ============================================================================
63
87
 
64
88
  export const sessionStatsPlugin = definePlugin('session-stats')
65
- .events([agentEvents, llmEvents, toolEvents])
89
+ .events([agentEvents, llmEvents, toolEvents, contextEvents, userChatEvents])
66
90
  .state({
67
91
  key: PLUGIN_STATE_KEY,
68
92
  initial: createInitialStats,
69
- reduce: (stats, event) => {
93
+ reduce: (stats, event, sessionState) => {
70
94
  const now = event.timestamp
71
95
  const withTimestamp = (partial: Partial<SessionStatsState>): SessionStatsState => ({
72
96
  ...stats,
@@ -97,13 +121,29 @@ export const sessionStatsPlugin = definePlugin('session-stats')
97
121
  },
98
122
  }
99
123
  : stats.byProvider
124
+ // Core reducer runs before plugin slices, so the spawning agent is already in state.
125
+ const definitionName = sessionState.agents.get(event.agentId)?.definitionName
126
+ const byAgent = definitionName
127
+ ? {
128
+ ...stats.byAgent,
129
+ [definitionName]: {
130
+ llmCalls: (stats.byAgent[definitionName]?.llmCalls ?? 0) + 1,
131
+ promptTokens: (stats.byAgent[definitionName]?.promptTokens ?? 0) + event.metrics.promptTokens,
132
+ completionTokens: (stats.byAgent[definitionName]?.completionTokens ?? 0) + event.metrics.completionTokens,
133
+ totalCost: (stats.byAgent[definitionName]?.totalCost ?? 0) + (event.metrics.cost ?? 0),
134
+ },
135
+ }
136
+ : stats.byAgent
100
137
  return withTimestamp({
101
138
  llmCalls: stats.llmCalls + 1,
102
139
  totalTokens: stats.totalTokens + event.metrics.totalTokens,
103
140
  promptTokens: stats.promptTokens + event.metrics.promptTokens,
104
141
  completionTokens: stats.completionTokens + event.metrics.completionTokens,
142
+ cacheReadTokens: stats.cacheReadTokens + (event.metrics.cachedTokens ?? 0),
143
+ cacheWriteTokens: stats.cacheWriteTokens + (event.metrics.cacheWriteTokens ?? 0),
105
144
  totalCost: stats.totalCost + (event.metrics.cost ?? 0),
106
145
  byProvider,
146
+ byAgent,
107
147
  })
108
148
  }
109
149
 
@@ -116,6 +156,12 @@ export const sessionStatsPlugin = definePlugin('session-stats')
116
156
  case 'tool_failed':
117
157
  return withTimestamp({ toolErrors: stats.toolErrors + 1 })
118
158
 
159
+ case 'user_chat_message_received':
160
+ return withTimestamp({ userMessages: stats.userMessages + 1 })
161
+
162
+ case 'context_compacted':
163
+ return withTimestamp({ compactions: stats.compactions + 1 })
164
+
119
165
  default:
120
166
  return stats
121
167
  }