@raindrop-ai/opencode-plugin 0.0.1 → 0.0.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/dist/index.js CHANGED
@@ -28,7 +28,7 @@ function loadConfig(projectDirectory) {
28
28
  }
29
29
  return {
30
30
  writeKey: (_b = (_a = process.env["RAINDROP_WRITE_KEY"]) != null ? _a : merged.write_key) != null ? _b : "",
31
- endpoint: (_d = (_c = process.env["RAINDROP_API_URL"]) != null ? _c : merged.api_url) != null ? _d : "https://api.raindrop.ai",
31
+ endpoint: (_d = (_c = process.env["RAINDROP_API_URL"]) != null ? _c : merged.api_url) != null ? _d : "https://api.raindrop.ai/v1",
32
32
  eventName: (_e = merged.event_name) != null ? _e : "opencode_session",
33
33
  userId: merged.user_id,
34
34
  debug: process.env["RAINDROP_DEBUG"] === "true" ? true : (_f = merged.debug) != null ? _f : false
@@ -77,10 +77,11 @@ function base64Encode(bytes) {
77
77
  var SERVICE_NAME = "raindrop.opencode-plugin";
78
78
  var PLUGIN_VERSION = "0.1.0";
79
79
  var SpanStatusCode = {
80
- UNSET: 0,
81
- OK: 1,
82
80
  ERROR: 2
83
81
  };
82
+ function generateId() {
83
+ return base64Encode(randomBytes(12)).replace(/[+/=]/g, "").slice(0, 16);
84
+ }
84
85
  function createSpanIds(parent) {
85
86
  const traceId = parent ? parent.traceIdB64 : base64Encode(randomBytes(16));
86
87
  const spanId = base64Encode(randomBytes(8));
@@ -103,7 +104,11 @@ function attrInt(key, value) {
103
104
  return { key, value: { intValue: String(Math.trunc(value)) } };
104
105
  }
105
106
  function buildOtlpSpan(args) {
106
- const attrs = args.attributes.filter((x) => x !== void 0);
107
+ const attrs = [
108
+ attrString("span.id", args.ids.spanIdB64),
109
+ attrString("span.parent.id", args.ids.parentSpanIdB64),
110
+ ...args.attributes
111
+ ].filter((x) => x !== void 0);
107
112
  const span = {
108
113
  traceId: args.ids.traceIdB64,
109
114
  spanId: args.ids.spanIdB64,
@@ -173,9 +178,7 @@ async function withRetry(operation, opName, opts) {
173
178
  }
174
179
  if (delay > 0) await wait(delay);
175
180
  } else if (opts.debug) {
176
- console.log(
177
- `[raindrop-ai/opencode-plugin] ${opName} attempt ${attempt}/${opts.maxAttempts}`
178
- );
181
+ console.log(`[raindrop-ai/opencode-plugin] ${opName} attempt ${attempt}/${opts.maxAttempts}`);
179
182
  }
180
183
  try {
181
184
  return await operation();
@@ -409,6 +412,27 @@ var TraceShipper = class {
409
412
  });
410
413
  this.enqueue(otlp);
411
414
  }
415
+ /**
416
+ * Create and enqueue a span with both start and end times (atomic insert).
417
+ * Used for tool spans and any span whose full duration is known at write time.
418
+ */
419
+ createSpan(args) {
420
+ var _a;
421
+ const ids = createSpanIds(args.parent);
422
+ const attrs = [
423
+ attrString("ai.telemetry.metadata.raindrop.eventId", args.eventId)
424
+ ];
425
+ if ((_a = args.attributes) == null ? void 0 : _a.length) attrs.push(...args.attributes);
426
+ const otlp = buildOtlpSpan({
427
+ ids,
428
+ name: args.name,
429
+ startTimeUnixNano: args.startTimeUnixNano,
430
+ endTimeUnixNano: args.endTimeUnixNano,
431
+ attributes: attrs,
432
+ status: args.status
433
+ });
434
+ this.enqueue(otlp);
435
+ }
412
436
  enqueue(span) {
413
437
  if (this.queue.length >= this.maxQueueSize) this.queue.shift();
414
438
  this.queue.push(span);
@@ -434,10 +458,12 @@ var TraceShipper = class {
434
458
  const batch = this.queue.splice(0, this.maxBatchSize);
435
459
  const body = buildExportTraceServiceRequest(batch);
436
460
  const url = `${this.baseUrl}traces`;
461
+ const spanNames = batch.map((span) => span.name);
437
462
  if (this.debug) {
438
463
  console.log("[raindrop-ai/opencode-plugin] sending traces batch", {
439
464
  spans: batch.length,
440
- endpoint: url
465
+ endpoint: url,
466
+ spanNames
441
467
  });
442
468
  }
443
469
  const p = postJson(
@@ -451,14 +477,14 @@ var TraceShipper = class {
451
477
  try {
452
478
  await p;
453
479
  if (this.debug) {
454
- console.log(`[raindrop-ai/opencode-plugin] sent ${batch.length} spans`);
480
+ console.log(`[raindrop-ai/opencode-plugin] sent ${batch.length} spans`, {
481
+ spanNames
482
+ });
455
483
  }
456
484
  } catch (err) {
457
485
  if (this.debug) {
458
486
  const msg = err instanceof Error ? err.message : String(err);
459
- console.warn(
460
- `[raindrop-ai/opencode-plugin] failed to send spans (dropping): ${msg}`
461
- );
487
+ console.warn(`[raindrop-ai/opencode-plugin] failed to send spans (dropping): ${msg}`);
462
488
  }
463
489
  }
464
490
  } finally {
@@ -477,281 +503,458 @@ var TraceShipper = class {
477
503
  }
478
504
  };
479
505
 
506
+ // src/tracing-linkage-helpers.ts
507
+ function createSessionParentMapHelpers({
508
+ sessions: sessions2,
509
+ taskContexts: taskContexts2,
510
+ runningTaskCallsBySession: runningTaskCallsBySession2,
511
+ mapChildSessionToParent: mapChildSessionToParent2,
512
+ pendingChildSessionsByCallKey: pendingChildSessionsByCallKey2,
513
+ callKey: callKey2,
514
+ log
515
+ }) {
516
+ function applyChildSessionParentToState(childSessionId) {
517
+ const parentInfo = mapChildSessionToParent2.get(childSessionId);
518
+ const state = sessions2.get(childSessionId);
519
+ if (!parentInfo || !state) return;
520
+ state.parentId = parentInfo.parentId;
521
+ state.parentTaskSpanIds = parentInfo.parentTaskSpanIds;
522
+ state.parentEventContext = parentInfo.eventContext;
523
+ }
524
+ function queuePendingChildByCallKey(key, childSessionId) {
525
+ var _a;
526
+ const pending = (_a = pendingChildSessionsByCallKey2.get(key)) != null ? _a : /* @__PURE__ */ new Set();
527
+ pending.add(childSessionId);
528
+ pendingChildSessionsByCallKey2.set(key, pending);
529
+ }
530
+ function attachChildSessionToParentTask(childSessionId, parentId, parentCallId, source) {
531
+ const key = callKey2(parentId, parentCallId);
532
+ const ctx = taskContexts2.get(key);
533
+ if (!ctx) {
534
+ queuePendingChildByCallKey(key, childSessionId);
535
+ return false;
536
+ }
537
+ const parentInfo = {
538
+ parentId,
539
+ parentTaskSpanIds: ctx.ids,
540
+ eventContext: ctx.eventContext
541
+ };
542
+ mapChildSessionToParent2.set(childSessionId, parentInfo);
543
+ applyChildSessionParentToState(childSessionId);
544
+ return true;
545
+ }
546
+ function resolvePendingChildrenForCall(parentId, parentCallId, source) {
547
+ const key = callKey2(parentId, parentCallId);
548
+ const pending = pendingChildSessionsByCallKey2.get(key);
549
+ if (pending && pending.size > 0) {
550
+ for (const childSessionId of [...pending]) {
551
+ if (attachChildSessionToParentTask(childSessionId, parentId, parentCallId, source)) {
552
+ pending.delete(childSessionId);
553
+ }
554
+ }
555
+ if (pending.size === 0) pendingChildSessionsByCallKey2.delete(key);
556
+ }
557
+ }
558
+ function cleanupSessionParentMap(sessionId) {
559
+ mapChildSessionToParent2.delete(sessionId);
560
+ runningTaskCallsBySession2.delete(sessionId);
561
+ for (const [key, pending] of pendingChildSessionsByCallKey2.entries()) {
562
+ pending.delete(sessionId);
563
+ if (pending.size === 0) pendingChildSessionsByCallKey2.delete(key);
564
+ }
565
+ for (const key of [...taskContexts2.keys()]) {
566
+ if (key.startsWith(`${sessionId}:`)) taskContexts2.delete(key);
567
+ }
568
+ for (const [childSessionId, parentInfo] of [...mapChildSessionToParent2.entries()]) {
569
+ if (parentInfo.parentId === sessionId) {
570
+ mapChildSessionToParent2.delete(childSessionId);
571
+ }
572
+ }
573
+ }
574
+ return {
575
+ applyChildSessionParentToState,
576
+ attachChildSessionToParentTask,
577
+ cleanupSessionParentMap,
578
+ resolvePendingChildrenForCall
579
+ };
580
+ }
581
+
480
582
  // src/tracing.ts
481
583
  var sessions = /* @__PURE__ */ new Map();
584
+ var taskContexts = /* @__PURE__ */ new Map();
585
+ var runningTaskCallsBySession = /* @__PURE__ */ new Map();
586
+ var mapChildSessionToParent = /* @__PURE__ */ new Map();
587
+ var pendingChildSessionsByCallKey = /* @__PURE__ */ new Map();
588
+ function createSessionState(sessionId) {
589
+ return {
590
+ sessionId,
591
+ turnNumber: 0,
592
+ toolCount: 0,
593
+ currentInput: "",
594
+ outputParts: /* @__PURE__ */ new Map(),
595
+ reasoningParts: /* @__PURE__ */ new Map(),
596
+ toolSpanStarts: /* @__PURE__ */ new Map(),
597
+ processedMessages: /* @__PURE__ */ new Set()
598
+ };
599
+ }
600
+ function callKey(sessionId, callId) {
601
+ return `${sessionId}:${callId}`;
602
+ }
482
603
  function getHostname() {
483
- var _a, _b;
604
+ var _a;
484
605
  try {
485
606
  const bunGlobal = globalThis;
486
607
  if ((_a = bunGlobal.Bun) == null ? void 0 : _a.hostname) return bunGlobal.Bun.hostname;
487
608
  return __require("os").hostname();
488
609
  } catch (e) {
489
- return (_b = process.env["HOSTNAME"]) != null ? _b : "unknown";
610
+ const h = process.env["HOSTNAME"];
611
+ if (h === void 0 || h === "") throw new Error("HOSTNAME env is not set");
612
+ return h;
490
613
  }
491
614
  }
492
- function getProjectName(worktree) {
493
- var _a;
494
- const parts = worktree.split("/").filter(Boolean);
495
- return (_a = parts[parts.length - 1]) != null ? _a : "unknown";
496
- }
497
- function createHooks(client, config, worktree, directory, eventShipper, traceShipper) {
615
+ function createHooks(config, worktree, directory, eventShipper, traceShipper) {
498
616
  function log(msg, data) {
499
- var _a, _b;
500
- (_b = (_a = client.app.log({
501
- body: {
502
- service: "raindrop-opencode",
503
- level: config.debug ? "info" : "debug",
504
- message: msg,
505
- ...data !== void 0 ? { extra: data } : {}
506
- }
507
- })) == null ? void 0 : _a.catch) == null ? void 0 : _b.call(_a, () => {
508
- });
617
+ const level = config.debug ? "info" : "debug";
618
+ const prefix = `[raindrop-ai/opencode-plugin] [${level}] ${msg}`;
619
+ if (data !== void 0) {
620
+ console.log(prefix, data);
621
+ return;
622
+ }
623
+ console.log(prefix);
509
624
  }
510
- log("Raindrop tracing hooks registered");
625
+ const hostname = getHostname();
626
+ const os = process.platform;
627
+ const {
628
+ applyChildSessionParentToState,
629
+ attachChildSessionToParentTask,
630
+ cleanupSessionParentMap,
631
+ resolvePendingChildrenForCall
632
+ } = createSessionParentMapHelpers({
633
+ sessions,
634
+ taskContexts,
635
+ runningTaskCallsBySession,
636
+ mapChildSessionToParent,
637
+ pendingChildSessionsByCallKey,
638
+ callKey,
639
+ log
640
+ });
511
641
  return {
512
642
  // ------------------------------------------------------------------
513
643
  // event — session lifecycle + streaming parts
514
644
  // ------------------------------------------------------------------
515
- event: async ({
516
- event
517
- }) => {
518
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
645
+ event: async ({ event }) => {
646
+ var _a, _b, _c, _d, _e;
519
647
  try {
520
648
  const props = event.properties;
521
649
  const info = props["info"];
522
- const sessionID = props["sessionID"] || (info == null ? void 0 : info["id"]) || props["id"];
650
+ const sessionID = (_b = (_a = props["sessionID"]) != null ? _a : info == null ? void 0 : info["id"]) != null ? _b : props["id"];
651
+ const sessionEventTypes = ["session.created", "session.idle", "session.deleted", "session.error"];
652
+ if (sessionEventTypes.includes(event.type) && (sessionID === void 0 || sessionID === "")) {
653
+ throw new Error(`event ${event.type}: missing sessionID in properties`);
654
+ }
523
655
  if (event.type === "session.created") {
524
- if (!sessionID) {
525
- log("session.created: no session ID, skipping");
526
- return;
656
+ if (info == null || typeof info !== "object")
657
+ throw new Error("session.created: props.info is required");
658
+ const parentID = info["parentID"];
659
+ let state = sessions.get(sessionID);
660
+ if (!state) {
661
+ state = createSessionState(sessionID);
662
+ sessions.set(sessionID, state);
527
663
  }
528
- const userId = (_a = config.userId) != null ? _a : sessionID;
529
- const hostname = getHostname();
530
- const os = process.platform;
531
- const rootSpan = traceShipper.startSpan({
532
- name: `OpenCode: ${getProjectName(worktree)}`,
533
- eventId: sessionID,
534
- attributes: [
535
- attrString("workspace", worktree),
536
- attrString("directory", directory),
537
- attrString("hostname", hostname),
538
- attrString("os", os)
539
- ]
540
- });
541
- const state = {
542
- eventId: sessionID,
543
- rootSpan,
544
- turnNumber: 0,
545
- toolCount: 0,
546
- startTime: Date.now(),
547
- currentInput: "",
548
- currentOutput: "",
549
- outputParts: /* @__PURE__ */ new Map(),
550
- reasoningParts: /* @__PURE__ */ new Map(),
551
- toolCalls: /* @__PURE__ */ new Map(),
552
- toolStartTimes: /* @__PURE__ */ new Map(),
553
- toolSpans: /* @__PURE__ */ new Map(),
554
- toolCallMessageIds: /* @__PURE__ */ new Map(),
555
- processedMessages: /* @__PURE__ */ new Set(),
556
- attachments: []
557
- };
558
- sessions.set(sessionID, state);
559
- await eventShipper.patch(sessionID, {
560
- isPending: true,
561
- userId,
562
- convoId: sessionID,
563
- eventName: config.eventName,
564
- properties: {
565
- workspace: worktree,
566
- directory,
567
- hostname,
568
- os,
569
- turn_count: 0,
570
- tool_count: 0
571
- }
572
- });
573
- log("Session created", { sessionID, userId });
664
+ state.parentId = parentID;
665
+ if (state.parentId) mapChildSessionToParent.set(sessionID, { parentId: state.parentId });
574
666
  } else if (event.type === "message.part.updated") {
575
667
  const part = props["part"];
576
- if (!part) return;
577
- const partSessionID = part["sessionID"];
578
- const messageId = part["messageID"];
579
- if (!partSessionID || !messageId) return;
668
+ if (part == null || typeof part !== "object")
669
+ throw new Error("message.part.updated: props.part is required");
670
+ const partObj = part;
671
+ const partSessionID = partObj["sessionID"];
672
+ const messageId = partObj["messageID"];
673
+ if (partSessionID == null || partSessionID === "")
674
+ throw new Error("message.part.updated: part.sessionID is required");
675
+ if (messageId == null || messageId === "")
676
+ throw new Error("message.part.updated: part.messageID is required");
580
677
  const state = sessions.get(partSessionID);
581
678
  if (!state) return;
582
- if (part["type"] === "text" && typeof part["text"] === "string") {
583
- state.outputParts.set(messageId, part["text"]);
584
- } else if (part["type"] === "tool" && messageId) {
585
- const callID = part["callID"];
586
- const tool = part["tool"];
587
- const partState = part["state"];
588
- const input = partState == null ? void 0 : partState["input"];
589
- if (callID && tool && input) {
590
- let calls = state.toolCalls.get(messageId);
591
- if (!calls) {
592
- calls = [];
593
- state.toolCalls.set(messageId, calls);
679
+ if (partObj["type"] === "text" && typeof partObj["text"] === "string") {
680
+ state.outputParts.set(messageId, partObj["text"]);
681
+ } else if (partObj["type"] === "tool" && messageId) {
682
+ const tool = partObj["tool"];
683
+ if (tool === "task") {
684
+ const callID = partObj["callID"];
685
+ const partState = partObj["state"];
686
+ if (callID == null || callID === "")
687
+ throw new Error("message.part.updated: part.callID is required for tool part");
688
+ if (partState == null || typeof partState !== "object")
689
+ throw new Error("message.part.updated: part.state is required for tool part");
690
+ const metadata = partState["metadata"];
691
+ const childSessionId = metadata != null && typeof metadata === "object" ? metadata["sessionId"] : void 0;
692
+ if (childSessionId != null && childSessionId !== "") {
693
+ mapChildSessionToParent.set(childSessionId, {
694
+ parentId: partSessionID
695
+ });
696
+ attachChildSessionToParentTask(
697
+ childSessionId,
698
+ partSessionID,
699
+ callID,
700
+ "part.metadata"
701
+ );
594
702
  }
595
- const existingIdx = calls.findIndex((c) => c.callId === callID);
596
- const entry = { callId: callID, name: tool, args: JSON.stringify(input) };
597
- if (existingIdx >= 0) calls[existingIdx] = entry;
598
- else calls.push(entry);
599
- state.toolCallMessageIds.set(callID, messageId);
600
703
  }
601
- } else if (part["type"] === "reasoning" && typeof part["text"] === "string") {
602
- state.reasoningParts.set(messageId, part["text"]);
603
- } else {
604
- log(`message.part.updated: skipping part type=${String(part["type"])}`);
704
+ } else if (partObj["type"] === "reasoning" && typeof partObj["text"] === "string") {
705
+ state.reasoningParts.set(messageId, partObj["text"]);
605
706
  }
606
707
  } else if (event.type === "message.updated") {
607
708
  const msgInfo = props["info"];
608
- if (!msgInfo) return;
609
- const role = msgInfo["role"];
709
+ if (msgInfo == null || typeof msgInfo !== "object")
710
+ throw new Error("message.updated: props.info is required");
711
+ const info2 = msgInfo;
712
+ const role = info2["role"];
610
713
  if (role !== "assistant") return;
611
- const msgSessionID = msgInfo["sessionID"];
612
- const messageId = msgInfo["id"];
613
- const time = msgInfo["time"];
614
- if (!msgSessionID || !messageId || !(time == null ? void 0 : time["completed"])) return;
714
+ const msgSessionID = info2["sessionID"];
715
+ const messageId = info2["id"];
716
+ const time = info2["time"];
717
+ if (msgSessionID == null || msgSessionID === "")
718
+ throw new Error("message.updated: info.sessionID is required");
719
+ if (messageId == null || messageId === "")
720
+ throw new Error("message.updated: info.id is required");
721
+ if (time == null || typeof time !== "object")
722
+ throw new Error("message.updated: info.time is required");
723
+ const timeObj = time;
724
+ if (timeObj["completed"] == null) return;
615
725
  const state = sessions.get(msgSessionID);
616
- if (!state || !state.currentTurnSpan) return;
726
+ if (!state || !state.currentEventId || !state.currentRootSpan) return;
617
727
  if (state.processedMessages.has(messageId)) return;
618
728
  state.processedMessages.add(messageId);
619
- const tokens = msgInfo["tokens"];
620
- const inputTokens = (tokens == null ? void 0 : tokens["input"]) || 0;
621
- const outputTokens = (tokens == null ? void 0 : tokens["output"]) || 0;
622
- const reasoningTokens = (tokens == null ? void 0 : tokens["reasoning"]) || 0;
623
- const providerID = msgInfo["providerID"] || "unknown";
624
- const modelID = msgInfo["modelID"] || "unknown";
729
+ const tokens = info2["tokens"];
730
+ if (tokens == null || typeof tokens !== "object")
731
+ throw new Error("message.updated: info.tokens is required");
732
+ const tokensObj = tokens;
733
+ const inputTokens = tokensObj["input"];
734
+ const outputTokens = tokensObj["output"];
735
+ const reasoningTokens = tokensObj["reasoning"];
736
+ if (typeof inputTokens !== "number" || typeof outputTokens !== "number")
737
+ throw new Error("message.updated: tokens.input and tokens.output must be numbers");
738
+ const providerID = info2["providerID"];
739
+ const modelID = info2["modelID"];
740
+ if (typeof providerID !== "string" || providerID === "" || typeof modelID !== "string" || modelID === "")
741
+ throw new Error(
742
+ "message.updated: providerID and modelID are required non-empty strings"
743
+ );
625
744
  const modelName = `${providerID}/${modelID}`;
626
- state.model = modelName;
627
- const outputText = (_b = state.outputParts.get(messageId)) != null ? _b : "";
628
- state.currentOutput = outputText;
629
- const llmAttrs = [
630
- attrString("gen_ai.system", providerID),
631
- attrString("gen_ai.request.model", modelID),
632
- attrInt("gen_ai.usage.input_tokens", inputTokens),
633
- attrInt("gen_ai.usage.output_tokens", outputTokens)
634
- ];
635
- if (reasoningTokens > 0) {
636
- llmAttrs.push(attrInt("gen_ai.usage.reasoning_tokens", reasoningTokens));
637
- }
638
- const llmSpan = traceShipper.startSpan({
639
- name: modelName,
640
- parent: state.currentTurnSpan.ids,
641
- eventId: state.eventId,
642
- attributes: llmAttrs
643
- });
644
- if (typeof time["created"] === "number") {
645
- llmSpan.startTimeUnixNano = String(time["created"] * 1e6);
745
+ const finishReason = info2["finish"];
746
+ const outputText = state.outputParts.get(messageId);
747
+ const reasoningText = state.reasoningParts.get(messageId);
748
+ const isToolCallsCompletion = finishReason === "tool-calls";
749
+ const hasIntermediateReasoning = reasoningText !== void 0 && reasoningText.trim().length > 0;
750
+ if (outputText === void 0 && !(isToolCallsCompletion && hasIntermediateReasoning))
751
+ return;
752
+ if (isToolCallsCompletion && !hasIntermediateReasoning) {
753
+ await traceShipper.flush();
754
+ return;
646
755
  }
647
- const msgError = msgInfo["error"];
648
- const errorForSpan = msgError ? `${(_e = (_d = (_c = msgError.data) == null ? void 0 : _c.message) != null ? _d : msgError.name) != null ? _e : "Error"}
756
+ const msgError = info2["error"];
757
+ const errorForSpan = msgError ? (() => {
758
+ var _a2, _b2;
759
+ const msg = (_b2 = (_a2 = msgError.data) == null ? void 0 : _a2.message) != null ? _b2 : msgError.name;
760
+ const name = msgError.name;
761
+ if (msg == null && name == null)
762
+ throw new Error("message.updated: error object must have name or data.message");
763
+ return `${msg != null ? msg : name}
649
764
 
650
- type: ${(_f = msgError.name) != null ? _f : "UnknownError"}` : void 0;
651
- traceShipper.endSpan(llmSpan, { error: errorForSpan });
652
- await eventShipper.patch(state.eventId, {
653
- model: modelName,
654
- output: outputText || void 0
655
- });
656
- log("LLM span created", {
657
- messageId,
658
- modelName,
659
- inputTokens,
660
- outputTokens,
661
- reasoningTokens
662
- });
765
+ type: ${name != null ? name : "UnknownError"}`;
766
+ })() : void 0;
767
+ if (isToolCallsCompletion) {
768
+ if (reasoningText === void 0)
769
+ throw new Error("message.updated: reasoningParts missing for tool-call continuation");
770
+ const llmAttrs = [
771
+ attrString("ai.operationId", "generateText"),
772
+ attrString("ai.response.text", reasoningText),
773
+ attrString("gen_ai.system", providerID),
774
+ attrString("gen_ai.request.model", modelID),
775
+ attrInt("gen_ai.usage.input_tokens", inputTokens),
776
+ attrInt("gen_ai.usage.output_tokens", outputTokens)
777
+ ];
778
+ if (reasoningTokens != null && typeof reasoningTokens === "number" && reasoningTokens > 0) {
779
+ llmAttrs.push(attrInt("gen_ai.usage.reasoning_tokens", reasoningTokens));
780
+ }
781
+ const llmParent = state.parentId && state.parentTaskSpanIds ? state.parentTaskSpanIds : state.currentRootSpan.ids;
782
+ const llmSpan = traceShipper.startSpan({
783
+ name: modelName,
784
+ parent: llmParent,
785
+ eventId: state.currentEventId,
786
+ attributes: llmAttrs
787
+ });
788
+ if (typeof timeObj["created"] === "number") {
789
+ llmSpan.startTimeUnixNano = String(timeObj["created"] * 1e6);
790
+ }
791
+ traceShipper.endSpan(llmSpan, { error: errorForSpan });
792
+ await traceShipper.flush();
793
+ return;
794
+ }
795
+ if (outputText === void 0) return;
796
+ const rootSpan = state.currentRootSpan;
797
+ if (state.parentId) {
798
+ const llmAttrs = [
799
+ attrString("ai.operationId", "generateText"),
800
+ attrString("ai.prompt", state.currentInput),
801
+ attrString("ai.response.text", outputText),
802
+ attrString("gen_ai.system", providerID),
803
+ attrString("gen_ai.request.model", modelID),
804
+ attrInt("gen_ai.usage.input_tokens", inputTokens),
805
+ attrInt("gen_ai.usage.output_tokens", outputTokens)
806
+ ];
807
+ if (reasoningTokens != null && typeof reasoningTokens === "number" && reasoningTokens > 0) {
808
+ llmAttrs.push(attrInt("gen_ai.usage.reasoning_tokens", reasoningTokens));
809
+ }
810
+ if (!state.parentTaskSpanIds) {
811
+ traceShipper.endSpan(rootSpan, {
812
+ error: "Missing strict parent task for child session"
813
+ });
814
+ state.currentRootSpan = void 0;
815
+ state.currentEventId = void 0;
816
+ await traceShipper.flush();
817
+ return;
818
+ }
819
+ const finalLlmSpan = traceShipper.startSpan({
820
+ name: modelName,
821
+ parent: state.parentTaskSpanIds,
822
+ eventId: state.currentEventId,
823
+ attributes: llmAttrs
824
+ });
825
+ if (typeof timeObj["created"] === "number") {
826
+ finalLlmSpan.startTimeUnixNano = String(timeObj["created"] * 1e6);
827
+ }
828
+ traceShipper.endSpan(finalLlmSpan, { error: errorForSpan });
829
+ traceShipper.endSpan(rootSpan, {
830
+ attributes: [
831
+ attrString("is_subagent", "true"),
832
+ attrString("parent_session_id", state.parentId)
833
+ ],
834
+ error: errorForSpan
835
+ });
836
+ } else {
837
+ rootSpan.name = modelName;
838
+ const rootAttrs = [
839
+ attrString("ai.operationId", "generateText"),
840
+ attrString("ai.prompt", state.currentInput),
841
+ attrString("ai.response.text", outputText),
842
+ attrString("gen_ai.system", providerID),
843
+ attrString("gen_ai.request.model", modelID),
844
+ attrInt("gen_ai.usage.input_tokens", inputTokens),
845
+ attrInt("gen_ai.usage.output_tokens", outputTokens)
846
+ ];
847
+ if (reasoningTokens != null && typeof reasoningTokens === "number" && reasoningTokens > 0) {
848
+ rootAttrs.push(attrInt("gen_ai.usage.reasoning_tokens", reasoningTokens));
849
+ }
850
+ traceShipper.endSpan(rootSpan, {
851
+ attributes: rootAttrs,
852
+ error: errorForSpan
853
+ });
854
+ }
855
+ state.currentRootSpan = void 0;
856
+ const hasAssistantResponse = outputText.trim().length > 0;
857
+ if (!hasAssistantResponse) {
858
+ await traceShipper.flush();
859
+ state.currentEventId = void 0;
860
+ return;
861
+ }
862
+ if (!state.parentId) {
863
+ await eventShipper.finish(state.currentEventId, {
864
+ userId: state.sessionId,
865
+ model: modelName,
866
+ output: outputText,
867
+ properties: {
868
+ turn_count: state.turnNumber,
869
+ tool_count: state.toolCount
870
+ }
871
+ });
872
+ }
873
+ await traceShipper.flush();
874
+ state.currentEventId = void 0;
663
875
  } else if (event.type === "session.idle") {
664
876
  if (!sessionID) return;
665
877
  const state = sessions.get(String(sessionID));
666
878
  if (!state) return;
667
- if (state.currentTurnSpan) {
668
- traceShipper.endSpan(state.currentTurnSpan);
669
- state.currentTurnSpan = void 0;
879
+ if (state.currentRootSpan) {
880
+ traceShipper.endSpan(state.currentRootSpan);
881
+ state.currentRootSpan = void 0;
882
+ }
883
+ if (state.currentEventId) {
884
+ state.currentEventId = void 0;
885
+ }
886
+ await traceShipper.flush();
887
+ if (state.parentId) {
888
+ cleanupSessionParentMap(String(sessionID));
889
+ sessions.delete(String(sessionID));
890
+ } else {
670
891
  }
671
- await eventShipper.patch(state.eventId, {
672
- output: state.currentOutput || void 0,
673
- properties: {
674
- turn_count: state.turnNumber,
675
- tool_count: state.toolCount
676
- }
677
- });
678
- log("Session idle \u2014 turn span closed", { sessionID });
679
892
  } else if (event.type === "session.deleted") {
680
893
  if (!sessionID) return;
681
894
  const state = sessions.get(String(sessionID));
682
895
  if (!state) return;
683
- if (state.currentTurnSpan) {
684
- traceShipper.endSpan(state.currentTurnSpan);
685
- state.currentTurnSpan = void 0;
896
+ if (state.currentRootSpan) {
897
+ traceShipper.endSpan(state.currentRootSpan);
898
+ state.currentRootSpan = void 0;
686
899
  }
687
- traceShipper.endSpan(state.rootSpan);
688
- await eventShipper.finish(state.eventId, {
689
- output: state.currentOutput || void 0,
690
- model: state.model,
691
- properties: {
692
- turn_count: state.turnNumber,
693
- tool_count: state.toolCount
694
- }
695
- });
900
+ if (state.currentEventId) {
901
+ state.currentEventId = void 0;
902
+ }
903
+ cleanupSessionParentMap(String(sessionID));
696
904
  await Promise.all([eventShipper.flush(), traceShipper.flush()]);
697
905
  sessions.delete(String(sessionID));
698
- log("Session deleted \u2014 flushed", { sessionID });
699
906
  } else if (event.type === "session.error") {
700
- const errorSessionID = props["sessionID"] || sessionID;
701
- if (!errorSessionID) return;
907
+ const errorSessionID = (_c = props["sessionID"]) != null ? _c : sessionID;
908
+ if (errorSessionID === void 0 || errorSessionID === "")
909
+ throw new Error("session.error: missing sessionID");
702
910
  const state = sessions.get(String(errorSessionID));
703
911
  if (!state) return;
704
912
  const errorObj = props["error"];
705
- const errorName = (_g = errorObj == null ? void 0 : errorObj.name) != null ? _g : "UnknownError";
706
- const errorMessage = (_i = (_h = errorObj == null ? void 0 : errorObj.data) == null ? void 0 : _h.message) != null ? _i : errorName;
707
- const errorStr = `${errorMessage}
913
+ if (errorObj == null) throw new Error("session.error: missing error object");
914
+ const errorName = errorObj.name;
915
+ const errorMessage = (_e = (_d = errorObj.data) == null ? void 0 : _d.message) != null ? _e : errorObj.name;
916
+ if (errorName == null && errorMessage == null)
917
+ throw new Error("session.error: error object must have name or data.message");
918
+ const errorStr = `${errorMessage != null ? errorMessage : errorName}
708
919
 
709
- type: ${errorName}`;
710
- if (state.currentTurnSpan) {
711
- traceShipper.endSpan(state.currentTurnSpan, { error: errorStr });
712
- state.currentTurnSpan = void 0;
920
+ type: ${errorName != null ? errorName : "UnknownError"}`;
921
+ if (state.currentRootSpan) {
922
+ traceShipper.endSpan(state.currentRootSpan, { error: errorStr });
923
+ state.currentRootSpan = void 0;
713
924
  }
714
- traceShipper.endSpan(state.rootSpan, { error: errorStr });
715
- await eventShipper.finish(state.eventId, {
716
- properties: {
717
- error: errorStr,
718
- turn_count: state.turnNumber,
719
- tool_count: state.toolCount
720
- }
721
- });
925
+ if (state.currentEventId) {
926
+ state.currentEventId = void 0;
927
+ }
928
+ cleanupSessionParentMap(String(errorSessionID));
722
929
  await Promise.all([eventShipper.flush(), traceShipper.flush()]);
723
930
  sessions.delete(String(errorSessionID));
724
- log("Session error handled", { errorSessionID, errorName });
725
- } else {
726
- log(`unhandled event: ${event.type}`);
727
931
  }
728
932
  } catch (err) {
729
- (_k = (_j = client.app.log({
730
- body: {
731
- service: "raindrop-opencode",
732
- level: "error",
733
- message: `Error in event hook: ${err instanceof Error ? err.message : String(err)}`
734
- }
735
- })) == null ? void 0 : _j.catch) == null ? void 0 : _k.call(_j, () => {
736
- });
933
+ console.log(
934
+ `[raindrop-ai/opencode-plugin] [error] Error in event hook: ${err instanceof Error ? err.message : String(err)}`
935
+ );
737
936
  }
738
937
  },
739
938
  // ------------------------------------------------------------------
740
- // chat.message — user sent a message; start a new turn span
939
+ // chat.message — user sent a message; start a new turn with a fresh event ID
741
940
  // ------------------------------------------------------------------
742
941
  "chat.message": async (messageInput, output) => {
743
- var _a;
744
942
  try {
745
943
  const { sessionID } = messageInput;
746
- const state = sessions.get(sessionID);
747
- if (!state) return;
748
- if (state.currentTurnSpan) {
749
- traceShipper.endSpan(state.currentTurnSpan);
750
- state.currentTurnSpan = void 0;
944
+ let state = sessions.get(sessionID);
945
+ if (!state) {
946
+ state = createSessionState(sessionID);
947
+ sessions.set(sessionID, state);
948
+ }
949
+ applyChildSessionParentToState(sessionID);
950
+ if (state.currentRootSpan) {
951
+ traceShipper.endSpan(state.currentRootSpan);
952
+ state.currentRootSpan = void 0;
751
953
  }
752
954
  state.turnNumber++;
753
- state.currentOutput = "";
754
- const parts = (_a = output == null ? void 0 : output.parts) != null ? _a : [];
955
+ if (output == null) throw new Error("chat.message: output is required");
956
+ const parts = output.parts;
957
+ if (parts == null) throw new Error("chat.message: output.parts is required");
755
958
  const textParts = [];
756
959
  const newAttachments = [];
757
960
  for (const part of parts) {
@@ -762,84 +965,192 @@ type: ${errorName}`;
762
965
  const filename = part["filename"];
763
966
  const url = part["url"];
764
967
  if (url && (mimeType == null ? void 0 : mimeType.startsWith("image/"))) {
968
+ if (filename === void 0 || filename === "")
969
+ throw new Error("chat.message: file part must have filename");
765
970
  newAttachments.push({ type: "image", role: "input", name: filename, value: url });
766
971
  }
767
972
  }
768
973
  }
769
974
  const userText = textParts.join("\n");
770
975
  state.currentInput = userText;
771
- if (newAttachments.length > 0) state.attachments.push(...newAttachments);
772
- state.currentTurnSpan = traceShipper.startSpan({
773
- name: `Turn ${state.turnNumber}`,
774
- parent: state.rootSpan.ids,
775
- eventId: state.eventId,
776
- attributes: [attrInt("turn.number", state.turnNumber)]
777
- });
778
- await eventShipper.patch(state.eventId, {
779
- input: userText || void 0,
780
- attachments: newAttachments.length > 0 ? newAttachments : void 0,
781
- properties: { turn_count: state.turnNumber }
976
+ if (state.parentId) {
977
+ if (!state.parentTaskSpanIds || !state.parentEventContext) {
978
+ return;
979
+ }
980
+ state.currentEventId = state.parentEventContext.eventId;
981
+ state.currentRootSpan = traceShipper.startSpan({
982
+ name: "Subagent",
983
+ parent: state.parentTaskSpanIds,
984
+ eventId: state.currentEventId,
985
+ attributes: [
986
+ attrString("workspace", worktree),
987
+ attrString("directory", directory),
988
+ attrString("hostname", hostname),
989
+ attrString("os", os),
990
+ attrString("is_subagent", "true"),
991
+ attrString("parent_session_id", state.parentId)
992
+ ]
993
+ });
994
+ return;
995
+ }
996
+ state.currentEventId = generateId();
997
+ state.currentRootSpan = traceShipper.startSpan({
998
+ name: "ai.event",
999
+ eventId: state.currentEventId,
1000
+ attributes: [
1001
+ attrString("workspace", worktree),
1002
+ attrString("directory", directory),
1003
+ attrString("hostname", hostname),
1004
+ attrString("os", os)
1005
+ ]
782
1006
  });
783
- log("Chat message \u2014 new turn started", {
784
- sessionID,
785
- turnNumber: state.turnNumber,
786
- inputLength: userText.length,
787
- attachments: newAttachments.length
1007
+ await eventShipper.patch(state.currentEventId, {
1008
+ isPending: true,
1009
+ userId: state.sessionId,
1010
+ convoId: state.sessionId,
1011
+ eventName: config.eventName,
1012
+ input: userText,
1013
+ ...newAttachments.length > 0 ? { attachments: newAttachments } : {},
1014
+ properties: {
1015
+ workspace: worktree,
1016
+ directory,
1017
+ hostname,
1018
+ os,
1019
+ turn_count: state.turnNumber,
1020
+ tool_count: state.toolCount
1021
+ }
788
1022
  });
789
1023
  } catch (err) {
790
- log("Error in chat.message hook", { error: String(err) });
791
1024
  }
792
1025
  },
793
1026
  // ------------------------------------------------------------------
794
- // tool.execute.before — record start time and open tool span
1027
+ // tool.execute.before — record start time only (span created atomically in after)
1028
+ // For child sessions, tool spans are parented directly to the parent task span.
795
1029
  // ------------------------------------------------------------------
796
1030
  "tool.execute.before": async (toolInput, _output) => {
1031
+ var _a;
797
1032
  try {
798
1033
  const { tool, sessionID, callID } = toolInput;
799
1034
  const state = sessions.get(sessionID);
800
- if (!state || !state.currentTurnSpan) return;
801
- state.toolStartTimes.set(callID, Date.now());
802
- const toolSpan = traceShipper.startSpan({
803
- name: tool,
804
- parent: state.currentTurnSpan.ids,
805
- eventId: state.eventId,
806
- attributes: [attrString("tool.name", tool)]
807
- });
808
- state.toolSpans.set(callID, toolSpan);
809
- log("Tool execute before", { tool, sessionID, callID });
1035
+ if (!state || !state.currentEventId) return;
1036
+ const spanParent = state.parentId && state.parentTaskSpanIds ? state.parentTaskSpanIds : (_a = state.currentRootSpan) == null ? void 0 : _a.ids;
1037
+ if (!spanParent) return;
1038
+ const startTimeUnixNano = nowUnixNanoString();
1039
+ if (tool === "task") {
1040
+ const liveTaskSpan = traceShipper.startSpan({
1041
+ name: "ai.toolCall",
1042
+ parent: spanParent,
1043
+ eventId: state.currentEventId,
1044
+ attributes: [
1045
+ attrString("ai.operationId", "ai.toolCall"),
1046
+ attrString("ai.toolCall.name", tool),
1047
+ attrString("ai.toolCall.id", callID)
1048
+ ]
1049
+ });
1050
+ liveTaskSpan.startTimeUnixNano = startTimeUnixNano;
1051
+ const ctx = {
1052
+ ids: liveTaskSpan.ids,
1053
+ eventContext: {
1054
+ eventId: state.currentEventId,
1055
+ userId: state.sessionId,
1056
+ convoId: state.sessionId
1057
+ }
1058
+ };
1059
+ taskContexts.set(callKey(sessionID, callID), ctx);
1060
+ let running = runningTaskCallsBySession.get(sessionID);
1061
+ if (running == null) {
1062
+ running = /* @__PURE__ */ new Set();
1063
+ runningTaskCallsBySession.set(sessionID, running);
1064
+ }
1065
+ running.add(callID);
1066
+ resolvePendingChildrenForCall(sessionID, callID, "tool.execute.before");
1067
+ state.toolSpanStarts.set(callID, {
1068
+ startTimeUnixNano,
1069
+ parent: spanParent,
1070
+ eventId: state.currentEventId,
1071
+ name: tool,
1072
+ liveSpan: liveTaskSpan
1073
+ });
1074
+ } else {
1075
+ state.toolSpanStarts.set(callID, {
1076
+ startTimeUnixNano,
1077
+ parent: spanParent,
1078
+ eventId: state.currentEventId,
1079
+ name: tool
1080
+ });
1081
+ }
810
1082
  } catch (err) {
811
- log("Error in tool.execute.before hook", { error: String(err) });
812
1083
  }
813
1084
  },
814
1085
  // ------------------------------------------------------------------
815
- // tool.execute.after — finalize tool span
1086
+ // tool.execute.after — create tool span atomically with both start and end times
816
1087
  // ------------------------------------------------------------------
817
1088
  "tool.execute.after": async (toolInput, result) => {
818
- var _a;
819
1089
  try {
820
- const { tool, sessionID, callID } = toolInput;
1090
+ const { tool, sessionID, callID, args } = toolInput;
821
1091
  const state = sessions.get(sessionID);
822
- if (!state) return;
1092
+ if (!state || !state.currentEventId) return;
823
1093
  state.toolCount++;
824
- state.toolStartTimes.delete(callID);
825
- state.toolCallMessageIds.delete(callID);
826
- const toolSpan = state.toolSpans.get(callID);
827
- state.toolSpans.delete(callID);
828
- if (toolSpan) {
829
- const outputPreview = typeof result.output === "string" ? result.output.slice(0, 200) : JSON.stringify((_a = result.output) != null ? _a : "").slice(0, 200);
830
- traceShipper.endSpan(toolSpan, {
831
- attributes: [
832
- attrString("tool.title", result.title),
833
- attrString("tool.output_preview", outputPreview)
834
- ]
1094
+ if (args === void 0 || args === null)
1095
+ throw new Error("tool.execute.after: args is required");
1096
+ const toolCallArgs = typeof args === "string" ? args : JSON.stringify(args);
1097
+ const startInfo = state.toolSpanStarts.get(callID);
1098
+ state.toolSpanStarts.delete(callID);
1099
+ const resultMetadata = result.metadata;
1100
+ const childSessionId = resultMetadata == null ? void 0 : resultMetadata["sessionId"];
1101
+ if (tool === "task" && childSessionId) {
1102
+ mapChildSessionToParent.set(childSessionId, { parentId: sessionID });
1103
+ attachChildSessionToParentTask(childSessionId, sessionID, callID, "tool.execute.after");
1104
+ }
1105
+ if (startInfo) {
1106
+ const endTimeUnixNano = nowUnixNanoString();
1107
+ if (!("output" in result) || result.output === void 0)
1108
+ throw new Error("tool.execute.after: result.output is required");
1109
+ let toolResult;
1110
+ try {
1111
+ toolResult = JSON.stringify(result.output);
1112
+ } catch (err) {
1113
+ const msg = err instanceof Error ? err.message : String(err);
1114
+ throw new Error(`tool.execute.after: result.output must be JSON-serializable: ${msg}`);
1115
+ }
1116
+ if (startInfo.liveSpan) {
1117
+ traceShipper.endSpan(startInfo.liveSpan, {
1118
+ attributes: [
1119
+ attrString("ai.toolCall.args", toolCallArgs),
1120
+ attrString("ai.toolCall.result", toolResult)
1121
+ ]
1122
+ });
1123
+ } else {
1124
+ traceShipper.createSpan({
1125
+ name: "ai.toolCall",
1126
+ parent: startInfo.parent,
1127
+ eventId: startInfo.eventId,
1128
+ startTimeUnixNano: startInfo.startTimeUnixNano,
1129
+ endTimeUnixNano,
1130
+ attributes: [
1131
+ attrString("ai.operationId", "ai.toolCall"),
1132
+ attrString("ai.toolCall.name", tool),
1133
+ attrString("ai.toolCall.id", callID),
1134
+ attrString("ai.toolCall.args", toolCallArgs),
1135
+ attrString("ai.toolCall.result", toolResult)
1136
+ ]
1137
+ });
1138
+ }
1139
+ }
1140
+ if (tool === "task") {
1141
+ const running = runningTaskCallsBySession.get(sessionID);
1142
+ if (running) {
1143
+ running.delete(callID);
1144
+ if (running.size === 0) runningTaskCallsBySession.delete(sessionID);
1145
+ }
1146
+ }
1147
+ if (!state.parentId) {
1148
+ await eventShipper.patch(state.currentEventId, {
1149
+ userId: state.sessionId,
1150
+ properties: { tool_count: state.toolCount }
835
1151
  });
836
1152
  }
837
- await eventShipper.patch(state.eventId, {
838
- properties: { tool_count: state.toolCount }
839
- });
840
- log("Tool execute after", { tool, sessionID, callID });
841
1153
  } catch (err) {
842
- log("Error in tool.execute.after hook", { error: String(err) });
843
1154
  }
844
1155
  }
845
1156
  };
@@ -848,25 +1159,16 @@ type: ${errorName}`;
848
1159
  // src/index.ts
849
1160
  async function plugin(input) {
850
1161
  var _a;
851
- const { client } = input;
852
1162
  const config = loadConfig(input.directory);
853
1163
  function appLog(level, message) {
854
- var _a2, _b;
855
- (_b = (_a2 = client.app.log({
856
- body: {
857
- service: "raindrop-opencode",
858
- level,
859
- message
860
- }
861
- })) == null ? void 0 : _a2.catch) == null ? void 0 : _b.call(_a2, () => {
862
- });
1164
+ console.log(`[raindrop-ai/opencode-plugin] [${level}] ${message}`);
863
1165
  }
864
1166
  if (!config.writeKey) {
865
1167
  appLog(
866
1168
  "warn",
867
1169
  "RAINDROP_WRITE_KEY not set \u2014 Raindrop tracing disabled. Set it in your environment or in ~/.config/opencode/raindrop.json."
868
1170
  );
869
- return { hooks: {} };
1171
+ return {};
870
1172
  }
871
1173
  if (config.debug) {
872
1174
  appLog("info", `Raindrop tracing enabled (endpoint: ${config.endpoint})`);
@@ -883,14 +1185,13 @@ async function plugin(input) {
883
1185
  });
884
1186
  const worktree = (_a = input.worktree) != null ? _a : input.directory;
885
1187
  const hooks = createHooks(
886
- client,
887
1188
  config,
888
1189
  worktree,
889
1190
  input.directory,
890
1191
  eventShipper,
891
1192
  traceShipper
892
1193
  );
893
- return { hooks };
1194
+ return hooks;
894
1195
  }
895
1196
  export {
896
1197
  plugin as default