@softprobe/opencode-plugin 0.1.1 → 0.1.6

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.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Session relationship data for OpenCode sub-agent nesting.
3
+ *
4
+ * Pure bookkeeping — no spans, no I/O, no timers. The tracer consults this
5
+ * graph to decide whether a session is a sub-agent child and, if so, which
6
+ * task tool call in the parent session dispatched it.
7
+ *
8
+ * Binding sources, in order of authority:
9
+ * 1. `bind(source: "metadata")` from the task tool part's
10
+ * `state.metadata.sessionId`. OpenCode publishes this while the part is
11
+ * still running, before the child session starts — exact and safe under
12
+ * concurrent task calls.
13
+ * 2. Task args `task_id` (session resume) — the child session is named
14
+ * explicitly, matched in {@link resolveTaskCall}.
15
+ * 3. Heuristic inference from parentID + task call candidates (unique
16
+ * candidate → agent name → prompt text), for OpenCode versions that do
17
+ * not publish part metadata. Never resolves on ambiguity: a wrong
18
+ * nesting is silent data corruption, a missing one is only a missing
19
+ * edge.
20
+ */
21
+ /** Window in which an ended task call stays a valid inference candidate. */
22
+ const RECENTLY_ENDED_TASK_MS = 15 * 60 * 1000;
23
+ /** Ended task calls are pruned after a day; active ones are never pruned. */
24
+ const ENDED_TASK_CALL_TTL_MS = 24 * 60 * 60 * 1000;
25
+ /** Safety cap on remembered task calls (active + recently ended). */
26
+ const MAX_TASK_CALLS = 500;
27
+ /** Extract the task-tool args relevant for child-session binding. */
28
+ export function parseTaskCallArgs(args) {
29
+ if (!args || typeof args !== "object")
30
+ return {};
31
+ const record = args;
32
+ const out = {};
33
+ if (typeof record.prompt === "string")
34
+ out.prompt = record.prompt;
35
+ if (typeof record.subagent_type === "string") {
36
+ out.subagent_type = record.subagent_type;
37
+ }
38
+ if (typeof record.task_id === "string")
39
+ out.task_id = record.task_id;
40
+ return out;
41
+ }
42
+ export class SessionGraph {
43
+ /** child sessionID → parent sessionID (from session events or API lookup). */
44
+ parents = new Map();
45
+ /** Sessions known to have no parent. */
46
+ roots = new Set();
47
+ /** child sessionID → dispatching task call. */
48
+ bindings = new Map();
49
+ /** task callID → child sessionID (reverse of bindings). */
50
+ childByTaskCall = new Map();
51
+ /** task callID → call record (args for inference, times for recency). */
52
+ taskCalls = new Map();
53
+ registerParent(childSessionID, parentSessionID) {
54
+ if (childSessionID === parentSessionID)
55
+ return;
56
+ this.roots.delete(childSessionID);
57
+ this.parents.set(childSessionID, parentSessionID);
58
+ }
59
+ markRoot(sessionID) {
60
+ if (this.parents.has(sessionID) || this.bindings.has(sessionID))
61
+ return;
62
+ this.roots.add(sessionID);
63
+ }
64
+ isRoot(sessionID) {
65
+ return this.roots.has(sessionID);
66
+ }
67
+ parentOf(sessionID) {
68
+ return this.parents.get(sessionID);
69
+ }
70
+ registerTaskCall(callID, sessionID, args) {
71
+ const existing = this.taskCalls.get(callID);
72
+ if (existing) {
73
+ existing.args = { ...existing.args, ...parseTaskCallArgs(args) };
74
+ return;
75
+ }
76
+ this.taskCalls.set(callID, {
77
+ callID,
78
+ sessionID,
79
+ args: parseTaskCallArgs(args),
80
+ startedAt: Date.now(),
81
+ });
82
+ this.pruneTaskCalls();
83
+ }
84
+ endTaskCall(callID) {
85
+ const task = this.taskCalls.get(callID);
86
+ if (task && task.endedAt === undefined)
87
+ task.endedAt = Date.now();
88
+ }
89
+ bind(binding) {
90
+ if (binding.childSessionID === binding.parentSessionID)
91
+ return;
92
+ const existing = this.bindings.get(binding.childSessionID);
93
+ if (existing?.source === "metadata" && binding.source === "inferred") {
94
+ return;
95
+ }
96
+ if (existing && existing.taskCallID !== binding.taskCallID) {
97
+ this.childByTaskCall.delete(existing.taskCallID);
98
+ }
99
+ this.roots.delete(binding.childSessionID);
100
+ this.parents.set(binding.childSessionID, binding.parentSessionID);
101
+ this.bindings.set(binding.childSessionID, binding);
102
+ this.childByTaskCall.set(binding.taskCallID, binding.childSessionID);
103
+ }
104
+ bindingFor(childSessionID) {
105
+ return this.bindings.get(childSessionID);
106
+ }
107
+ childForTaskCall(callID) {
108
+ return this.childByTaskCall.get(callID);
109
+ }
110
+ /**
111
+ * Infer which task call in `parentSessionID` dispatched `childSessionID`.
112
+ * Returns undefined on any ambiguity — never guesses.
113
+ */
114
+ resolveTaskCall(childSessionID, parentSessionID, hints = {}) {
115
+ const now = Date.now();
116
+ const candidates = [...this.taskCalls.values()].filter((task) => task.sessionID === parentSessionID && this.isCandidate(task, now));
117
+ if (candidates.length === 0)
118
+ return undefined;
119
+ // task_id resume names the child session explicitly — unambiguous even
120
+ // with several candidates.
121
+ const resumed = candidates.filter((task) => task.args.task_id === childSessionID);
122
+ if (resumed.length > 0) {
123
+ return resumed.reduce((a, b) => (a.startedAt >= b.startedAt ? a : b))
124
+ .callID;
125
+ }
126
+ if (candidates.length === 1) {
127
+ const only = candidates[0];
128
+ // A lone candidate that contradicts the hints is not a match — the
129
+ // child was likely dispatched by a call we never saw.
130
+ if (hints.agent &&
131
+ only.args.subagent_type &&
132
+ only.args.subagent_type !== hints.agent) {
133
+ return undefined;
134
+ }
135
+ if (hints.prompt && only.args.prompt && only.args.prompt !== hints.prompt) {
136
+ return undefined;
137
+ }
138
+ return only.callID;
139
+ }
140
+ if (hints.agent) {
141
+ const byAgent = candidates.filter((task) => task.args.subagent_type === hints.agent);
142
+ if (byAgent.length === 1)
143
+ return byAgent[0].callID;
144
+ }
145
+ if (hints.prompt) {
146
+ const byPrompt = candidates.filter((task) => task.args.prompt === hints.prompt);
147
+ if (byPrompt.length === 1)
148
+ return byPrompt[0].callID;
149
+ }
150
+ return undefined;
151
+ }
152
+ /**
153
+ * Drop relationship data owned by a deleted session: its own parent link,
154
+ * root mark, task calls, and bindings dispatched by it (its children lose
155
+ * their dispatcher).
156
+ */
157
+ evictSession(sessionID) {
158
+ this.parents.delete(sessionID);
159
+ this.roots.delete(sessionID);
160
+ this.dropBinding(sessionID);
161
+ // Children of the deleted session lose their dispatcher and parent link.
162
+ for (const [child, parent] of this.parents) {
163
+ if (parent === sessionID)
164
+ this.parents.delete(child);
165
+ }
166
+ for (const binding of [...this.bindings.values()]) {
167
+ if (binding.parentSessionID === sessionID) {
168
+ this.dropBinding(binding.childSessionID);
169
+ }
170
+ }
171
+ for (const [callID, task] of this.taskCalls) {
172
+ if (task.sessionID === sessionID)
173
+ this.taskCalls.delete(callID);
174
+ }
175
+ }
176
+ dropBinding(childSessionID) {
177
+ const binding = this.bindings.get(childSessionID);
178
+ if (!binding)
179
+ return;
180
+ this.bindings.delete(childSessionID);
181
+ this.childByTaskCall.delete(binding.taskCallID);
182
+ }
183
+ isCandidate(task, now) {
184
+ return (task.endedAt === undefined || now - task.endedAt <= RECENTLY_ENDED_TASK_MS);
185
+ }
186
+ pruneTaskCalls() {
187
+ const now = Date.now();
188
+ for (const [callID, task] of this.taskCalls) {
189
+ if (task.endedAt !== undefined && now - task.endedAt > ENDED_TASK_CALL_TTL_MS) {
190
+ this.taskCalls.delete(callID);
191
+ }
192
+ }
193
+ if (this.taskCalls.size <= MAX_TASK_CALLS)
194
+ return;
195
+ const ended = [...this.taskCalls.values()]
196
+ .filter((task) => task.endedAt !== undefined)
197
+ .sort((a, b) => a.endedAt - b.endedAt);
198
+ for (const task of ended) {
199
+ if (this.taskCalls.size <= MAX_TASK_CALLS)
200
+ break;
201
+ this.taskCalls.delete(task.callID);
202
+ }
203
+ }
204
+ }
@@ -1,6 +1,9 @@
1
1
  import { SoftprobeClient, type Generation, type JsonValue, type Observation, type SoftprobeClientOptions } from "@softprobe/tracing";
2
- import type { AssistantGenerationInput, MessagePart, SessionErrorInfo, UserMessageInput } from "./types.js";
3
- export type SoftprobeSessionTracerOptions = SoftprobeClientOptions;
2
+ import type { AssistantGenerationInput, MessagePart, SessionErrorInfo, SessionLookup, UserMessageInput } from "./types.js";
3
+ export type SoftprobeSessionTracerOptions = SoftprobeClientOptions & {
4
+ /** OpenCode client API lookup used to classify unknown sessions lazily. */
5
+ sessionLookup?: SessionLookup;
6
+ };
4
7
  /**
5
8
  * Maps OpenCode session hooks to Softprobe Part A observations.
6
9
  * Construct with an injected SoftprobeClient (tests) or via {@link createSoftprobeSessionTracer}.
@@ -8,6 +11,18 @@ export type SoftprobeSessionTracerOptions = SoftprobeClientOptions;
8
11
  export declare class SoftprobeSessionTracer {
9
12
  readonly client: SoftprobeClient;
10
13
  private readonly userId?;
14
+ private readonly sessionLookup?;
15
+ /** Sub-agent session relationships (parent links + task-call bindings). */
16
+ private readonly sessionGraph;
17
+ /** In-flight classification promises, deduped per session. */
18
+ private readonly classifying;
19
+ /**
20
+ * Recently ended task spans, kept so late-starting child sessions can still
21
+ * be parented under them (OTEL allows ended spans as parents).
22
+ */
23
+ private readonly endedTaskSpans;
24
+ /** messageID → sessionID index enabling per-session state cleanup. */
25
+ private readonly messageSession;
11
26
  private readonly abortedSessions;
12
27
  private readonly tracedMessageIds;
13
28
  private readonly tracedGenerationIds;
@@ -24,12 +39,73 @@ export declare class SoftprobeSessionTracer {
24
39
  private readonly generationParents;
25
40
  constructor(client: SoftprobeClient, options?: {
26
41
  userId?: string;
42
+ sessionLookup?: SessionLookup;
27
43
  });
28
- clearTraceState(): void;
44
+ /**
45
+ * Clear bookkeeping for ended spans. With a sessionID, only that session's
46
+ * state is cleared (a sub-agent session going idle must not disturb the
47
+ * parent session's in-flight spans).
48
+ *
49
+ * Only payload caches are cleared, in both the scoped and the global path.
50
+ * Dedup sets are never cleared here — they are capped instead (see
51
+ * {@link BoundedSet}) — so a late duplicate delivery can never recreate a
52
+ * span that was already force-ended, on scoped and legacy hosts alike.
53
+ */
54
+ clearTraceState(sessionID?: string): void;
29
55
  endActiveToolObservations(sessionID?: string, error?: SessionErrorInfo): void;
30
56
  endActiveGenerationSteps(sessionID?: string, error?: SessionErrorInfo): void;
31
- endActiveTurnObservations(): void;
32
- finalizeSessionTracing(): void;
57
+ endActiveTurnObservations(sessionID?: string): void;
58
+ /**
59
+ * End open observations and flush bookkeeping. With a sessionID, only that
60
+ * session is finalized; without one (dispose/shutdown, or hosts whose idle
61
+ * event carries no sessionID) everything is finalized.
62
+ */
63
+ finalizeSessionTracing(sessionID?: string): void;
64
+ /**
65
+ * Register session genealogy reported by OpenCode (`session.created` /
66
+ * `session.updated` carry `info.parentID` for task-tool child sessions).
67
+ */
68
+ registerSessionInfo(sessionID: string, parentID?: string | null): void;
69
+ /** Drop relationship data for a deleted session (`session.deleted`). */
70
+ evictSession(sessionID: string): void;
71
+ /**
72
+ * Ensure a session is classified (root vs sub-agent child) before its first
73
+ * spans are created. Resolves the parent from prior events or, failing
74
+ * that, the OpenCode client API, then tries to bind the session to the
75
+ * dispatching task call. Idempotent and deduplicated while in flight.
76
+ */
77
+ ensureSessionClassified(sessionID: string, hints?: {
78
+ agent?: string;
79
+ promptText?: string;
80
+ }): Promise<void>;
81
+ private classifySession;
82
+ /**
83
+ * Bind a child session to the task call that dispatched it, when the call
84
+ * can be identified unambiguously. Bindings from task-part metadata are
85
+ * authoritative and are never replaced by inference.
86
+ */
87
+ private tryBindTask;
88
+ /**
89
+ * Capture the authoritative child-session link published on the task tool
90
+ * part (`state.metadata.sessionId`, present while the part is running,
91
+ * before the child session starts).
92
+ */
93
+ private captureTaskBinding;
94
+ /** Move an ended task span into the archive used for late child attach. */
95
+ private archiveTaskSpan;
96
+ /**
97
+ * Locate the span of the task call that dispatched a child session.
98
+ *
99
+ * Direct lookup only. A previous version fell back to "the unique active
100
+ * task span in the parent session" when the bound call had no span, which
101
+ * could parent a child under an unrelated sibling task. Its justification —
102
+ * a path keying the task span by part.id while the binding carries a
103
+ * different callID — does not exist: `ToolPart.callID` is required in the
104
+ * SDK, and traceToolPart and captureTaskBinding derive the id from the same
105
+ * expression, so the two can never diverge. Callers fall back to the parent
106
+ * session's turn when this returns undefined.
107
+ */
108
+ private taskSpanFor;
33
109
  forceFlush(): Promise<void>;
34
110
  shutdown(): Promise<void>;
35
111
  private getTurn;
@@ -89,8 +165,10 @@ export declare class SoftprobeSessionTracer {
89
165
  */
90
166
  private toolParent;
91
167
  /**
92
- * Capture tool observations from message.part.updated when tool.execute.*
93
- * hooks are missed (or spans from those hooks were dropped on export).
168
+ * Capture tool observations from message.part.updated.
169
+ * Also the canonical end path for MCP tools: tool.execute.after often
170
+ * receives raw CallToolResult without `output`, so we leave the span
171
+ * open until this completed/error part update arrives.
94
172
  */
95
173
  traceToolPart(part: MessagePart): void;
96
174
  traceToolStart(input: {