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