@techdivision/opencode-time-tracking 0.3.2 → 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.2",
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,7 +84,8 @@ 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
91
  // Track model and agent from assistant messages
@@ -94,6 +96,11 @@ 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
+
97
104
  // Track model
98
105
  if (assistantMsg.modelID && assistantMsg.providerID) {
99
106
  sessionManager.setModel(assistantMsg.sessionID, {
@@ -118,6 +125,11 @@ export function createEventHook(
118
125
 
119
126
  // Track token usage from step-finish events
120
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
+
121
133
  sessionManager.addTokenUsage(part.sessionID, {
122
134
  input: part.tokens.input,
123
135
  output: part.tokens.output,
@@ -139,10 +151,17 @@ export function createEventHook(
139
151
  return
140
152
  }
141
153
 
142
- const session = sessionManager.get(sessionID)
154
+ // Atomically get and delete to prevent race conditions
155
+ const session = sessionManager.getAndDelete(sessionID)
143
156
 
144
- if (!session || session.activities.length === 0) {
145
- 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)) {
146
165
  return
147
166
  }
148
167
 
@@ -171,9 +190,13 @@ export function createEventHook(
171
190
  // Get agent name if available
172
191
  const agentString = session.agent?.name ?? null
173
192
 
193
+ // Resolve ticket and account key with fallback hierarchy
194
+ const resolved = await ticketResolver.resolve(sessionID, agentString)
195
+
174
196
  try {
175
197
  await csvWriter.write({
176
- ticket: session.ticket,
198
+ ticket: resolved.ticket,
199
+ accountKey: resolved.accountKey,
177
200
  startTime: session.startTime,
178
201
  endTime,
179
202
  durationSeconds,
@@ -188,7 +211,7 @@ export function createEventHook(
188
211
 
189
212
  await client.tui.showToast({
190
213
  body: {
191
- 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}` : ""}`,
192
215
  variant: "success",
193
216
  },
194
217
  })
@@ -199,9 +222,7 @@ export function createEventHook(
199
222
  variant: "error",
200
223
  },
201
224
  })
202
- }
203
-
204
- sessionManager.delete(sessionID)
225
+ }
205
226
  }
206
227
  }
207
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(