@timefly/opencode-plugin 0.2.2 → 0.2.3

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/README.md CHANGED
@@ -180,7 +180,7 @@ OpenCode hooks → @timefly/opencode-plugin → @timefly/ai-sdk
180
180
  → POST /ai/sync (gzip JSON, Bearer token)
181
181
  → Gateway auth + Supporter check
182
182
  → Ingest queue (Redis)
183
- → Worker (every 5s) → ClickHouse ai_usage_events
183
+ → Worker (every 5s) → ClickHouse activity_events (ai.* activities)
184
184
  → Dashboard GET /analytics/ai-usage
185
185
  ```
186
186
 
@@ -230,10 +230,76 @@ OpenCode installs npm plugins automatically at startup via Bun.
230
230
 
231
231
  ## Events captured
232
232
 
233
+ Based on OpenCode source (`packages/opencode`, `@opencode-ai/plugin` hooks, and `@opencode-ai/sdk` event types). We only emit metadata — never prompts, tool args, or file contents.
234
+
235
+ ### Plugin hooks we use
236
+
237
+ | Hook | When it fires (OpenCode source) | TimeFly event |
238
+ |------|----------------------------------|---------------|
239
+ | `event` | Bus events forwarded from `EventV2Bridge` to all plugins | see bus table below |
240
+ | `chat.params` | Before every LLM call (`LLMRequestPrep.prepare`) | `llm_request` |
241
+ | `tool.execute.before` | Before built-in, MCP, and registry tools run (`SessionTools.resolve`) | `tool_call` |
242
+ | `tool.execute.after` | After tool completes (same path) | `tool_result` |
243
+ | `experimental.session.compacting` | Before compaction LLM call (`SessionCompaction.process`) | `compaction` |
244
+
245
+ ### Bus events we handle (`event` hook)
246
+
247
+ | OpenCode event | Status | TimeFly event | Data captured |
248
+ |----------------|--------|---------------|---------------|
249
+ | `session.created` | Active | `session_start` | title, project, directory |
250
+ | `session.status` (`type: idle`) | Preferred | `session_end` | session token/tool/request totals |
251
+ | `session.idle` | Deprecated alias | `session_end` | same as above |
252
+ | `message.updated` (assistant, completed) | Active | `turn_complete` + `llm_response` | tokens, cost, duration, finish reason |
253
+ | `message.updated` (user) | Active | `llm_request` | model, agent (metadata only) |
254
+ | `message.part.updated` (`step-finish`) | Active | `llm_response` | per-step tokens, cost |
255
+ | `message.part.updated` (`retry`) | Active | `error` | retry attempt |
256
+ | `message.part.updated` (`compaction`) | Active | `compaction` | auto/manual flag |
257
+ | `session.compacted` | Active | `compaction` | session id |
258
+ | `session.error` | Active | `error` | error name/message |
259
+ | `command.executed` | Active | `tool_call` | command name (as `command:*`) |
260
+
261
+ ### Bus events we intentionally skip
262
+
263
+ | OpenCode event | Why not tracked |
264
+ |----------------|-----------------|
265
+ | `message.part.updated` (`text`, `reasoning`, `tool`, …) | Would expose prompt/response/tool args |
266
+ | `message.removed`, `message.part.removed` | Deletion only — no usage signal |
267
+ | `session.updated`, `session.deleted`, `session.diff` | Metadata or file diffs — not AI usage |
268
+ | `file.edited`, `file.watcher.updated` | File paths/content |
269
+ | `permission.asked`, `permission.replied` | No token/model signal |
270
+ | `todo.updated` | Task list text |
271
+ | `lsp.*`, `pty.*`, `tui.*`, `installation.*`, `server.*` | IDE/infra — not LLM usage |
272
+
273
+ ### Hooks we do not use (available in OpenCode, not needed for usage telemetry)
274
+
275
+ | Hook | Reason |
276
+ |------|--------|
277
+ | `chat.message` | Full user message + parts — privacy |
278
+ | `chat.headers` | Auth headers — security |
279
+ | `shell.env` | Environment variables — secrets risk |
280
+ | `permission.ask` | Could track denials; not implemented yet |
281
+ | `command.execute.before` | Parts contain prompt fragments |
282
+ | `experimental.chat.messages.transform` | Full message history |
283
+ | `experimental.compaction.autocontinue` | No extra telemetry beyond compaction events |
284
+ | `tool` (custom tools) | Execution still flows through `tool.execute.*` |
285
+
286
+ ### Known limitations
287
+
288
+ | Topic | Detail |
289
+ |-------|--------|
290
+ | **Custom providers** (e.g. `nan`) | OpenCode runtime passes flat `Provider.Info` (`provider.id`) via `LLMRequestPrep.prepare` — not `provider.info.id`. Plugin reads both runtime and typed `ProviderContext` shapes. |
291
+ | **Token counts** | Require provider to report usage. If `message.updated` lacks token fields, turn events are skipped (no crash). |
292
+ | **Provider-side tools** | Tools executed inside the provider (`providerExecuted`) may not hit `tool.execute.*` — no tool events for those. |
293
+ | **Multi-step turns** | `step-finish` parts can emit extra `llm_response` events; message-level `turn_complete` deduplicates by message id. |
294
+ | **`session.idle` vs `session.status`** | OpenCode marks `session.idle` deprecated; we handle both. |
295
+ | **v2 event system** | OpenCode is migrating to `session.next.*` internally; plugins still receive v1 bus types above via the bridge. |
296
+
297
+ ### Quick reference (captured signals)
298
+
233
299
  | OpenCode signal | TimeFly `eventType` | Data |
234
300
  |-----------------|---------------------|------|
235
301
  | `session.created` | `session_start` | title, project, directory |
236
- | `session.idle` | `session_end` | session token/tool/request totals |
302
+ | `session.status` / `session.idle` | `session_end` | session token/tool/request totals |
237
303
  | `chat.params` | `llm_request` | model, provider, agent, temperature |
238
304
  | `message.updated` (assistant, completed) | `turn_complete` + `llm_response` | tokens, duration, tokens/s, cost |
239
305
  | `message.part.updated` (step-finish) | `llm_response` | per-step tokens |
@@ -1 +1 @@
1
- {"version":3,"file":"event-handlers.d.ts","sourceRoot":"","sources":["../src/event-handlers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAiBvD,OAAO,EASN,KAAK,gBAAgB,EACrB,MAAM,uBAAuB,CAAA;AAC9B,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAA;AAG/D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC,SAAS,CAAC,CAAA;AAKxE,eAAO,MAAM,oBAAoB,GAAI,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS,cAAc,KAAG,OAAO,CAAC,IAAI,CAapH,CAAA;AAED,eAAO,MAAM,iBAAiB,GAC7B,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,UAAU,CAAC,OAAO,kBAAkB,CAAC,EAC9C,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAad,CAAA;AAED,eAAO,MAAM,oBAAoB,GAChC,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,UAAU,CAAC,OAAO,kBAAkB,CAAC,EAC9C,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAwCd,CAAA;AAED,eAAO,MAAM,wBAAwB,GACpC,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,UAAU,CAAC,OAAO,kBAAkB,CAAC,EAC9C,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAiCd,CAAA;AAED,eAAO,MAAM,sBAAsB,GAClC,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAQd,CAAA;AAED,eAAO,MAAM,kBAAkB,GAC9B,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAmBd,CAAA;AAED,eAAO,MAAM,qBAAqB,GACjC,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAad,CAAA;AAED,eAAO,MAAM,cAAc,GAC1B,OAAO,gBAAgB,EACvB,SAAS,UAAU,CAAC,OAAO,kBAAkB,CAAC,EAC9C,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAyBd,CAAA"}
1
+ {"version":3,"file":"event-handlers.d.ts","sourceRoot":"","sources":["../src/event-handlers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAiBvD,OAAO,EASN,KAAK,gBAAgB,EACrB,MAAM,uBAAuB,CAAA;AAC9B,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAA;AAG/D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC,SAAS,CAAC,CAAA;AAKxE,eAAO,MAAM,oBAAoB,GAAI,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,SAAS,cAAc,KAAG,OAAO,CAAC,IAAI,CAapH,CAAA;AAED,eAAO,MAAM,iBAAiB,GAC7B,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,UAAU,CAAC,OAAO,kBAAkB,CAAC,EAC9C,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAad,CAAA;AAED,eAAO,MAAM,oBAAoB,GAChC,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,UAAU,CAAC,OAAO,kBAAkB,CAAC,EAC9C,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAwCd,CAAA;AAED,eAAO,MAAM,wBAAwB,GACpC,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,UAAU,CAAC,OAAO,kBAAkB,CAAC,EAC9C,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAiCd,CAAA;AAED,eAAO,MAAM,sBAAsB,GAClC,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAQd,CAAA;AAED,eAAO,MAAM,kBAAkB,GAC9B,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAmBd,CAAA;AAED,eAAO,MAAM,qBAAqB,GACjC,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACxC,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAad,CAAA;AAED,eAAO,MAAM,cAAc,GAC1B,OAAO,gBAAgB,EACvB,SAAS,UAAU,CAAC,OAAO,kBAAkB,CAAC,EAC9C,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAkCd,CAAA"}
@@ -127,6 +127,13 @@ export const handleBusEvent = (event, tracker, publish) => {
127
127
  switch (event.type) {
128
128
  case 'session.created':
129
129
  return handleSessionCreated(eventProperties, publish);
130
+ case 'session.status': {
131
+ const statusRecord = isRecord(eventProperties.status) ? eventProperties.status : undefined;
132
+ if (statusRecord?.type !== 'idle') {
133
+ return Promise.resolve();
134
+ }
135
+ return handleSessionIdle(eventProperties, tracker, publish);
136
+ }
130
137
  case 'session.idle':
131
138
  return handleSessionIdle(eventProperties, tracker, publish);
132
139
  case 'message.updated':
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAWjD,eAAO,MAAM,qBAAqB,EAAE,MAyDnC,CAAA;AAED,eAAe,qBAAqB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAgBjD,eAAO,MAAM,qBAAqB,EAAE,MA+DnC,CAAA;AAED,eAAe,qBAAqB,CAAA"}
package/dist/index.js CHANGED
@@ -5,6 +5,9 @@ import { createEventPublisher } from './publish-events.js';
5
5
  import { resolveChatParams } from './read-chat-params.js';
6
6
  import packageJson from '../package.json' with { type: 'json' };
7
7
  const PLUGIN_VERSION = packageJson.version;
8
+ const runHookSafely = (run) => Promise.resolve()
9
+ .then(run)
10
+ .catch(() => undefined);
8
11
  export const timeflyOpenCodePlugin = ({ client }) => {
9
12
  const tracker = createEventTracker();
10
13
  const publisher = createEventPublisher(client, PLUGIN_VERSION);
@@ -20,8 +23,8 @@ export const timeflyOpenCodePlugin = ({ client }) => {
20
23
  .then(() => undefined)
21
24
  .catch(() => undefined)
22
25
  .then(() => Promise.resolve({
23
- event: (input) => handleBusEvent(input.event, tracker, publisher.publish),
24
- 'chat.params': (input, output) => {
26
+ event: (input) => runHookSafely(() => handleBusEvent(input.event, tracker, publisher.publish)),
27
+ 'chat.params': (input, output) => runHookSafely(() => {
25
28
  tracker.recordSessionStats(input.sessionID, { requestCount: 1 });
26
29
  const resolvedParams = resolveChatParams(input, output);
27
30
  return publisher.publish([
@@ -36,12 +39,12 @@ export const timeflyOpenCodePlugin = ({ client }) => {
36
39
  maxOutputTokens: resolvedParams.maxOutputTokens
37
40
  })
38
41
  ]);
39
- },
40
- 'tool.execute.before': (input) => {
42
+ }),
43
+ 'tool.execute.before': (input) => runHookSafely(() => {
41
44
  tracker.recordSessionStats(input.sessionID, { toolCallCount: 1 });
42
45
  return publisher.publish([mapToolCallInput(input)]);
43
- },
44
- 'tool.execute.after': (input, output) => publisher.publish([
46
+ }),
47
+ 'tool.execute.after': (input, output) => runHookSafely(() => publisher.publish([
45
48
  mapToolResultInput({
46
49
  sessionID: input.sessionID,
47
50
  tool: input.tool,
@@ -49,8 +52,8 @@ export const timeflyOpenCodePlugin = ({ client }) => {
49
52
  hasOutput: Boolean(output.output),
50
53
  outputLength: output.output.length
51
54
  })
52
- ]),
53
- 'experimental.session.compacting': (input) => publisher.publish([mapCompactionInput(input.sessionID)])
55
+ ])),
56
+ 'experimental.session.compacting': (input) => runHookSafely(() => publisher.publish([mapCompactionInput(input.sessionID)]))
54
57
  }));
55
58
  };
56
59
  export default timeflyOpenCodePlugin;
@@ -1,16 +1,37 @@
1
+ /**
2
+ * OpenCode runtime passes flat `Provider.Info` to `chat.params`:
3
+ * `packages/opencode/src/session/llm/request.ts` → `provider: input.provider`
4
+ *
5
+ * Shape: `{ id, name, source, env, options, models }` — id is at the top level.
6
+ *
7
+ * `@opencode-ai/plugin` types document `ProviderContext` (`{ source, info, options }`)
8
+ * which does not match current runtime (anomalyco/opencode#20562). We accept both.
9
+ *
10
+ * Model shape: `Provider.Model` → `{ id, providerID, … }`.
11
+ */
12
+ export type OpenCodeProviderInfo = {
13
+ id: string;
14
+ name?: string;
15
+ source?: 'env' | 'config' | 'custom' | 'api' | string;
16
+ options?: Record<string, unknown>;
17
+ };
18
+ export type OpenCodeProviderContext = {
19
+ source?: 'env' | 'config' | 'custom' | 'api' | string;
20
+ info?: {
21
+ id?: string;
22
+ name?: string;
23
+ };
24
+ options?: Record<string, unknown>;
25
+ };
26
+ export type OpenCodeModel = {
27
+ id: string;
28
+ providerID: string;
29
+ };
1
30
  export type ChatParamsInput = {
2
31
  sessionID: string;
3
32
  agent: string;
4
- model?: {
5
- id?: string;
6
- providerID?: string;
7
- };
8
- provider?: {
9
- source?: string;
10
- info?: {
11
- id?: string;
12
- };
13
- };
33
+ model?: OpenCodeModel;
34
+ provider?: OpenCodeProviderInfo | OpenCodeProviderContext;
14
35
  };
15
36
  export type ChatParamsOutput = {
16
37
  temperature: number;
@@ -1 +1 @@
1
- {"version":3,"file":"read-chat-params.d.ts","sourceRoot":"","sources":["../src/read-chat-params.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GAAG;IAC7B,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE;QACP,EAAE,CAAC,EAAE,MAAM,CAAA;QACX,UAAU,CAAC,EAAE,MAAM,CAAA;KACnB,CAAA;IACD,QAAQ,CAAC,EAAE;QACV,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,IAAI,CAAC,EAAE;YACN,EAAE,CAAC,EAAE,MAAM,CAAA;SACX,CAAA;KACD,CAAA;CACD,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC9B,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,eAAe,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAChC,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;IACf,cAAc,EAAE,MAAM,CAAA;IACtB,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,eAAe,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAED,eAAO,MAAM,iBAAiB,GAAI,OAAO,eAAe,EAAE,QAAQ,gBAAgB,KAAG,kBASnF,CAAA"}
1
+ {"version":3,"file":"read-chat-params.d.ts","sourceRoot":"","sources":["../src/read-chat-params.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,MAAM,oBAAoB,GAAG;IAClC,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAA;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACjC,CAAA;AAED,MAAM,MAAM,uBAAuB,GAAG;IACrC,MAAM,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAA;IACrD,IAAI,CAAC,EAAE;QACN,EAAE,CAAC,EAAE,MAAM,CAAA;QACX,IAAI,CAAC,EAAE,MAAM,CAAA;KACb,CAAA;IACD,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACjC,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,UAAU,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC7B,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,aAAa,CAAA;IACrB,QAAQ,CAAC,EAAE,oBAAoB,GAAG,uBAAuB,CAAA;CACzD,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAC9B,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,eAAe,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAChC,SAAS,EAAE,MAAM,CAAA;IACjB,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;IACf,cAAc,EAAE,MAAM,CAAA;IACtB,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,eAAe,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAiBD,eAAO,MAAM,iBAAiB,GAAI,OAAO,eAAe,EAAE,QAAQ,gBAAgB,KAAG,kBASnF,CAAA"}
@@ -1,9 +1,20 @@
1
+ const readProviderId = (provider, model) => {
2
+ if (provider && 'id' in provider && typeof provider.id === 'string' && provider.id.length > 0) {
3
+ return provider.id;
4
+ }
5
+ const nestedProviderId = provider && 'info' in provider ? provider.info?.id : undefined;
6
+ if (typeof nestedProviderId === 'string' && nestedProviderId.length > 0) {
7
+ return nestedProviderId;
8
+ }
9
+ return model?.providerID ?? 'unknown';
10
+ };
11
+ const readProviderSource = (provider) => provider?.source ?? 'unknown';
1
12
  export const resolveChatParams = (input, output) => ({
2
13
  sessionID: input.sessionID,
3
14
  agent: input.agent,
4
- providerId: input.provider?.info?.id ?? input.model?.providerID ?? 'unknown',
15
+ providerId: readProviderId(input.provider, input.model),
5
16
  modelId: input.model?.id ?? 'unknown',
6
- providerSource: input.provider?.source ?? 'unknown',
17
+ providerSource: readProviderSource(input.provider),
7
18
  temperature: output.temperature,
8
19
  topP: output.topP,
9
20
  maxOutputTokens: output.maxOutputTokens
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timefly/opencode-plugin",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "TimeFly telemetry plugin for OpenCode — sessions, tokens, models, and tools",
5
5
  "type": "module",
6
6
  "bin": "./dist/cli.js",