@raindrop-ai/opencode-plugin 0.0.2 → 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.
Files changed (3) hide show
  1. package/dist/index.cjs +507 -296
  2. package/dist/index.js +507 -296
  3. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -96,8 +96,6 @@ 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
  };
103
101
  function generateId() {
@@ -125,7 +123,11 @@ function attrInt(key, value) {
125
123
  return { key, value: { intValue: String(Math.trunc(value)) } };
126
124
  }
127
125
  function buildOtlpSpan(args) {
128
- 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);
129
131
  const span = {
130
132
  traceId: args.ids.traceIdB64,
131
133
  spanId: args.ids.spanIdB64,
@@ -195,9 +197,7 @@ async function withRetry(operation, opName, opts) {
195
197
  }
196
198
  if (delay > 0) await wait(delay);
197
199
  } else if (opts.debug) {
198
- console.log(
199
- `[raindrop-ai/opencode-plugin] ${opName} attempt ${attempt}/${opts.maxAttempts}`
200
- );
200
+ console.log(`[raindrop-ai/opencode-plugin] ${opName} attempt ${attempt}/${opts.maxAttempts}`);
201
201
  }
202
202
  try {
203
203
  return await operation();
@@ -477,10 +477,12 @@ var TraceShipper = class {
477
477
  const batch = this.queue.splice(0, this.maxBatchSize);
478
478
  const body = buildExportTraceServiceRequest(batch);
479
479
  const url = `${this.baseUrl}traces`;
480
+ const spanNames = batch.map((span) => span.name);
480
481
  if (this.debug) {
481
482
  console.log("[raindrop-ai/opencode-plugin] sending traces batch", {
482
483
  spans: batch.length,
483
- endpoint: url
484
+ endpoint: url,
485
+ spanNames
484
486
  });
485
487
  }
486
488
  const p = postJson(
@@ -494,14 +496,14 @@ var TraceShipper = class {
494
496
  try {
495
497
  await p;
496
498
  if (this.debug) {
497
- console.log(`[raindrop-ai/opencode-plugin] sent ${batch.length} spans`);
499
+ console.log(`[raindrop-ai/opencode-plugin] sent ${batch.length} spans`, {
500
+ spanNames
501
+ });
498
502
  }
499
503
  } catch (err) {
500
504
  if (this.debug) {
501
505
  const msg = err instanceof Error ? err.message : String(err);
502
- console.warn(
503
- `[raindrop-ai/opencode-plugin] failed to send spans (dropping): ${msg}`
504
- );
506
+ console.warn(`[raindrop-ai/opencode-plugin] failed to send spans (dropping): ${msg}`);
505
507
  }
506
508
  }
507
509
  } finally {
@@ -520,328 +522,458 @@ var TraceShipper = class {
520
522
  }
521
523
  };
522
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
+
523
601
  // src/tracing.ts
524
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
+ }
525
622
  function getHostname() {
526
- var _a, _b;
623
+ var _a;
527
624
  try {
528
625
  const bunGlobal = globalThis;
529
626
  if ((_a = bunGlobal.Bun) == null ? void 0 : _a.hostname) return bunGlobal.Bun.hostname;
530
627
  return require("os").hostname();
531
628
  } catch (e) {
532
- 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;
533
632
  }
534
633
  }
535
- function getProjectName(worktree) {
536
- var _a;
537
- const parts = worktree.split("/").filter(Boolean);
538
- return (_a = parts[parts.length - 1]) != null ? _a : "unknown";
539
- }
540
- function createHooks(client, config, worktree, directory, eventShipper, traceShipper) {
634
+ function createHooks(config, worktree, directory, eventShipper, traceShipper) {
541
635
  function log(msg, data) {
542
- var _a, _b;
543
- (_b = (_a = client.app.log({
544
- body: {
545
- service: "raindrop-opencode",
546
- level: config.debug ? "info" : "debug",
547
- message: msg,
548
- ...data !== void 0 ? { extra: data } : {}
549
- }
550
- })) == null ? void 0 : _a.catch) == null ? void 0 : _b.call(_a, () => {
551
- });
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);
552
643
  }
553
644
  const hostname = getHostname();
554
645
  const os = process.platform;
555
- log("Raindrop tracing hooks registered");
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
+ });
556
660
  return {
557
661
  // ------------------------------------------------------------------
558
662
  // event — session lifecycle + streaming parts
559
663
  // ------------------------------------------------------------------
560
- event: async ({
561
- event
562
- }) => {
563
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
664
+ event: async ({ event }) => {
665
+ var _a, _b, _c, _d, _e;
564
666
  try {
565
667
  const props = event.properties;
566
668
  const info = props["info"];
567
- 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
+ }
568
674
  if (event.type === "session.created") {
569
- if (!sessionID) {
570
- log("session.created: no session ID, skipping");
571
- 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);
572
682
  }
573
- const userId = (_a = config.userId) != null ? _a : sessionID;
574
- const state = {
575
- sessionId: sessionID,
576
- userId,
577
- turnNumber: 0,
578
- toolCount: 0,
579
- startTime: Date.now(),
580
- currentInput: "",
581
- currentOutput: "",
582
- outputParts: /* @__PURE__ */ new Map(),
583
- reasoningParts: /* @__PURE__ */ new Map(),
584
- toolCalls: /* @__PURE__ */ new Map(),
585
- toolStartTimes: /* @__PURE__ */ new Map(),
586
- toolSpanStarts: /* @__PURE__ */ new Map(),
587
- toolCallMessageIds: /* @__PURE__ */ new Map(),
588
- processedMessages: /* @__PURE__ */ new Set(),
589
- attachments: []
590
- };
591
- state.currentRootSpan = traceShipper.startSpan({
592
- name: `OpenCode: ${getProjectName(worktree)}`,
593
- eventId: sessionID,
594
- attributes: [
595
- attrString("workspace", worktree),
596
- attrString("directory", directory),
597
- attrString("hostname", hostname),
598
- attrString("os", os)
599
- ]
600
- });
601
- sessions.set(sessionID, state);
602
- log("Session created", { sessionID, userId });
683
+ state.parentId = parentID;
684
+ if (state.parentId) mapChildSessionToParent.set(sessionID, { parentId: state.parentId });
603
685
  } else if (event.type === "message.part.updated") {
604
686
  const part = props["part"];
605
- if (!part) return;
606
- const partSessionID = part["sessionID"];
607
- const messageId = part["messageID"];
608
- 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");
609
696
  const state = sessions.get(partSessionID);
610
697
  if (!state) return;
611
- if (part["type"] === "text" && typeof part["text"] === "string") {
612
- state.outputParts.set(messageId, part["text"]);
613
- } else if (part["type"] === "tool" && messageId) {
614
- const callID = part["callID"];
615
- const tool = part["tool"];
616
- const partState = part["state"];
617
- const input = partState == null ? void 0 : partState["input"];
618
- if (callID && tool && input) {
619
- let calls = state.toolCalls.get(messageId);
620
- if (!calls) {
621
- calls = [];
622
- 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
+ );
623
721
  }
624
- const existingIdx = calls.findIndex((c) => c.callId === callID);
625
- const entry = { callId: callID, name: tool, args: JSON.stringify(input) };
626
- if (existingIdx >= 0) calls[existingIdx] = entry;
627
- else calls.push(entry);
628
- state.toolCallMessageIds.set(callID, messageId);
629
722
  }
630
- } else if (part["type"] === "reasoning" && typeof part["text"] === "string") {
631
- state.reasoningParts.set(messageId, part["text"]);
632
- } else {
633
- 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"]);
634
725
  }
635
726
  } else if (event.type === "message.updated") {
636
727
  const msgInfo = props["info"];
637
- if (!msgInfo) return;
638
- 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"];
639
732
  if (role !== "assistant") return;
640
- const msgSessionID = msgInfo["sessionID"];
641
- const messageId = msgInfo["id"];
642
- const time = msgInfo["time"];
643
- 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;
644
744
  const state = sessions.get(msgSessionID);
645
- if (!state || !state.currentEventId || !state.currentTurnSpan) return;
745
+ if (!state || !state.currentEventId || !state.currentRootSpan) return;
646
746
  if (state.processedMessages.has(messageId)) return;
647
747
  state.processedMessages.add(messageId);
648
- const tokens = msgInfo["tokens"];
649
- const inputTokens = (tokens == null ? void 0 : tokens["input"]) || 0;
650
- const outputTokens = (tokens == null ? void 0 : tokens["output"]) || 0;
651
- const reasoningTokens = (tokens == null ? void 0 : tokens["reasoning"]) || 0;
652
- const providerID = msgInfo["providerID"] || "unknown";
653
- 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
+ );
654
763
  const modelName = `${providerID}/${modelID}`;
655
- state.model = modelName;
656
- const outputText = (_b = state.outputParts.get(messageId)) != null ? _b : "";
657
- state.currentOutput = outputText;
658
- const llmAttrs = [
659
- attrString("gen_ai.system", providerID),
660
- attrString("gen_ai.request.model", modelID),
661
- attrInt("gen_ai.usage.input_tokens", inputTokens),
662
- attrInt("gen_ai.usage.output_tokens", outputTokens)
663
- ];
664
- if (reasoningTokens > 0) {
665
- llmAttrs.push(attrInt("gen_ai.usage.reasoning_tokens", reasoningTokens));
666
- }
667
- const llmSpan = traceShipper.startSpan({
668
- name: modelName,
669
- parent: state.currentTurnSpan.ids,
670
- eventId: state.currentEventId,
671
- attributes: llmAttrs
672
- });
673
- if (typeof time["created"] === "number") {
674
- 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;
675
774
  }
676
- const msgError = msgInfo["error"];
677
- 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}
678
783
 
679
- type: ${(_f = msgError.name) != null ? _f : "UnknownError"}` : void 0;
680
- traceShipper.endSpan(llmSpan, { error: errorForSpan });
681
- await eventShipper.finish(state.currentEventId, {
682
- userId: state.userId,
683
- model: modelName,
684
- output: outputText || void 0,
685
- properties: {
686
- turn_count: state.turnNumber,
687
- tool_count: state.toolCount
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));
688
799
  }
689
- });
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
+ }
690
892
  await traceShipper.flush();
691
- log("LLM span created, turn/root close on session.idle", {
692
- messageId,
693
- modelName,
694
- inputTokens,
695
- outputTokens,
696
- reasoningTokens
697
- });
893
+ state.currentEventId = void 0;
698
894
  } else if (event.type === "session.idle") {
699
895
  if (!sessionID) return;
700
896
  const state = sessions.get(String(sessionID));
701
897
  if (!state) return;
702
- if (state.currentTurnSpan) {
703
- traceShipper.endSpan(state.currentTurnSpan);
704
- state.currentTurnSpan = void 0;
705
- }
706
898
  if (state.currentRootSpan) {
707
899
  traceShipper.endSpan(state.currentRootSpan);
708
900
  state.currentRootSpan = void 0;
709
901
  }
710
902
  if (state.currentEventId) {
711
- await eventShipper.finish(state.currentEventId, {
712
- userId: state.userId,
713
- output: state.currentOutput || void 0,
714
- properties: {
715
- turn_count: state.turnNumber,
716
- tool_count: state.toolCount
717
- }
718
- });
719
903
  state.currentEventId = void 0;
720
904
  }
721
905
  await traceShipper.flush();
722
- log("Session idle \u2014 turn and root closed", { sessionID });
906
+ if (state.parentId) {
907
+ cleanupSessionParentMap(String(sessionID));
908
+ sessions.delete(String(sessionID));
909
+ } else {
910
+ }
723
911
  } else if (event.type === "session.deleted") {
724
912
  if (!sessionID) return;
725
913
  const state = sessions.get(String(sessionID));
726
914
  if (!state) return;
727
- if (state.currentTurnSpan) {
728
- traceShipper.endSpan(state.currentTurnSpan);
729
- state.currentTurnSpan = void 0;
730
- }
731
915
  if (state.currentRootSpan) {
732
916
  traceShipper.endSpan(state.currentRootSpan);
733
917
  state.currentRootSpan = void 0;
734
918
  }
735
919
  if (state.currentEventId) {
736
- await eventShipper.finish(state.currentEventId, {
737
- userId: state.userId,
738
- output: state.currentOutput || void 0,
739
- model: state.model,
740
- properties: {
741
- turn_count: state.turnNumber,
742
- tool_count: state.toolCount
743
- }
744
- });
745
920
  state.currentEventId = void 0;
746
921
  }
922
+ cleanupSessionParentMap(String(sessionID));
747
923
  await Promise.all([eventShipper.flush(), traceShipper.flush()]);
748
924
  sessions.delete(String(sessionID));
749
- log("Session deleted \u2014 flushed", { sessionID });
750
925
  } else if (event.type === "session.error") {
751
- const errorSessionID = props["sessionID"] || sessionID;
752
- 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");
753
929
  const state = sessions.get(String(errorSessionID));
754
930
  if (!state) return;
755
931
  const errorObj = props["error"];
756
- const errorName = (_g = errorObj == null ? void 0 : errorObj.name) != null ? _g : "UnknownError";
757
- const errorMessage = (_i = (_h = errorObj == null ? void 0 : errorObj.data) == null ? void 0 : _h.message) != null ? _i : errorName;
758
- 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}
759
938
 
760
- type: ${errorName}`;
761
- if (state.currentTurnSpan) {
762
- traceShipper.endSpan(state.currentTurnSpan, { error: errorStr });
763
- state.currentTurnSpan = void 0;
764
- }
939
+ type: ${errorName != null ? errorName : "UnknownError"}`;
765
940
  if (state.currentRootSpan) {
766
941
  traceShipper.endSpan(state.currentRootSpan, { error: errorStr });
767
942
  state.currentRootSpan = void 0;
768
943
  }
769
944
  if (state.currentEventId) {
770
- await eventShipper.finish(state.currentEventId, {
771
- userId: state.userId,
772
- properties: {
773
- error: errorStr,
774
- turn_count: state.turnNumber,
775
- tool_count: state.toolCount
776
- }
777
- });
778
945
  state.currentEventId = void 0;
779
946
  }
947
+ cleanupSessionParentMap(String(errorSessionID));
780
948
  await Promise.all([eventShipper.flush(), traceShipper.flush()]);
781
949
  sessions.delete(String(errorSessionID));
782
- log("Session error handled", { errorSessionID, errorName });
783
- } else {
784
- log(`unhandled event: ${event.type}`);
785
950
  }
786
951
  } catch (err) {
787
- (_k = (_j = client.app.log({
788
- body: {
789
- service: "raindrop-opencode",
790
- level: "error",
791
- message: `Error in event hook: ${err instanceof Error ? err.message : String(err)}`
792
- }
793
- })) == null ? void 0 : _j.catch) == null ? void 0 : _k.call(_j, () => {
794
- });
952
+ console.log(
953
+ `[raindrop-ai/opencode-plugin] [error] Error in event hook: ${err instanceof Error ? err.message : String(err)}`
954
+ );
795
955
  }
796
956
  },
797
957
  // ------------------------------------------------------------------
798
958
  // chat.message — user sent a message; start a new turn with a fresh event ID
799
959
  // ------------------------------------------------------------------
800
960
  "chat.message": async (messageInput, output) => {
801
- var _a, _b;
802
961
  try {
803
962
  const { sessionID } = messageInput;
804
963
  let state = sessions.get(sessionID);
805
964
  if (!state) {
806
- state = {
807
- sessionId: sessionID,
808
- userId: (_a = config.userId) != null ? _a : sessionID,
809
- turnNumber: 0,
810
- toolCount: 0,
811
- startTime: Date.now(),
812
- currentInput: "",
813
- currentOutput: "",
814
- outputParts: /* @__PURE__ */ new Map(),
815
- reasoningParts: /* @__PURE__ */ new Map(),
816
- toolCalls: /* @__PURE__ */ new Map(),
817
- toolStartTimes: /* @__PURE__ */ new Map(),
818
- toolSpanStarts: /* @__PURE__ */ new Map(),
819
- toolCallMessageIds: /* @__PURE__ */ new Map(),
820
- processedMessages: /* @__PURE__ */ new Set(),
821
- attachments: []
822
- };
965
+ state = createSessionState(sessionID);
823
966
  sessions.set(sessionID, state);
824
967
  }
825
- if (state.currentTurnSpan) {
826
- traceShipper.endSpan(state.currentTurnSpan);
827
- state.currentTurnSpan = void 0;
828
- }
829
- if (!state.currentRootSpan) {
830
- state.currentRootSpan = traceShipper.startSpan({
831
- name: `OpenCode: ${getProjectName(worktree)}`,
832
- eventId: sessionID,
833
- attributes: [
834
- attrString("workspace", worktree),
835
- attrString("directory", directory),
836
- attrString("hostname", hostname),
837
- attrString("os", os)
838
- ]
839
- });
968
+ applyChildSessionParentToState(sessionID);
969
+ if (state.currentRootSpan) {
970
+ traceShipper.endSpan(state.currentRootSpan);
971
+ state.currentRootSpan = void 0;
840
972
  }
841
973
  state.turnNumber++;
842
- state.currentOutput = "";
843
- state.currentEventId = generateId();
844
- const parts = (_b = output == null ? void 0 : output.parts) != null ? _b : [];
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");
845
977
  const textParts = [];
846
978
  const newAttachments = [];
847
979
  for (const part of parts) {
@@ -852,26 +984,52 @@ type: ${errorName}`;
852
984
  const filename = part["filename"];
853
985
  const url = part["url"];
854
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");
855
989
  newAttachments.push({ type: "image", role: "input", name: filename, value: url });
856
990
  }
857
991
  }
858
992
  }
859
993
  const userText = textParts.join("\n");
860
994
  state.currentInput = userText;
861
- if (newAttachments.length > 0) state.attachments.push(...newAttachments);
862
- state.currentTurnSpan = traceShipper.startSpan({
863
- name: `Turn ${state.turnNumber}`,
864
- parent: state.currentRootSpan.ids,
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",
865
1018
  eventId: state.currentEventId,
866
- attributes: [attrInt("turn.number", state.turnNumber)]
1019
+ attributes: [
1020
+ attrString("workspace", worktree),
1021
+ attrString("directory", directory),
1022
+ attrString("hostname", hostname),
1023
+ attrString("os", os)
1024
+ ]
867
1025
  });
868
1026
  await eventShipper.patch(state.currentEventId, {
869
1027
  isPending: true,
870
- userId: state.userId,
1028
+ userId: state.sessionId,
871
1029
  convoId: state.sessionId,
872
1030
  eventName: config.eventName,
873
- input: userText || void 0,
874
- attachments: newAttachments.length > 0 ? newAttachments : void 0,
1031
+ input: userText,
1032
+ ...newAttachments.length > 0 ? { attachments: newAttachments } : {},
875
1033
  properties: {
876
1034
  workspace: worktree,
877
1035
  directory,
@@ -881,74 +1039,137 @@ type: ${errorName}`;
881
1039
  tool_count: state.toolCount
882
1040
  }
883
1041
  });
884
- log("Chat message \u2014 new turn started", {
885
- sessionID,
886
- eventId: state.currentEventId,
887
- turnNumber: state.turnNumber,
888
- inputLength: userText.length,
889
- attachments: newAttachments.length
890
- });
891
1042
  } catch (err) {
892
- log("Error in chat.message hook", { error: String(err) });
893
1043
  }
894
1044
  },
895
1045
  // ------------------------------------------------------------------
896
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.
897
1048
  // ------------------------------------------------------------------
898
1049
  "tool.execute.before": async (toolInput, _output) => {
1050
+ var _a;
899
1051
  try {
900
1052
  const { tool, sessionID, callID } = toolInput;
901
1053
  const state = sessions.get(sessionID);
902
- if (!state || !state.currentTurnSpan || !state.currentEventId) return;
903
- state.toolStartTimes.set(callID, Date.now());
904
- state.toolSpanStarts.set(callID, {
905
- startTimeUnixNano: nowUnixNanoString(),
906
- parent: state.currentTurnSpan.ids,
907
- eventId: state.currentEventId,
908
- name: tool
909
- });
910
- 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
+ }
911
1101
  } catch (err) {
912
- log("Error in tool.execute.before hook", { error: String(err) });
913
1102
  }
914
1103
  },
915
1104
  // ------------------------------------------------------------------
916
1105
  // tool.execute.after — create tool span atomically with both start and end times
917
1106
  // ------------------------------------------------------------------
918
1107
  "tool.execute.after": async (toolInput, result) => {
919
- var _a;
920
1108
  try {
921
- const { tool, sessionID, callID } = toolInput;
1109
+ const { tool, sessionID, callID, args } = toolInput;
922
1110
  const state = sessions.get(sessionID);
923
1111
  if (!state || !state.currentEventId) return;
924
1112
  state.toolCount++;
925
- state.toolStartTimes.delete(callID);
926
- state.toolCallMessageIds.delete(callID);
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);
927
1116
  const startInfo = state.toolSpanStarts.get(callID);
928
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
+ }
929
1124
  if (startInfo) {
930
1125
  const endTimeUnixNano = nowUnixNanoString();
931
- const outputPreview = typeof result.output === "string" ? result.output.slice(0, 200) : JSON.stringify((_a = result.output) != null ? _a : "").slice(0, 200);
932
- traceShipper.createSpan({
933
- name: startInfo.name,
934
- parent: startInfo.parent,
935
- eventId: startInfo.eventId,
936
- startTimeUnixNano: startInfo.startTimeUnixNano,
937
- endTimeUnixNano,
938
- attributes: [
939
- attrString("tool.name", tool),
940
- attrString("tool.title", result.title),
941
- attrString("tool.output_preview", outputPreview)
942
- ]
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 }
943
1170
  });
944
1171
  }
945
- await eventShipper.patch(state.currentEventId, {
946
- userId: state.userId,
947
- properties: { tool_count: state.toolCount }
948
- });
949
- log("Tool execute after", { tool, sessionID, callID });
950
1172
  } catch (err) {
951
- log("Error in tool.execute.after hook", { error: String(err) });
952
1173
  }
953
1174
  }
954
1175
  };
@@ -957,18 +1178,9 @@ type: ${errorName}`;
957
1178
  // src/index.ts
958
1179
  async function plugin(input) {
959
1180
  var _a;
960
- const { client } = input;
961
1181
  const config = loadConfig(input.directory);
962
1182
  function appLog(level, message) {
963
- var _a2, _b;
964
- (_b = (_a2 = client.app.log({
965
- body: {
966
- service: "raindrop-opencode",
967
- level,
968
- message
969
- }
970
- })) == null ? void 0 : _a2.catch) == null ? void 0 : _b.call(_a2, () => {
971
- });
1183
+ console.log(`[raindrop-ai/opencode-plugin] [${level}] ${message}`);
972
1184
  }
973
1185
  if (!config.writeKey) {
974
1186
  appLog(
@@ -992,7 +1204,6 @@ async function plugin(input) {
992
1204
  });
993
1205
  const worktree = (_a = input.worktree) != null ? _a : input.directory;
994
1206
  const hooks = createHooks(
995
- client,
996
1207
  config,
997
1208
  worktree,
998
1209
  input.directory,