@techdivision/opencode-time-tracking 0.3.1 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techdivision/opencode-time-tracking",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Automatic time tracking plugin for OpenCode - tracks session duration and tool usage to CSV",
5
5
  "main": "src/Plugin.ts",
6
6
  "types": "src/Plugin.ts",
package/src/Plugin.ts CHANGED
@@ -13,6 +13,7 @@ import { ConfigLoader } from "./services/ConfigLoader"
13
13
  import { CsvWriter } from "./services/CsvWriter"
14
14
  import { SessionManager } from "./services/SessionManager"
15
15
  import { TicketExtractor } from "./services/TicketExtractor"
16
+ import { TicketResolver } from "./services/TicketResolver"
16
17
  import { createEventHook } from "./hooks/EventHook"
17
18
  import { createToolExecuteAfterHook } from "./hooks/ToolExecuteAfterHook"
18
19
 
@@ -61,13 +62,14 @@ export const plugin: Plugin = async ({
61
62
  const sessionManager = new SessionManager()
62
63
  const csvWriter = new CsvWriter(config, directory)
63
64
  const ticketExtractor = new TicketExtractor(client)
65
+ const ticketResolver = new TicketResolver(config, ticketExtractor)
64
66
 
65
67
  const hooks: Hooks = {
66
68
  "tool.execute.after": createToolExecuteAfterHook(
67
69
  sessionManager,
68
70
  ticketExtractor
69
71
  ),
70
- event: createEventHook(sessionManager, csvWriter, client),
72
+ event: createEventHook(sessionManager, csvWriter, client, ticketResolver),
71
73
  }
72
74
 
73
75
  return hooks
@@ -6,6 +6,7 @@ import type { AssistantMessage, Event, Message } from "@opencode-ai/sdk"
6
6
 
7
7
  import type { CsvWriter } from "../services/CsvWriter"
8
8
  import type { SessionManager } from "../services/SessionManager"
9
+ import type { TicketResolver } from "../services/TicketResolver"
9
10
  import type { MessagePartUpdatedProperties } from "../types/MessagePartUpdatedProperties"
10
11
  import type { MessageWithParts } from "../types/MessageWithParts"
11
12
  import type { OpencodeClient } from "../types/OpencodeClient"
@@ -83,10 +84,11 @@ async function extractSummaryTitle(
83
84
  export function createEventHook(
84
85
  sessionManager: SessionManager,
85
86
  csvWriter: CsvWriter,
86
- client: OpencodeClient
87
+ client: OpencodeClient,
88
+ ticketResolver: TicketResolver
87
89
  ) {
88
90
  return async ({ event }: { event: Event }): Promise<void> => {
89
- // Track model from assistant messages
91
+ // Track model and agent from assistant messages
90
92
  if (event.type === "message.updated") {
91
93
  const props = event.properties as MessageUpdatedProperties
92
94
  const message = props.info
@@ -94,29 +96,40 @@ export function createEventHook(
94
96
  if (message.role === "assistant") {
95
97
  const assistantMsg = message as AssistantMessage
96
98
 
99
+ // Ensure session exists for tracking
100
+ if (!sessionManager.has(assistantMsg.sessionID)) {
101
+ sessionManager.create(assistantMsg.sessionID, null)
102
+ }
103
+
104
+ // Track model
97
105
  if (assistantMsg.modelID && assistantMsg.providerID) {
98
106
  sessionManager.setModel(assistantMsg.sessionID, {
99
107
  modelID: assistantMsg.modelID,
100
108
  providerID: assistantMsg.providerID,
101
109
  })
102
110
  }
111
+
112
+ // Track agent from mode field
113
+ if (assistantMsg.mode) {
114
+ sessionManager.setAgent(assistantMsg.sessionID, assistantMsg.mode)
115
+ }
103
116
  }
104
117
 
105
118
  return
106
119
  }
107
120
 
108
- // Track token usage and agent from message part events
121
+ // Track token usage from message part events
109
122
  if (event.type === "message.part.updated") {
110
123
  const props = event.properties as MessagePartUpdatedProperties
111
124
  const part = props.part
112
125
 
113
- // Track agent from agent parts (only first agent is stored)
114
- if (part.type === "agent" && part.sessionID && part.name) {
115
- sessionManager.setAgent(part.sessionID, part.name)
116
- }
117
-
118
126
  // Track token usage from step-finish events
119
127
  if (part.type === "step-finish" && part.sessionID && part.tokens) {
128
+ // Ensure session exists for token tracking
129
+ if (!sessionManager.has(part.sessionID)) {
130
+ sessionManager.create(part.sessionID, null)
131
+ }
132
+
120
133
  sessionManager.addTokenUsage(part.sessionID, {
121
134
  input: part.tokens.input,
122
135
  output: part.tokens.output,
@@ -138,10 +151,17 @@ export function createEventHook(
138
151
  return
139
152
  }
140
153
 
141
- const session = sessionManager.get(sessionID)
154
+ // Atomically get and delete to prevent race conditions
155
+ const session = sessionManager.getAndDelete(sessionID)
142
156
 
143
- if (!session || session.activities.length === 0) {
144
- sessionManager.delete(sessionID)
157
+ // Check if session has any trackable data
158
+ const hasActivity = (session?.activities.length ?? 0) > 0
159
+ const hasTokens =
160
+ (session?.tokenUsage.input ?? 0) +
161
+ (session?.tokenUsage.output ?? 0) >
162
+ 0
163
+
164
+ if (!session || (!hasActivity && !hasTokens)) {
145
165
  return
146
166
  }
147
167
 
@@ -170,9 +190,13 @@ export function createEventHook(
170
190
  // Get agent name if available
171
191
  const agentString = session.agent?.name ?? null
172
192
 
193
+ // Resolve ticket and account key with fallback hierarchy
194
+ const resolved = await ticketResolver.resolve(sessionID, agentString)
195
+
173
196
  try {
174
197
  await csvWriter.write({
175
- ticket: session.ticket,
198
+ ticket: resolved.ticket,
199
+ accountKey: resolved.accountKey,
176
200
  startTime: session.startTime,
177
201
  endTime,
178
202
  durationSeconds,
@@ -187,7 +211,7 @@ export function createEventHook(
187
211
 
188
212
  await client.tui.showToast({
189
213
  body: {
190
- message: `Time tracked: ${minutes} min, ${totalTokens} tokens${session.ticket ? ` for ${session.ticket}` : ""}`,
214
+ message: `Time tracked: ${minutes} min, ${totalTokens} tokens${resolved.ticket ? ` for ${resolved.ticket}` : ""}`,
191
215
  variant: "success",
192
216
  },
193
217
  })
@@ -198,9 +222,7 @@ export function createEventHook(
198
222
  variant: "error",
199
223
  },
200
224
  })
201
- }
202
-
203
- sessionManager.delete(sessionID)
225
+ }
204
226
  }
205
227
  }
206
228
  }
@@ -113,7 +113,7 @@ export class CsvWriter {
113
113
  this.config.user_email,
114
114
  "",
115
115
  data.ticket ?? "",
116
- this.config.default_account_key,
116
+ data.accountKey,
117
117
  CsvFormatter.formatTime(data.startTime),
118
118
  CsvFormatter.formatTime(data.endTime),
119
119
  data.durationSeconds.toString(),
@@ -81,6 +81,27 @@ export class SessionManager {
81
81
  this.sessions.delete(sessionID)
82
82
  }
83
83
 
84
+ /**
85
+ * Retrieves and deletes a session atomically.
86
+ *
87
+ * @param sessionID - The OpenCode session identifier
88
+ * @returns The session data, or `undefined` if not found
89
+ *
90
+ * @remarks
91
+ * Prevents race conditions when multiple idle events fire
92
+ * for the same session. The session is removed immediately
93
+ * after retrieval to ensure it can only be processed once.
94
+ */
95
+ getAndDelete(sessionID: string): SessionData | undefined {
96
+ const session = this.sessions.get(sessionID)
97
+
98
+ if (session) {
99
+ this.sessions.delete(sessionID)
100
+ }
101
+
102
+ return session
103
+ }
104
+
84
105
  /**
85
106
  * Adds a tool activity to a session.
86
107
  *
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @fileoverview Resolves tickets and account keys with fallback hierarchy.
3
+ */
4
+
5
+ import type { ResolvedTicketInfo } from "../types/ResolvedTicketInfo"
6
+ import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
7
+ import type { TicketExtractor } from "./TicketExtractor"
8
+
9
+ /**
10
+ * Resolves tickets and account keys using fallback hierarchy.
11
+ *
12
+ * @remarks
13
+ * Ticket fallback hierarchy:
14
+ * 1. Context ticket (from messages/todos)
15
+ * 2. Agent default (from config)
16
+ * 3. Global default (from config)
17
+ * 4. `null` (no ticket found)
18
+ *
19
+ * Account key fallback hierarchy:
20
+ * 1. Agent-specific account_key
21
+ * 2. Global default account_key
22
+ * 3. default_account_key from config
23
+ */
24
+ export class TicketResolver {
25
+ /** Plugin configuration */
26
+ private config: TimeTrackingConfig
27
+
28
+ /** Ticket extractor for context-based lookup */
29
+ private ticketExtractor: TicketExtractor
30
+
31
+ /**
32
+ * Creates a new ticket resolver instance.
33
+ *
34
+ * @param config - The plugin configuration
35
+ * @param ticketExtractor - The ticket extractor instance
36
+ */
37
+ constructor(config: TimeTrackingConfig, ticketExtractor: TicketExtractor) {
38
+ this.config = config
39
+ this.ticketExtractor = ticketExtractor
40
+ }
41
+
42
+ /**
43
+ * Resolves ticket and account key for a session.
44
+ *
45
+ * @param sessionID - The OpenCode session identifier
46
+ * @param agentName - The agent name (e.g., "@developer"), or `null`
47
+ * @returns Resolved ticket info with ticket and accountKey
48
+ *
49
+ * @example
50
+ * ```typescript
51
+ * const resolved = await ticketResolver.resolve("session-123", "@developer")
52
+ * // Returns { ticket: "PROJ-123", accountKey: "TD_DEV" }
53
+ * ```
54
+ */
55
+ async resolve(
56
+ sessionID: string,
57
+ agentName: string | null
58
+ ): Promise<ResolvedTicketInfo> {
59
+ // 1. Try context ticket
60
+ const contextTicket = await this.ticketExtractor.extract(sessionID)
61
+
62
+ if (contextTicket) {
63
+ return {
64
+ ticket: contextTicket,
65
+ accountKey: this.resolveAccountKey(agentName),
66
+ }
67
+ }
68
+
69
+ // 2. Try agent default
70
+ if (agentName && this.config.agent_defaults?.[agentName]) {
71
+ return {
72
+ ticket: this.config.agent_defaults[agentName].issue_key,
73
+ accountKey: this.resolveAccountKey(agentName),
74
+ }
75
+ }
76
+
77
+ // 3. Try global default
78
+ if (this.config.global_default) {
79
+ return {
80
+ ticket: this.config.global_default.issue_key,
81
+ accountKey: this.resolveAccountKey(agentName),
82
+ }
83
+ }
84
+
85
+ // 4. No ticket found
86
+ return {
87
+ ticket: null,
88
+ accountKey: this.resolveAccountKey(agentName),
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Resolves account key using fallback hierarchy.
94
+ *
95
+ * @param agentName - The agent name, or `null`
96
+ * @returns Resolved Tempo account key
97
+ */
98
+ private resolveAccountKey(agentName: string | null): string {
99
+ // 1. Agent-specific account_key
100
+ if (agentName && this.config.agent_defaults?.[agentName]?.account_key) {
101
+ return this.config.agent_defaults[agentName].account_key!
102
+ }
103
+
104
+ // 2. Global default account_key
105
+ if (this.config.global_default?.account_key) {
106
+ return this.config.global_default.account_key
107
+ }
108
+
109
+ // 3. Config default
110
+ return this.config.default_account_key
111
+ }
112
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @fileoverview Agent-specific default ticket configuration.
3
+ */
4
+
5
+ /**
6
+ * Configuration for agent-specific default tickets.
7
+ *
8
+ * @remarks
9
+ * Used as fallback when no ticket is found in session context.
10
+ * Each agent (e.g., "@developer", "@reviewer") can have its own default.
11
+ */
12
+ export interface AgentDefaultConfig {
13
+ /**
14
+ * Default JIRA Issue Key for this agent.
15
+ *
16
+ * @remarks
17
+ * Must match pattern `^[A-Z][A-Z0-9]+-[0-9]+$` (e.g., "PROJ-123")
18
+ */
19
+ issue_key: string
20
+
21
+ /**
22
+ * Optional Tempo Account Key override.
23
+ *
24
+ * @remarks
25
+ * If not set, falls back to `global_default.account_key`
26
+ * or `default_account_key`.
27
+ */
28
+ account_key?: string
29
+ }
@@ -45,4 +45,15 @@ export interface CsvEntryData {
45
45
  * Only the first/primary agent is tracked.
46
46
  */
47
47
  agent: string | null
48
+
49
+ /**
50
+ * Tempo account key for the worklog entry.
51
+ *
52
+ * @remarks
53
+ * Resolved from (in order of priority):
54
+ * 1. Agent-specific account_key
55
+ * 2. Global default account_key
56
+ * 3. default_account_key from config
57
+ */
58
+ accountKey: string
48
59
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @fileoverview Global fallback ticket configuration.
3
+ */
4
+
5
+ /**
6
+ * Configuration for global default ticket fallback.
7
+ *
8
+ * @remarks
9
+ * Used when no ticket is found in session context and no
10
+ * agent-specific default is configured.
11
+ */
12
+ export interface GlobalDefaultConfig {
13
+ /**
14
+ * Global default JIRA Issue Key.
15
+ *
16
+ * @remarks
17
+ * Must match pattern `^[A-Z][A-Z0-9]+-[0-9]+$` (e.g., "PROJ-MISC-001")
18
+ */
19
+ issue_key: string
20
+
21
+ /**
22
+ * Optional Tempo Account Key.
23
+ *
24
+ * @remarks
25
+ * If not set, falls back to `default_account_key`.
26
+ */
27
+ account_key?: string
28
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @fileoverview Result type for ticket resolution.
3
+ */
4
+
5
+ /**
6
+ * Result of ticket resolution containing ticket and account key.
7
+ *
8
+ * @remarks
9
+ * Returned by `TicketResolver.resolve()` after applying the
10
+ * fallback hierarchy.
11
+ */
12
+ export interface ResolvedTicketInfo {
13
+ /**
14
+ * Resolved JIRA ticket, or `null` if not found.
15
+ *
16
+ * @remarks
17
+ * Resolution priority:
18
+ * 1. Context ticket (from messages/todos)
19
+ * 2. Agent default (from config)
20
+ * 3. Global default (from config)
21
+ * 4. `null` (no ticket found)
22
+ */
23
+ ticket: string | null
24
+
25
+ /**
26
+ * Resolved Tempo account key.
27
+ *
28
+ * @remarks
29
+ * Resolution priority:
30
+ * 1. Agent-specific account_key
31
+ * 2. Global default account_key
32
+ * 3. default_account_key from config
33
+ */
34
+ accountKey: string
35
+ }
@@ -2,6 +2,9 @@
2
2
  * @fileoverview Configuration type for the time tracking plugin.
3
3
  */
4
4
 
5
+ import type { AgentDefaultConfig } from "./AgentDefaultConfig"
6
+ import type { GlobalDefaultConfig } from "./GlobalDefaultConfig"
7
+
5
8
  /**
6
9
  * Time tracking configuration as stored in `.opencode/opencode-project.json`.
7
10
  *
@@ -24,6 +27,24 @@ export interface TimeTrackingJsonConfig {
24
27
 
25
28
  /** Default Jira account key for time entries */
26
29
  default_account_key: string
30
+
31
+ /**
32
+ * Agent-specific default tickets.
33
+ *
34
+ * @remarks
35
+ * Map of agent names (e.g., "@developer", "@reviewer") to their
36
+ * default ticket configuration. Used when no ticket is found in context.
37
+ */
38
+ agent_defaults?: Record<string, AgentDefaultConfig>
39
+
40
+ /**
41
+ * Global fallback ticket configuration.
42
+ *
43
+ * @remarks
44
+ * Used when no ticket is found in context and no agent-specific
45
+ * default is configured.
46
+ */
47
+ global_default?: GlobalDefaultConfig
27
48
  }
28
49
 
29
50
  /**
@@ -35,7 +35,7 @@ export class DescriptionGenerator {
35
35
  */
36
36
  static generate(activities: ActivityData[]): string {
37
37
  if (activities.length === 0) {
38
- return "No activities tracked"
38
+ return "Chat session (no tool calls)"
39
39
  }
40
40
 
41
41
  const toolCounts = activities.reduce(