@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.js CHANGED
@@ -77,8 +77,6 @@ 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
  };
84
82
  function generateId() {
@@ -106,7 +104,11 @@ function attrInt(key, value) {
106
104
  return { key, value: { intValue: String(Math.trunc(value)) } };
107
105
  }
108
106
  function buildOtlpSpan(args) {
109
- 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);
110
112
  const span = {
111
113
  traceId: args.ids.traceIdB64,
112
114
  spanId: args.ids.spanIdB64,
@@ -176,9 +178,7 @@ async function withRetry(operation, opName, opts) {
176
178
  }
177
179
  if (delay > 0) await wait(delay);
178
180
  } else if (opts.debug) {
179
- console.log(
180
- `[raindrop-ai/opencode-plugin] ${opName} attempt ${attempt}/${opts.maxAttempts}`
181
- );
181
+ console.log(`[raindrop-ai/opencode-plugin] ${opName} attempt ${attempt}/${opts.maxAttempts}`);
182
182
  }
183
183
  try {
184
184
  return await operation();
@@ -458,10 +458,12 @@ var TraceShipper = class {
458
458
  const batch = this.queue.splice(0, this.maxBatchSize);
459
459
  const body = buildExportTraceServiceRequest(batch);
460
460
  const url = `${this.baseUrl}traces`;
461
+ const spanNames = batch.map((span) => span.name);
461
462
  if (this.debug) {
462
463
  console.log("[raindrop-ai/opencode-plugin] sending traces batch", {
463
464
  spans: batch.length,
464
- endpoint: url
465
+ endpoint: url,
466
+ spanNames
465
467
  });
466
468
  }
467
469
  const p = postJson(
@@ -475,14 +477,14 @@ var TraceShipper = class {
475
477
  try {
476
478
  await p;
477
479
  if (this.debug) {
478
- console.log(`[raindrop-ai/opencode-plugin] sent ${batch.length} spans`);
480
+ console.log(`[raindrop-ai/opencode-plugin] sent ${batch.length} spans`, {
481
+ spanNames
482
+ });
479
483
  }
480
484
  } catch (err) {
481
485
  if (this.debug) {
482
486
  const msg = err instanceof Error ? err.message : String(err);
483
- console.warn(
484
- `[raindrop-ai/opencode-plugin] failed to send spans (dropping): ${msg}`
485
- );
487
+ console.warn(`[raindrop-ai/opencode-plugin] failed to send spans (dropping): ${msg}`);
486
488
  }
487
489
  }
488
490
  } finally {
@@ -501,328 +503,458 @@ var TraceShipper = class {
501
503
  }
502
504
  };
503
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
+
504
582
  // src/tracing.ts
505
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
+ }
506
603
  function getHostname() {
507
- var _a, _b;
604
+ var _a;
508
605
  try {
509
606
  const bunGlobal = globalThis;
510
607
  if ((_a = bunGlobal.Bun) == null ? void 0 : _a.hostname) return bunGlobal.Bun.hostname;
511
608
  return __require("os").hostname();
512
609
  } catch (e) {
513
- 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;
514
613
  }
515
614
  }
516
- function getProjectName(worktree) {
517
- var _a;
518
- const parts = worktree.split("/").filter(Boolean);
519
- return (_a = parts[parts.length - 1]) != null ? _a : "unknown";
520
- }
521
- function createHooks(client, config, worktree, directory, eventShipper, traceShipper) {
615
+ function createHooks(config, worktree, directory, eventShipper, traceShipper) {
522
616
  function log(msg, data) {
523
- var _a, _b;
524
- (_b = (_a = client.app.log({
525
- body: {
526
- service: "raindrop-opencode",
527
- level: config.debug ? "info" : "debug",
528
- message: msg,
529
- ...data !== void 0 ? { extra: data } : {}
530
- }
531
- })) == null ? void 0 : _a.catch) == null ? void 0 : _b.call(_a, () => {
532
- });
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);
533
624
  }
534
625
  const hostname = getHostname();
535
626
  const os = process.platform;
536
- log("Raindrop tracing hooks registered");
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
+ });
537
641
  return {
538
642
  // ------------------------------------------------------------------
539
643
  // event — session lifecycle + streaming parts
540
644
  // ------------------------------------------------------------------
541
- event: async ({
542
- event
543
- }) => {
544
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k;
645
+ event: async ({ event }) => {
646
+ var _a, _b, _c, _d, _e;
545
647
  try {
546
648
  const props = event.properties;
547
649
  const info = props["info"];
548
- 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
+ }
549
655
  if (event.type === "session.created") {
550
- if (!sessionID) {
551
- log("session.created: no session ID, skipping");
552
- 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);
553
663
  }
554
- const userId = (_a = config.userId) != null ? _a : sessionID;
555
- const state = {
556
- sessionId: sessionID,
557
- userId,
558
- turnNumber: 0,
559
- toolCount: 0,
560
- startTime: Date.now(),
561
- currentInput: "",
562
- currentOutput: "",
563
- outputParts: /* @__PURE__ */ new Map(),
564
- reasoningParts: /* @__PURE__ */ new Map(),
565
- toolCalls: /* @__PURE__ */ new Map(),
566
- toolStartTimes: /* @__PURE__ */ new Map(),
567
- toolSpanStarts: /* @__PURE__ */ new Map(),
568
- toolCallMessageIds: /* @__PURE__ */ new Map(),
569
- processedMessages: /* @__PURE__ */ new Set(),
570
- attachments: []
571
- };
572
- state.currentRootSpan = traceShipper.startSpan({
573
- name: `OpenCode: ${getProjectName(worktree)}`,
574
- eventId: sessionID,
575
- attributes: [
576
- attrString("workspace", worktree),
577
- attrString("directory", directory),
578
- attrString("hostname", hostname),
579
- attrString("os", os)
580
- ]
581
- });
582
- sessions.set(sessionID, state);
583
- log("Session created", { sessionID, userId });
664
+ state.parentId = parentID;
665
+ if (state.parentId) mapChildSessionToParent.set(sessionID, { parentId: state.parentId });
584
666
  } else if (event.type === "message.part.updated") {
585
667
  const part = props["part"];
586
- if (!part) return;
587
- const partSessionID = part["sessionID"];
588
- const messageId = part["messageID"];
589
- 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");
590
677
  const state = sessions.get(partSessionID);
591
678
  if (!state) return;
592
- if (part["type"] === "text" && typeof part["text"] === "string") {
593
- state.outputParts.set(messageId, part["text"]);
594
- } else if (part["type"] === "tool" && messageId) {
595
- const callID = part["callID"];
596
- const tool = part["tool"];
597
- const partState = part["state"];
598
- const input = partState == null ? void 0 : partState["input"];
599
- if (callID && tool && input) {
600
- let calls = state.toolCalls.get(messageId);
601
- if (!calls) {
602
- calls = [];
603
- 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
+ );
604
702
  }
605
- const existingIdx = calls.findIndex((c) => c.callId === callID);
606
- const entry = { callId: callID, name: tool, args: JSON.stringify(input) };
607
- if (existingIdx >= 0) calls[existingIdx] = entry;
608
- else calls.push(entry);
609
- state.toolCallMessageIds.set(callID, messageId);
610
703
  }
611
- } else if (part["type"] === "reasoning" && typeof part["text"] === "string") {
612
- state.reasoningParts.set(messageId, part["text"]);
613
- } else {
614
- 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"]);
615
706
  }
616
707
  } else if (event.type === "message.updated") {
617
708
  const msgInfo = props["info"];
618
- if (!msgInfo) return;
619
- 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"];
620
713
  if (role !== "assistant") return;
621
- const msgSessionID = msgInfo["sessionID"];
622
- const messageId = msgInfo["id"];
623
- const time = msgInfo["time"];
624
- 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;
625
725
  const state = sessions.get(msgSessionID);
626
- if (!state || !state.currentEventId || !state.currentTurnSpan) return;
726
+ if (!state || !state.currentEventId || !state.currentRootSpan) return;
627
727
  if (state.processedMessages.has(messageId)) return;
628
728
  state.processedMessages.add(messageId);
629
- const tokens = msgInfo["tokens"];
630
- const inputTokens = (tokens == null ? void 0 : tokens["input"]) || 0;
631
- const outputTokens = (tokens == null ? void 0 : tokens["output"]) || 0;
632
- const reasoningTokens = (tokens == null ? void 0 : tokens["reasoning"]) || 0;
633
- const providerID = msgInfo["providerID"] || "unknown";
634
- 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
+ );
635
744
  const modelName = `${providerID}/${modelID}`;
636
- state.model = modelName;
637
- const outputText = (_b = state.outputParts.get(messageId)) != null ? _b : "";
638
- state.currentOutput = outputText;
639
- const llmAttrs = [
640
- attrString("gen_ai.system", providerID),
641
- attrString("gen_ai.request.model", modelID),
642
- attrInt("gen_ai.usage.input_tokens", inputTokens),
643
- attrInt("gen_ai.usage.output_tokens", outputTokens)
644
- ];
645
- if (reasoningTokens > 0) {
646
- llmAttrs.push(attrInt("gen_ai.usage.reasoning_tokens", reasoningTokens));
647
- }
648
- const llmSpan = traceShipper.startSpan({
649
- name: modelName,
650
- parent: state.currentTurnSpan.ids,
651
- eventId: state.currentEventId,
652
- attributes: llmAttrs
653
- });
654
- if (typeof time["created"] === "number") {
655
- 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;
656
755
  }
657
- const msgError = msgInfo["error"];
658
- 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}
659
764
 
660
- type: ${(_f = msgError.name) != null ? _f : "UnknownError"}` : void 0;
661
- traceShipper.endSpan(llmSpan, { error: errorForSpan });
662
- await eventShipper.finish(state.currentEventId, {
663
- userId: state.userId,
664
- model: modelName,
665
- output: outputText || void 0,
666
- properties: {
667
- turn_count: state.turnNumber,
668
- tool_count: state.toolCount
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));
669
780
  }
670
- });
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
+ }
671
873
  await traceShipper.flush();
672
- log("LLM span created, turn/root close on session.idle", {
673
- messageId,
674
- modelName,
675
- inputTokens,
676
- outputTokens,
677
- reasoningTokens
678
- });
874
+ state.currentEventId = void 0;
679
875
  } else if (event.type === "session.idle") {
680
876
  if (!sessionID) return;
681
877
  const state = sessions.get(String(sessionID));
682
878
  if (!state) return;
683
- if (state.currentTurnSpan) {
684
- traceShipper.endSpan(state.currentTurnSpan);
685
- state.currentTurnSpan = void 0;
686
- }
687
879
  if (state.currentRootSpan) {
688
880
  traceShipper.endSpan(state.currentRootSpan);
689
881
  state.currentRootSpan = void 0;
690
882
  }
691
883
  if (state.currentEventId) {
692
- await eventShipper.finish(state.currentEventId, {
693
- userId: state.userId,
694
- output: state.currentOutput || void 0,
695
- properties: {
696
- turn_count: state.turnNumber,
697
- tool_count: state.toolCount
698
- }
699
- });
700
884
  state.currentEventId = void 0;
701
885
  }
702
886
  await traceShipper.flush();
703
- log("Session idle \u2014 turn and root closed", { sessionID });
887
+ if (state.parentId) {
888
+ cleanupSessionParentMap(String(sessionID));
889
+ sessions.delete(String(sessionID));
890
+ } else {
891
+ }
704
892
  } else if (event.type === "session.deleted") {
705
893
  if (!sessionID) return;
706
894
  const state = sessions.get(String(sessionID));
707
895
  if (!state) return;
708
- if (state.currentTurnSpan) {
709
- traceShipper.endSpan(state.currentTurnSpan);
710
- state.currentTurnSpan = void 0;
711
- }
712
896
  if (state.currentRootSpan) {
713
897
  traceShipper.endSpan(state.currentRootSpan);
714
898
  state.currentRootSpan = void 0;
715
899
  }
716
900
  if (state.currentEventId) {
717
- await eventShipper.finish(state.currentEventId, {
718
- userId: state.userId,
719
- output: state.currentOutput || void 0,
720
- model: state.model,
721
- properties: {
722
- turn_count: state.turnNumber,
723
- tool_count: state.toolCount
724
- }
725
- });
726
901
  state.currentEventId = void 0;
727
902
  }
903
+ cleanupSessionParentMap(String(sessionID));
728
904
  await Promise.all([eventShipper.flush(), traceShipper.flush()]);
729
905
  sessions.delete(String(sessionID));
730
- log("Session deleted \u2014 flushed", { sessionID });
731
906
  } else if (event.type === "session.error") {
732
- const errorSessionID = props["sessionID"] || sessionID;
733
- 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");
734
910
  const state = sessions.get(String(errorSessionID));
735
911
  if (!state) return;
736
912
  const errorObj = props["error"];
737
- const errorName = (_g = errorObj == null ? void 0 : errorObj.name) != null ? _g : "UnknownError";
738
- const errorMessage = (_i = (_h = errorObj == null ? void 0 : errorObj.data) == null ? void 0 : _h.message) != null ? _i : errorName;
739
- 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}
740
919
 
741
- type: ${errorName}`;
742
- if (state.currentTurnSpan) {
743
- traceShipper.endSpan(state.currentTurnSpan, { error: errorStr });
744
- state.currentTurnSpan = void 0;
745
- }
920
+ type: ${errorName != null ? errorName : "UnknownError"}`;
746
921
  if (state.currentRootSpan) {
747
922
  traceShipper.endSpan(state.currentRootSpan, { error: errorStr });
748
923
  state.currentRootSpan = void 0;
749
924
  }
750
925
  if (state.currentEventId) {
751
- await eventShipper.finish(state.currentEventId, {
752
- userId: state.userId,
753
- properties: {
754
- error: errorStr,
755
- turn_count: state.turnNumber,
756
- tool_count: state.toolCount
757
- }
758
- });
759
926
  state.currentEventId = void 0;
760
927
  }
928
+ cleanupSessionParentMap(String(errorSessionID));
761
929
  await Promise.all([eventShipper.flush(), traceShipper.flush()]);
762
930
  sessions.delete(String(errorSessionID));
763
- log("Session error handled", { errorSessionID, errorName });
764
- } else {
765
- log(`unhandled event: ${event.type}`);
766
931
  }
767
932
  } catch (err) {
768
- (_k = (_j = client.app.log({
769
- body: {
770
- service: "raindrop-opencode",
771
- level: "error",
772
- message: `Error in event hook: ${err instanceof Error ? err.message : String(err)}`
773
- }
774
- })) == null ? void 0 : _j.catch) == null ? void 0 : _k.call(_j, () => {
775
- });
933
+ console.log(
934
+ `[raindrop-ai/opencode-plugin] [error] Error in event hook: ${err instanceof Error ? err.message : String(err)}`
935
+ );
776
936
  }
777
937
  },
778
938
  // ------------------------------------------------------------------
779
939
  // chat.message — user sent a message; start a new turn with a fresh event ID
780
940
  // ------------------------------------------------------------------
781
941
  "chat.message": async (messageInput, output) => {
782
- var _a, _b;
783
942
  try {
784
943
  const { sessionID } = messageInput;
785
944
  let state = sessions.get(sessionID);
786
945
  if (!state) {
787
- state = {
788
- sessionId: sessionID,
789
- userId: (_a = config.userId) != null ? _a : sessionID,
790
- turnNumber: 0,
791
- toolCount: 0,
792
- startTime: Date.now(),
793
- currentInput: "",
794
- currentOutput: "",
795
- outputParts: /* @__PURE__ */ new Map(),
796
- reasoningParts: /* @__PURE__ */ new Map(),
797
- toolCalls: /* @__PURE__ */ new Map(),
798
- toolStartTimes: /* @__PURE__ */ new Map(),
799
- toolSpanStarts: /* @__PURE__ */ new Map(),
800
- toolCallMessageIds: /* @__PURE__ */ new Map(),
801
- processedMessages: /* @__PURE__ */ new Set(),
802
- attachments: []
803
- };
946
+ state = createSessionState(sessionID);
804
947
  sessions.set(sessionID, state);
805
948
  }
806
- if (state.currentTurnSpan) {
807
- traceShipper.endSpan(state.currentTurnSpan);
808
- state.currentTurnSpan = void 0;
809
- }
810
- if (!state.currentRootSpan) {
811
- state.currentRootSpan = traceShipper.startSpan({
812
- name: `OpenCode: ${getProjectName(worktree)}`,
813
- eventId: sessionID,
814
- attributes: [
815
- attrString("workspace", worktree),
816
- attrString("directory", directory),
817
- attrString("hostname", hostname),
818
- attrString("os", os)
819
- ]
820
- });
949
+ applyChildSessionParentToState(sessionID);
950
+ if (state.currentRootSpan) {
951
+ traceShipper.endSpan(state.currentRootSpan);
952
+ state.currentRootSpan = void 0;
821
953
  }
822
954
  state.turnNumber++;
823
- state.currentOutput = "";
824
- state.currentEventId = generateId();
825
- const parts = (_b = output == null ? void 0 : output.parts) != null ? _b : [];
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");
826
958
  const textParts = [];
827
959
  const newAttachments = [];
828
960
  for (const part of parts) {
@@ -833,26 +965,52 @@ type: ${errorName}`;
833
965
  const filename = part["filename"];
834
966
  const url = part["url"];
835
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");
836
970
  newAttachments.push({ type: "image", role: "input", name: filename, value: url });
837
971
  }
838
972
  }
839
973
  }
840
974
  const userText = textParts.join("\n");
841
975
  state.currentInput = userText;
842
- if (newAttachments.length > 0) state.attachments.push(...newAttachments);
843
- state.currentTurnSpan = traceShipper.startSpan({
844
- name: `Turn ${state.turnNumber}`,
845
- parent: state.currentRootSpan.ids,
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",
846
999
  eventId: state.currentEventId,
847
- attributes: [attrInt("turn.number", state.turnNumber)]
1000
+ attributes: [
1001
+ attrString("workspace", worktree),
1002
+ attrString("directory", directory),
1003
+ attrString("hostname", hostname),
1004
+ attrString("os", os)
1005
+ ]
848
1006
  });
849
1007
  await eventShipper.patch(state.currentEventId, {
850
1008
  isPending: true,
851
- userId: state.userId,
1009
+ userId: state.sessionId,
852
1010
  convoId: state.sessionId,
853
1011
  eventName: config.eventName,
854
- input: userText || void 0,
855
- attachments: newAttachments.length > 0 ? newAttachments : void 0,
1012
+ input: userText,
1013
+ ...newAttachments.length > 0 ? { attachments: newAttachments } : {},
856
1014
  properties: {
857
1015
  workspace: worktree,
858
1016
  directory,
@@ -862,74 +1020,137 @@ type: ${errorName}`;
862
1020
  tool_count: state.toolCount
863
1021
  }
864
1022
  });
865
- log("Chat message \u2014 new turn started", {
866
- sessionID,
867
- eventId: state.currentEventId,
868
- turnNumber: state.turnNumber,
869
- inputLength: userText.length,
870
- attachments: newAttachments.length
871
- });
872
1023
  } catch (err) {
873
- log("Error in chat.message hook", { error: String(err) });
874
1024
  }
875
1025
  },
876
1026
  // ------------------------------------------------------------------
877
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.
878
1029
  // ------------------------------------------------------------------
879
1030
  "tool.execute.before": async (toolInput, _output) => {
1031
+ var _a;
880
1032
  try {
881
1033
  const { tool, sessionID, callID } = toolInput;
882
1034
  const state = sessions.get(sessionID);
883
- if (!state || !state.currentTurnSpan || !state.currentEventId) return;
884
- state.toolStartTimes.set(callID, Date.now());
885
- state.toolSpanStarts.set(callID, {
886
- startTimeUnixNano: nowUnixNanoString(),
887
- parent: state.currentTurnSpan.ids,
888
- eventId: state.currentEventId,
889
- name: tool
890
- });
891
- 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
+ }
892
1082
  } catch (err) {
893
- log("Error in tool.execute.before hook", { error: String(err) });
894
1083
  }
895
1084
  },
896
1085
  // ------------------------------------------------------------------
897
1086
  // tool.execute.after — create tool span atomically with both start and end times
898
1087
  // ------------------------------------------------------------------
899
1088
  "tool.execute.after": async (toolInput, result) => {
900
- var _a;
901
1089
  try {
902
- const { tool, sessionID, callID } = toolInput;
1090
+ const { tool, sessionID, callID, args } = toolInput;
903
1091
  const state = sessions.get(sessionID);
904
1092
  if (!state || !state.currentEventId) return;
905
1093
  state.toolCount++;
906
- state.toolStartTimes.delete(callID);
907
- state.toolCallMessageIds.delete(callID);
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);
908
1097
  const startInfo = state.toolSpanStarts.get(callID);
909
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
+ }
910
1105
  if (startInfo) {
911
1106
  const endTimeUnixNano = nowUnixNanoString();
912
- const outputPreview = typeof result.output === "string" ? result.output.slice(0, 200) : JSON.stringify((_a = result.output) != null ? _a : "").slice(0, 200);
913
- traceShipper.createSpan({
914
- name: startInfo.name,
915
- parent: startInfo.parent,
916
- eventId: startInfo.eventId,
917
- startTimeUnixNano: startInfo.startTimeUnixNano,
918
- endTimeUnixNano,
919
- attributes: [
920
- attrString("tool.name", tool),
921
- attrString("tool.title", result.title),
922
- attrString("tool.output_preview", outputPreview)
923
- ]
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 }
924
1151
  });
925
1152
  }
926
- await eventShipper.patch(state.currentEventId, {
927
- userId: state.userId,
928
- properties: { tool_count: state.toolCount }
929
- });
930
- log("Tool execute after", { tool, sessionID, callID });
931
1153
  } catch (err) {
932
- log("Error in tool.execute.after hook", { error: String(err) });
933
1154
  }
934
1155
  }
935
1156
  };
@@ -938,18 +1159,9 @@ type: ${errorName}`;
938
1159
  // src/index.ts
939
1160
  async function plugin(input) {
940
1161
  var _a;
941
- const { client } = input;
942
1162
  const config = loadConfig(input.directory);
943
1163
  function appLog(level, message) {
944
- var _a2, _b;
945
- (_b = (_a2 = client.app.log({
946
- body: {
947
- service: "raindrop-opencode",
948
- level,
949
- message
950
- }
951
- })) == null ? void 0 : _a2.catch) == null ? void 0 : _b.call(_a2, () => {
952
- });
1164
+ console.log(`[raindrop-ai/opencode-plugin] [${level}] ${message}`);
953
1165
  }
954
1166
  if (!config.writeKey) {
955
1167
  appLog(
@@ -973,7 +1185,6 @@ async function plugin(input) {
973
1185
  });
974
1186
  const worktree = (_a = input.worktree) != null ? _a : input.directory;
975
1187
  const hooks = createHooks(
976
- client,
977
1188
  config,
978
1189
  worktree,
979
1190
  input.directory,