@softprobe/opencode-plugin 0.1.0 → 0.1.5

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/dist/softprobe.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { SoftprobeClient, recordToolCalls, } from "@softprobe/tracing";
2
+ import { BoundedSet } from "./bounded.js";
3
+ import { SessionGraph, parseTaskCallArgs, } from "./session-graph.js";
2
4
  import { inferToolKind } from "./tool-kind.js";
3
5
  function asJson(value) {
4
6
  if (value === null ||
@@ -12,6 +14,23 @@ function asJson(value) {
12
14
  }
13
15
  return String(value);
14
16
  }
17
+ /** Dedup-set key scoped to a session so per-session cleanup stays precise. */
18
+ function scopedKey(sessionID, id) {
19
+ return `${sessionID}:${id}`;
20
+ }
21
+ /** Cap on remembered ended task spans (attachment targets for late children). */
22
+ const ENDED_TASK_SPAN_CAP = 200;
23
+ /**
24
+ * Cap per dedup set.
25
+ *
26
+ * This bounds memory but does not make eviction free: if an evicted callID
27
+ * later receives a duplicate delivery, its span is recreated — two spans for
28
+ * one tool call, the exact failure the dedup set exists to prevent. The cap
29
+ * raises the threshold rather than removing the failure, so it is set far
30
+ * above what any live session produces; reaching it needs thousands of calls
31
+ * between a span's force-end and a late duplicate for that same call.
32
+ */
33
+ const TRACED_ID_CAP = 10_000;
15
34
  function formatUserParts(parts) {
16
35
  const formatted = parts.map((part) => {
17
36
  if (part.type === "text") {
@@ -73,13 +92,32 @@ function getCompletedReasoningTimestamp(part) {
73
92
  export class SoftprobeSessionTracer {
74
93
  client;
75
94
  userId;
95
+ sessionLookup;
96
+ /** Sub-agent session relationships (parent links + task-call bindings). */
97
+ sessionGraph = new SessionGraph();
98
+ /** In-flight classification promises, deduped per session. */
99
+ classifying = new Map();
100
+ /**
101
+ * Recently ended task spans, kept so late-starting child sessions can still
102
+ * be parented under them (OTEL allows ended spans as parents).
103
+ */
104
+ endedTaskSpans = new Map();
105
+ /** messageID → sessionID index enabling per-session state cleanup. */
106
+ messageSession = new Map();
76
107
  abortedSessions = new Set();
77
- tracedMessageIds = new Set();
78
- tracedGenerationIds = new Set();
79
- tracedEventIds = new Set();
80
- tracedReasoningIds = new Set();
81
- tracedToolCallIds = new Set();
108
+ // Dedup keys outlive every scoped finalize (see clearTraceState), so they
109
+ // are capped rather than cleared. Only ids far past their session's end can
110
+ // fall out, and re-tracing one of those is harmless.
111
+ tracedMessageIds = new BoundedSet(TRACED_ID_CAP);
112
+ tracedGenerationIds = new BoundedSet(TRACED_ID_CAP);
113
+ tracedEventIds = new BoundedSet(TRACED_ID_CAP);
114
+ tracedReasoningIds = new BoundedSet(TRACED_ID_CAP);
115
+ tracedToolCallIds = new BoundedSet(TRACED_ID_CAP);
82
116
  pendingReasoningByMessageId = new Map();
117
+ // Deliberately unbounded: every SDK part shape declares sessionID, so every
118
+ // entry is indexed in messageSession and cleared with its session. A cap
119
+ // here could only drop parts a pending traceGeneration still needs — losing
120
+ // the turn's and the generation's output — never save memory.
83
121
  assistantParts = new Map();
84
122
  generationByMessageId = new Map();
85
123
  turnByMessageId = new Map();
@@ -90,23 +128,56 @@ export class SoftprobeSessionTracer {
90
128
  constructor(client, options = {}) {
91
129
  this.client = client;
92
130
  this.userId = options.userId;
131
+ this.sessionLookup = options.sessionLookup;
93
132
  }
94
- clearTraceState() {
95
- this.assistantParts.clear();
96
- this.abortedSessions.clear();
97
- this.tracedEventIds.clear();
98
- this.tracedReasoningIds.clear();
99
- this.tracedToolCallIds.clear();
100
- this.pendingReasoningByMessageId.clear();
101
- this.generationByMessageId.clear();
102
- this.generationParents.clear();
103
- this.turnByMessageId.clear();
104
- this.latestTurnBySession.clear();
133
+ /**
134
+ * Clear bookkeeping for ended spans. With a sessionID, only that session's
135
+ * state is cleared (a sub-agent session going idle must not disturb the
136
+ * parent session's in-flight spans).
137
+ *
138
+ * Only payload caches are cleared, in both the scoped and the global path.
139
+ * Dedup sets are never cleared here — they are capped instead (see
140
+ * {@link BoundedSet}) — so a late duplicate delivery can never recreate a
141
+ * span that was already force-ended, on scoped and legacy hosts alike.
142
+ */
143
+ clearTraceState(sessionID) {
144
+ if (!sessionID) {
145
+ this.assistantParts.clear();
146
+ this.abortedSessions.clear();
147
+ this.pendingReasoningByMessageId.clear();
148
+ this.generationByMessageId.clear();
149
+ this.generationParents.clear();
150
+ this.turnByMessageId.clear();
151
+ this.latestTurnBySession.clear();
152
+ this.messageSession.clear();
153
+ return;
154
+ }
155
+ this.abortedSessions.delete(sessionID);
156
+ this.generationParents.delete(sessionID);
157
+ this.latestTurnBySession.delete(sessionID);
158
+ for (const [messageID, turn] of this.turnByMessageId) {
159
+ if (turn.sessionID === sessionID)
160
+ this.turnByMessageId.delete(messageID);
161
+ }
162
+ for (const [messageID, owner] of this.messageSession) {
163
+ if (owner !== sessionID)
164
+ continue;
165
+ this.messageSession.delete(messageID);
166
+ this.assistantParts.delete(messageID);
167
+ this.pendingReasoningByMessageId.delete(messageID);
168
+ this.generationByMessageId.delete(messageID);
169
+ }
105
170
  }
106
171
  endActiveToolObservations(sessionID, error) {
107
172
  for (const [callID, active] of this.activeTools) {
108
173
  if (sessionID && active.sessionID !== sessionID)
109
174
  continue;
175
+ if (active.tool === "task") {
176
+ const child = this.sessionGraph.childForTaskCall(callID);
177
+ if (child) {
178
+ active.observation.setAttributes({ "sp.child.session.id": child });
179
+ }
180
+ }
110
181
  if (error && error.name !== "MessageAbortedError") {
111
182
  active.observation.recordException(new Error(getSessionErrorMessage(error)));
112
183
  active.observation.end({
@@ -122,6 +193,11 @@ export class SoftprobeSessionTracer {
122
193
  active.observation.end();
123
194
  }
124
195
  this.activeTools.delete(callID);
196
+ // Mark as traced so a late duplicate tool end (part.completed arriving
197
+ // after this forced end) does not recreate the span.
198
+ this.tracedToolCallIds.add(scopedKey(active.sessionID, callID));
199
+ if (active.tool === "task")
200
+ this.archiveTaskSpan(callID, active);
125
201
  }
126
202
  }
127
203
  endActiveGenerationSteps(sessionID, error) {
@@ -139,18 +215,165 @@ export class SoftprobeSessionTracer {
139
215
  this.generationParents.delete(activeSessionID);
140
216
  }
141
217
  }
142
- endActiveTurnObservations() {
218
+ endActiveTurnObservations(sessionID) {
143
219
  for (const turn of new Set(this.latestTurnBySession.values())) {
220
+ if (sessionID && turn.sessionID !== sessionID)
221
+ continue;
144
222
  turn.observation.end();
145
223
  }
146
- this.turnByMessageId.clear();
147
- this.latestTurnBySession.clear();
224
+ if (!sessionID) {
225
+ this.turnByMessageId.clear();
226
+ this.latestTurnBySession.clear();
227
+ return;
228
+ }
229
+ for (const [messageID, turn] of this.turnByMessageId) {
230
+ if (turn.sessionID === sessionID)
231
+ this.turnByMessageId.delete(messageID);
232
+ }
233
+ this.latestTurnBySession.delete(sessionID);
148
234
  }
149
- finalizeSessionTracing() {
150
- this.endActiveToolObservations();
151
- this.endActiveGenerationSteps();
152
- this.endActiveTurnObservations();
153
- this.clearTraceState();
235
+ /**
236
+ * End open observations and flush bookkeeping. With a sessionID, only that
237
+ * session is finalized; without one (dispose/shutdown, or hosts whose idle
238
+ * event carries no sessionID) everything is finalized.
239
+ */
240
+ finalizeSessionTracing(sessionID) {
241
+ this.endActiveToolObservations(sessionID);
242
+ this.endActiveGenerationSteps(sessionID);
243
+ this.endActiveTurnObservations(sessionID);
244
+ this.clearTraceState(sessionID);
245
+ }
246
+ /**
247
+ * Register session genealogy reported by OpenCode (`session.created` /
248
+ * `session.updated` carry `info.parentID` for task-tool child sessions).
249
+ */
250
+ registerSessionInfo(sessionID, parentID) {
251
+ if (parentID) {
252
+ this.sessionGraph.registerParent(sessionID, parentID);
253
+ this.tryBindTask(sessionID, parentID, {});
254
+ }
255
+ else {
256
+ this.sessionGraph.markRoot(sessionID);
257
+ }
258
+ }
259
+ /** Drop relationship data for a deleted session (`session.deleted`). */
260
+ evictSession(sessionID) {
261
+ this.sessionGraph.evictSession(sessionID);
262
+ for (const [callID, ended] of this.endedTaskSpans) {
263
+ if (ended.sessionID === sessionID)
264
+ this.endedTaskSpans.delete(callID);
265
+ }
266
+ }
267
+ /**
268
+ * Ensure a session is classified (root vs sub-agent child) before its first
269
+ * spans are created. Resolves the parent from prior events or, failing
270
+ * that, the OpenCode client API, then tries to bind the session to the
271
+ * dispatching task call. Idempotent and deduplicated while in flight.
272
+ */
273
+ async ensureSessionClassified(sessionID, hints = {}) {
274
+ if (this.sessionGraph.bindingFor(sessionID) ||
275
+ this.sessionGraph.isRoot(sessionID)) {
276
+ return;
277
+ }
278
+ const pending = this.classifying.get(sessionID);
279
+ if (pending)
280
+ return pending;
281
+ const task = this.classifySession(sessionID, hints).finally(() => {
282
+ this.classifying.delete(sessionID);
283
+ });
284
+ this.classifying.set(sessionID, task);
285
+ return task;
286
+ }
287
+ async classifySession(sessionID, hints) {
288
+ let parentID = this.sessionGraph.parentOf(sessionID);
289
+ if (!parentID && this.sessionLookup) {
290
+ try {
291
+ parentID = (await this.sessionLookup(sessionID)) ?? undefined;
292
+ }
293
+ catch {
294
+ parentID = undefined;
295
+ }
296
+ if (parentID)
297
+ this.sessionGraph.registerParent(sessionID, parentID);
298
+ }
299
+ if (!parentID) {
300
+ // No evidence of a parent. Treat as root; a late session.updated or
301
+ // task-part metadata event can still reclassify it.
302
+ this.sessionGraph.markRoot(sessionID);
303
+ return;
304
+ }
305
+ this.tryBindTask(sessionID, parentID, hints);
306
+ }
307
+ /**
308
+ * Bind a child session to the task call that dispatched it, when the call
309
+ * can be identified unambiguously. Bindings from task-part metadata are
310
+ * authoritative and are never replaced by inference.
311
+ */
312
+ tryBindTask(childSessionID, parentSessionID, hints) {
313
+ if (this.sessionGraph.bindingFor(childSessionID)?.source === "metadata") {
314
+ return;
315
+ }
316
+ const taskCallID = this.sessionGraph.resolveTaskCall(childSessionID, parentSessionID, { agent: hints.agent, prompt: hints.promptText });
317
+ if (taskCallID) {
318
+ this.sessionGraph.bind({
319
+ childSessionID,
320
+ parentSessionID,
321
+ taskCallID,
322
+ source: "inferred",
323
+ });
324
+ }
325
+ }
326
+ /**
327
+ * Capture the authoritative child-session link published on the task tool
328
+ * part (`state.metadata.sessionId`, present while the part is running,
329
+ * before the child session starts).
330
+ */
331
+ captureTaskBinding(part) {
332
+ const metadata = part.state?.metadata;
333
+ const childSessionID = metadata?.["sessionId"];
334
+ if (typeof childSessionID !== "string" || !part.sessionID)
335
+ return;
336
+ if (childSessionID === part.sessionID)
337
+ return;
338
+ const callID = part.callID ?? part.id;
339
+ if (!callID)
340
+ return;
341
+ const parentSessionId = metadata?.["parentSessionId"];
342
+ this.sessionGraph.bind({
343
+ childSessionID,
344
+ parentSessionID: typeof parentSessionId === "string" ? parentSessionId : part.sessionID,
345
+ taskCallID: callID,
346
+ source: "metadata",
347
+ });
348
+ }
349
+ /** Move an ended task span into the archive used for late child attach. */
350
+ archiveTaskSpan(callID, active) {
351
+ this.sessionGraph.endTaskCall(callID);
352
+ this.endedTaskSpans.set(callID, {
353
+ observation: active.observation,
354
+ sessionID: active.sessionID,
355
+ });
356
+ if (this.endedTaskSpans.size > ENDED_TASK_SPAN_CAP) {
357
+ const oldest = this.endedTaskSpans.keys().next().value;
358
+ if (oldest !== undefined)
359
+ this.endedTaskSpans.delete(oldest);
360
+ }
361
+ }
362
+ /**
363
+ * Locate the span of the task call that dispatched a child session.
364
+ *
365
+ * Direct lookup only. A previous version fell back to "the unique active
366
+ * task span in the parent session" when the bound call had no span, which
367
+ * could parent a child under an unrelated sibling task. Its justification —
368
+ * a path keying the task span by part.id while the binding carries a
369
+ * different callID — does not exist: `ToolPart.callID` is required in the
370
+ * SDK, and traceToolPart and captureTaskBinding derive the id from the same
371
+ * expression, so the two can never diverge. Callers fall back to the parent
372
+ * session's turn when this returns undefined.
373
+ */
374
+ taskSpanFor(binding) {
375
+ return (this.activeTools.get(binding.taskCallID)?.observation ??
376
+ this.endedTaskSpans.get(binding.taskCallID)?.observation);
154
377
  }
155
378
  async forceFlush() {
156
379
  await this.client.forceFlush();
@@ -174,13 +397,15 @@ export class SoftprobeSessionTracer {
174
397
  };
175
398
  }
176
399
  traceUserMessage(input) {
177
- if (input.messageID && this.tracedMessageIds.has(input.messageID)) {
400
+ if (input.messageID &&
401
+ this.tracedMessageIds.has(scopedKey(input.sessionID, input.messageID))) {
178
402
  return;
179
403
  }
180
404
  this.abortedSessions.delete(input.sessionID);
181
405
  const formattedInput = formatUserParts(input.parts);
182
406
  if (input.messageID) {
183
- this.tracedMessageIds.add(input.messageID);
407
+ this.tracedMessageIds.add(scopedKey(input.sessionID, input.messageID));
408
+ this.messageSession.set(input.messageID, input.sessionID);
184
409
  }
185
410
  const previous = this.latestTurnBySession.get(input.sessionID);
186
411
  if (previous) {
@@ -188,15 +413,33 @@ export class SoftprobeSessionTracer {
188
413
  this.latestTurnBySession.delete(input.sessionID);
189
414
  }
190
415
  this.generationParents.delete(input.sessionID);
416
+ // Sub-agent child sessions nest under the dispatching task span (or, when
417
+ // the span is gone/ambiguous, the parent session's current turn). Root
418
+ // sessions explicitly detach from any ambient host span.
419
+ const binding = this.sessionGraph.bindingFor(input.sessionID);
420
+ const parentSessionID = binding?.parentSessionID ?? this.sessionGraph.parentOf(input.sessionID);
421
+ const parentObservation = (binding ? this.taskSpanFor(binding) : undefined) ??
422
+ (parentSessionID
423
+ ? this.getTurn(parentSessionID)?.observation
424
+ : undefined);
191
425
  const turn = this.client.startAgent({
192
426
  name: "opencode.turn",
193
427
  ...this.sessionAttrs(input.sessionID),
194
428
  input: formattedInput,
429
+ ...(parentObservation
430
+ ? { parent: parentObservation }
431
+ : { root: true }),
195
432
  metadata: {
196
433
  messageID: input.messageID ?? null,
197
434
  agent: input.agent ?? null,
198
435
  providerID: input.model?.providerID ?? null,
199
436
  modelID: input.model?.modelID ?? null,
437
+ ...(parentSessionID
438
+ ? { "opencode.parentSessionID": parentSessionID }
439
+ : {}),
440
+ ...(binding
441
+ ? { "opencode.parentTaskCallID": binding.taskCallID }
442
+ : {}),
200
443
  },
201
444
  });
202
445
  const observation = {
@@ -281,6 +524,8 @@ export class SoftprobeSessionTracer {
281
524
  rememberAssistantPart(part) {
282
525
  if (!part.id || !part.messageID)
283
526
  return;
527
+ if (part.sessionID)
528
+ this.messageSession.set(part.messageID, part.sessionID);
284
529
  const parts = this.assistantParts.get(part.messageID) ?? new Map();
285
530
  parts.set(part.id, part);
286
531
  this.assistantParts.set(part.messageID, parts);
@@ -324,9 +569,11 @@ export class SoftprobeSessionTracer {
324
569
  traceGeneration(input) {
325
570
  if (this.abortedSessions.has(input.sessionID))
326
571
  return;
327
- if (this.tracedGenerationIds.has(input.messageID))
572
+ if (this.tracedGenerationIds.has(scopedKey(input.sessionID, input.messageID))) {
328
573
  return;
329
- this.tracedGenerationIds.add(input.messageID);
574
+ }
575
+ this.tracedGenerationIds.add(scopedKey(input.sessionID, input.messageID));
576
+ this.messageSession.set(input.messageID, input.sessionID);
330
577
  const text = this.getAssistantText(input.messageID);
331
578
  const output = text ? { text } : undefined;
332
579
  const turn = this.getTurn(input.sessionID, input.parentID);
@@ -417,9 +664,10 @@ export class SoftprobeSessionTracer {
417
664
  generation.end({ endTime: input.completed });
418
665
  }
419
666
  traceFailedGenerationStep(input) {
420
- if (this.tracedGenerationIds.has(input.id))
667
+ if (this.tracedGenerationIds.has(scopedKey(input.sessionID, input.id))) {
421
668
  return;
422
- this.tracedGenerationIds.add(input.id);
669
+ }
670
+ this.tracedGenerationIds.add(scopedKey(input.sessionID, input.id));
423
671
  const step = this.activeGenerations.get(input.sessionID);
424
672
  if (step) {
425
673
  step.observation.recordException(new Error(input.error.message));
@@ -448,9 +696,10 @@ export class SoftprobeSessionTracer {
448
696
  generation.end({ endTime: input.completed });
449
697
  }
450
698
  traceEvent(input) {
451
- if (this.tracedEventIds.has(input.id))
699
+ const eventKey = scopedKey(input.sessionID, input.id);
700
+ if (this.tracedEventIds.has(eventKey))
452
701
  return;
453
- this.tracedEventIds.add(input.id);
702
+ this.tracedEventIds.add(eventKey);
454
703
  const parent = input.parent ??
455
704
  this.generationParent(input.sessionID) ??
456
705
  this.getTurn(input.sessionID)?.observation;
@@ -548,12 +797,16 @@ export class SoftprobeSessionTracer {
548
797
  return this.generationParent(sessionID);
549
798
  }
550
799
  /**
551
- * Capture tool observations from message.part.updated when tool.execute.*
552
- * hooks are missed (or spans from those hooks were dropped on export).
800
+ * Capture tool observations from message.part.updated.
801
+ * Also the canonical end path for MCP tools: tool.execute.after often
802
+ * receives raw CallToolResult without `output`, so we leave the span
803
+ * open until this completed/error part update arrives.
553
804
  */
554
805
  traceToolPart(part) {
555
806
  if (part.type !== "tool" || !part.tool || !part.sessionID)
556
807
  return;
808
+ if (part.tool === "task")
809
+ this.captureTaskBinding(part);
557
810
  const callID = part.callID ?? part.id;
558
811
  if (!callID)
559
812
  return;
@@ -587,8 +840,25 @@ export class SoftprobeSessionTracer {
587
840
  });
588
841
  }
589
842
  traceToolStart(input) {
590
- if (this.tracedToolCallIds.has(input.callID))
843
+ if (this.tracedToolCallIds.has(scopedKey(input.sessionID, input.callID))) {
591
844
  return;
845
+ }
846
+ if (input.tool === "task") {
847
+ const taskArgs = parseTaskCallArgs(input.args);
848
+ this.sessionGraph.registerTaskCall(input.callID, input.sessionID, taskArgs);
849
+ if (taskArgs.task_id) {
850
+ // task_id names the resumed child session explicitly — same
851
+ // authority as part metadata, and always precedes the child's next
852
+ // message, so stale bindings from an earlier dispatch are replaced
853
+ // before they can be consumed.
854
+ this.sessionGraph.bind({
855
+ childSessionID: taskArgs.task_id,
856
+ parentSessionID: input.sessionID,
857
+ taskCallID: input.callID,
858
+ source: "metadata",
859
+ });
860
+ }
861
+ }
592
862
  const existing = this.activeTools.get(input.callID);
593
863
  if (existing) {
594
864
  // Same call already open (e.g. message.part.updated running before
@@ -627,7 +897,8 @@ export class SoftprobeSessionTracer {
627
897
  });
628
898
  }
629
899
  traceToolEnd(input) {
630
- if (this.tracedToolCallIds.has(input.callID)) {
900
+ const traceKey = scopedKey(input.sessionID, input.callID);
901
+ if (this.tracedToolCallIds.has(traceKey)) {
631
902
  this.activeTools.delete(input.callID);
632
903
  return;
633
904
  }
@@ -644,10 +915,21 @@ export class SoftprobeSessionTracer {
644
915
  if (!active)
645
916
  return;
646
917
  const toolStatus = input.status ?? "ok";
918
+ // Best-effort: the link is read once, here at end, and the ended span is
919
+ // never revisited. A task that finishes before OpenCode publishes its
920
+ // child session ships without this attribute. The child turn carries the
921
+ // reverse edge (sp.metadata.opencode.parentTaskCallID) in most cases but
922
+ // not all — an ambiguous dispatch leaves the turn with only
923
+ // sp.metadata.opencode.parentSessionID, which links the sessions but not
924
+ // the specific task call.
925
+ const childSessionID = input.tool === "task"
926
+ ? this.sessionGraph.childForTaskCall(input.callID)
927
+ : undefined;
647
928
  active.observation.setAttributes({
648
929
  "sp.metadata.callID": input.callID,
649
930
  "sp.metadata.tool": input.tool,
650
931
  "sp.tool.status": toolStatus,
932
+ ...(childSessionID ? { "sp.child.session.id": childSessionID } : {}),
651
933
  });
652
934
  active.observation.end({
653
935
  output: { title: input.title, output: input.output },
@@ -657,7 +939,9 @@ export class SoftprobeSessionTracer {
657
939
  : {}),
658
940
  });
659
941
  this.activeTools.delete(input.callID);
660
- this.tracedToolCallIds.add(input.callID);
942
+ this.tracedToolCallIds.add(traceKey);
943
+ if (input.tool === "task")
944
+ this.archiveTaskSpan(input.callID, active);
661
945
  }
662
946
  traceSessionError(input) {
663
947
  this.endActiveToolObservations(input.sessionID, input.error);
@@ -686,8 +970,9 @@ export class SoftprobeSessionTracer {
686
970
  }
687
971
  }
688
972
  export function createSoftprobeSessionTracer(options) {
973
+ const { sessionLookup, ...clientOptions } = options;
689
974
  const client = new SoftprobeClient({
690
- ...options,
975
+ ...clientOptions,
691
976
  serviceName: options.serviceName ?? "opencode",
692
977
  // OpenCode already owns experimental OTEL; keep Softprobe spans on a
693
978
  // private provider so we never replace or disable the host tracer.
@@ -703,5 +988,8 @@ export function createSoftprobeSessionTracer(options) {
703
988
  ...(options.headers ?? {}),
704
989
  },
705
990
  });
706
- return new SoftprobeSessionTracer(client, { userId: options.userId });
991
+ return new SoftprobeSessionTracer(client, {
992
+ userId: options.userId,
993
+ sessionLookup,
994
+ });
707
995
  }
package/dist/types.d.ts CHANGED
@@ -79,6 +79,12 @@ export type MessagePart = {
79
79
  error?: string;
80
80
  input?: unknown;
81
81
  output?: string;
82
+ /**
83
+ * Tool-state metadata. OpenCode's task tool publishes
84
+ * `{ sessionId, parentSessionId }` here while the part is still running —
85
+ * the authoritative link between a task call and its child session.
86
+ */
87
+ metadata?: Record<string, unknown>;
82
88
  time?: {
83
89
  start?: number;
84
90
  end?: number;
@@ -96,6 +102,62 @@ export type SessionErrorInfo = {
96
102
  message?: string;
97
103
  };
98
104
  };
105
+ /** Session record as it appears on `session.created` / `session.updated`. */
106
+ export type SessionInfo = {
107
+ id?: string;
108
+ /** Set on task-tool child sessions; absent or null on roots. */
109
+ parentID?: string | null;
110
+ };
111
+ /**
112
+ * Session lifecycle events the plugin consumes.
113
+ *
114
+ * Declared locally for the same reason as {@link SessionNextEvent}: the host's
115
+ * `Event` union resolves to `any` unless `@opencode-ai/sdk` is installed, so
116
+ * inline casts at each call site are unchecked *and* invisible to a rename.
117
+ * Keeping the shapes here at least makes them one searchable definition.
118
+ */
119
+ export type SessionLifecycleEvent = {
120
+ type: "session.idle";
121
+ properties?: {
122
+ sessionID?: string;
123
+ };
124
+ } | {
125
+ type: "session.status";
126
+ properties?: {
127
+ sessionID?: string;
128
+ status?: {
129
+ type?: string;
130
+ };
131
+ };
132
+ } | {
133
+ type: "session.created";
134
+ properties?: {
135
+ info?: SessionInfo;
136
+ };
137
+ } | {
138
+ type: "session.updated";
139
+ properties?: {
140
+ info?: SessionInfo;
141
+ };
142
+ } | {
143
+ type: "session.deleted";
144
+ properties?: {
145
+ info?: {
146
+ id?: string;
147
+ };
148
+ };
149
+ } | {
150
+ type: "session.error";
151
+ properties?: {
152
+ sessionID?: string;
153
+ error?: SessionErrorInfo;
154
+ };
155
+ };
156
+ /**
157
+ * Resolves a session's parent sessionID via the OpenCode client API.
158
+ * Returns undefined when the session is a root, unknown, or the lookup fails.
159
+ */
160
+ export type SessionLookup = (sessionID: string) => Promise<string | undefined>;
99
161
  export type UserMessageInput = {
100
162
  sessionID: string;
101
163
  messageID?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softprobe/opencode-plugin",
3
- "version": "0.1.0",
3
+ "version": "0.1.5",
4
4
  "description": "OpenCode plugin that sends session telemetry to Softprobe",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -25,7 +25,7 @@
25
25
  "lint": "tsc -p tsconfig.json --noEmit"
26
26
  },
27
27
  "dependencies": {
28
- "@softprobe/tracing": "workspace:*"
28
+ "@softprobe/tracing": "0.1.5"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "@opencode-ai/plugin": ">=1.15.0",
@@ -45,7 +45,8 @@
45
45
  "vitest": "^3.0.8"
46
46
  },
47
47
  "publishConfig": {
48
- "access": "public"
48
+ "access": "public",
49
+ "provenance": true
49
50
  },
50
51
  "keywords": [
51
52
  "softprobe",