@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.
package/README.md CHANGED
@@ -67,4 +67,20 @@ credentials when `SPCODE_MODE` is on (see softprobe-code
67
67
  - Retries, reasoning, compaction events
68
68
  - Failed steps and session errors / aborts
69
69
 
70
+ ### Sub-agent sessions
71
+
72
+ OpenCode runs sub-agents (the `task` tool) in child sessions. The plugin nests
73
+ each child session's turn under the dispatching task span, so a whole agent
74
+ run — root session plus any sub-agents, recursively — forms a single trace
75
+ instead of several disconnected ones. The link comes from the task part's
76
+ `state.metadata.sessionId` (authoritative), with `task_id` resume and
77
+ parentID + task-call inference as fallbacks; ambiguous dispatches (e.g.
78
+ parallel identical task calls) are left unnested rather than guessed. Each
79
+ span keeps its own `sp.session.id`; the parent side is recorded as
80
+ `sp.metadata.opencode.parentSessionID` / `sp.metadata.opencode.parentTaskCallID`
81
+ on the child turn and `sp.child.session.id` on the task span.
82
+
83
+ Session lifecycle is isolated per session: a sub-agent session going idle
84
+ finalizes only its own spans, never the parent session's in-flight work.
85
+
70
86
  See [Coding agents](../../docs/integrations/coding-agents.md).
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Insertion-ordered collections with a hard cap.
3
+ *
4
+ * Used for bookkeeping that has to outlive a single session: dedup keys must
5
+ * survive a scoped `session.idle` (otherwise a late duplicate delivery
6
+ * recreates a span that was already force-ended), but nothing clears them
7
+ * afterwards except process shutdown. A cap keeps them from growing without
8
+ * bound on a long-lived `opencode serve` while preserving the property that
9
+ * matters — recent ids are always remembered.
10
+ */
11
+ export declare class BoundedSet {
12
+ private readonly cap;
13
+ private readonly items;
14
+ constructor(cap: number);
15
+ get size(): number;
16
+ has(key: string): boolean;
17
+ /** Insertion order is kept: re-adding a known key does not refresh it. */
18
+ add(key: string): void;
19
+ }
20
+ export declare class BoundedMap<V> {
21
+ private readonly cap;
22
+ private readonly items;
23
+ constructor(cap: number);
24
+ get size(): number;
25
+ get(key: string): V | undefined;
26
+ /** Writing a key refreshes its position, so active entries are not evicted. */
27
+ set(key: string, value: V): void;
28
+ delete(key: string): void;
29
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Insertion-ordered collections with a hard cap.
3
+ *
4
+ * Used for bookkeeping that has to outlive a single session: dedup keys must
5
+ * survive a scoped `session.idle` (otherwise a late duplicate delivery
6
+ * recreates a span that was already force-ended), but nothing clears them
7
+ * afterwards except process shutdown. A cap keeps them from growing without
8
+ * bound on a long-lived `opencode serve` while preserving the property that
9
+ * matters — recent ids are always remembered.
10
+ */
11
+ export class BoundedSet {
12
+ cap;
13
+ items = new Set();
14
+ constructor(cap) {
15
+ this.cap = cap;
16
+ }
17
+ get size() {
18
+ return this.items.size;
19
+ }
20
+ has(key) {
21
+ return this.items.has(key);
22
+ }
23
+ /** Insertion order is kept: re-adding a known key does not refresh it. */
24
+ add(key) {
25
+ if (this.items.has(key))
26
+ return;
27
+ this.items.add(key);
28
+ while (this.items.size > this.cap) {
29
+ const oldest = this.items.values().next().value;
30
+ if (oldest === undefined)
31
+ return;
32
+ this.items.delete(oldest);
33
+ }
34
+ }
35
+ }
36
+ export class BoundedMap {
37
+ cap;
38
+ items = new Map();
39
+ constructor(cap) {
40
+ this.cap = cap;
41
+ }
42
+ get size() {
43
+ return this.items.size;
44
+ }
45
+ get(key) {
46
+ return this.items.get(key);
47
+ }
48
+ /** Writing a key refreshes its position, so active entries are not evicted. */
49
+ set(key, value) {
50
+ this.items.delete(key);
51
+ this.items.set(key, value);
52
+ while (this.items.size > this.cap) {
53
+ const oldest = this.items.keys().next().value;
54
+ if (oldest === undefined)
55
+ return;
56
+ this.items.delete(oldest);
57
+ }
58
+ }
59
+ delete(key) {
60
+ this.items.delete(key);
61
+ }
62
+ }
package/dist/index.d.ts CHANGED
@@ -14,5 +14,8 @@ declare const _default: {
14
14
  };
15
15
  export default _default;
16
16
  export { SoftprobeSessionTracer, createSoftprobeSessionTracer, } from "./softprobe.js";
17
+ export { SessionGraph, parseTaskCallArgs } from "./session-graph.js";
18
+ export type { TaskBinding, TaskCallArgs } from "./session-graph.js";
19
+ export type { SessionLookup } from "./types.js";
17
20
  export { loadSoftprobeCredentials, MissingSoftprobeCredentialsError, defaultConfigDir, defaultConfigPath, type SoftprobePluginCredentials, } from "./config.js";
18
21
  export { inferToolKind } from "./tool-kind.js";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { loadSoftprobeCredentials, MissingSoftprobeCredentialsError, } from "./config.js";
2
2
  import { createSoftprobeSessionTracer, } from "./softprobe.js";
3
+ import { BoundedMap } from "./bounded.js";
3
4
  function log(level, message) {
4
5
  const line = `[softprobe-opencode] ${message}`;
5
6
  if (level === "error")
@@ -36,102 +37,50 @@ function createShutdownOnce(tracer) {
36
37
  return shutdownPromise;
37
38
  };
38
39
  }
39
- async function handleEvent(tracer, event, shutdown) {
40
- if (event.type === "session.idle") {
41
- log("info", "Flushing spans");
42
- tracer.finalizeSessionTracing();
43
- await tracer.forceFlush();
44
- }
45
- if (event.type === "server.instance.disposed") {
46
- tracer.finalizeSessionTracing();
47
- if (shutdown)
48
- await shutdown();
49
- }
50
- if (event.type === "session.error" && "properties" in event) {
51
- const props = event.properties;
52
- if (props.sessionID) {
53
- tracer.traceSessionError({
54
- sessionID: props.sessionID,
55
- error: props.error,
56
- });
57
- }
58
- }
59
- if (event.type === "message.part.updated" && "properties" in event) {
60
- const part = event.properties.part;
61
- tracer.rememberAssistantPart(part);
62
- tracer.traceReasoningPart(part);
63
- tracer.traceToolPart(part);
64
- }
65
- if (event.type === "session.next.step.started") {
66
- tracer.startActiveGenerationStep({
67
- sessionID: event.properties.sessionID,
68
- agent: event.properties.agent,
69
- model: event.properties.model,
70
- started: event.properties.timestamp,
71
- snapshot: event.properties.snapshot,
72
- });
73
- }
74
- if (event.type === "session.next.step.failed") {
75
- tracer.traceFailedGenerationStep({
76
- id: event.id,
77
- sessionID: event.properties.sessionID,
78
- completed: event.properties.timestamp,
79
- error: event.properties.error,
80
- });
81
- }
82
- if (event.type === "session.next.retried") {
83
- tracer.traceEvent({
84
- id: event.id,
85
- sessionID: event.properties.sessionID,
86
- name: "opencode.generation.retry",
87
- timestamp: event.properties.timestamp,
88
- output: event.properties.error,
89
- metadata: { attempt: event.properties.attempt },
90
- });
91
- }
92
- if (event.type === "session.next.reasoning.ended") {
93
- tracer.traceReasoning({
94
- reasoningID: event.properties.reasoningID,
95
- sessionID: event.properties.sessionID,
96
- timestamp: event.properties.timestamp,
97
- text: event.properties.text,
98
- messageID: event.properties.assistantMessageID,
99
- source: "session.next.reasoning.ended",
100
- });
40
+ const SESSION_LOOKUP_TIMEOUT_MS = 1500;
41
+ async function withTimeout(promise, ms) {
42
+ let timer;
43
+ try {
44
+ return await Promise.race([
45
+ promise,
46
+ new Promise((resolve) => {
47
+ timer = setTimeout(() => resolve(undefined), ms);
48
+ }),
49
+ ]);
101
50
  }
102
- if (event.type === "session.next.compaction.ended") {
103
- tracer.traceEvent({
104
- id: event.id,
105
- sessionID: event.properties.sessionID,
106
- name: "opencode.generation.compaction",
107
- timestamp: event.properties.timestamp,
108
- output: { text: event.properties.text },
109
- metadata: {
110
- include: event.properties.include ?? null,
111
- },
112
- });
51
+ catch {
52
+ return undefined;
113
53
  }
114
- if (event.type === "message.updated" && "properties" in event) {
115
- const message = event.properties.info;
116
- if (message.role !== "assistant" || !message.time.completed) {
117
- return;
118
- }
119
- tracer.traceGeneration({
120
- sessionID: message.sessionID,
121
- messageID: message.id,
122
- parentID: message.parentID,
123
- modelID: message.modelID,
124
- providerID: message.providerID,
125
- agent: message.mode,
126
- mode: message.mode,
127
- created: message.time.created,
128
- completed: message.time.completed,
129
- finish: message.finish,
130
- cost: message.cost,
131
- tokens: message.tokens,
132
- });
54
+ finally {
55
+ if (timer)
56
+ clearTimeout(timer);
133
57
  }
134
58
  }
59
+ /**
60
+ * Build a lazy parent-session lookup from the OpenCode plugin client.
61
+ * Returns undefined when the client or its session API is unavailable.
62
+ */
63
+ function createSessionLookup(client) {
64
+ const sessionApi = client?.session;
65
+ if (typeof sessionApi?.get !== "function")
66
+ return undefined;
67
+ const get = sessionApi.get;
68
+ return async (sessionID) => {
69
+ const response = await withTimeout(get.call(sessionApi, { path: { id: sessionID } }), SESSION_LOOKUP_TIMEOUT_MS);
70
+ if (!response || typeof response !== "object")
71
+ return undefined;
72
+ // hey-api clients wrap payloads in { data }; older shapes vary.
73
+ const record = response;
74
+ const data = (record.data ?? record);
75
+ const info = (data.info ?? data);
76
+ return typeof info.parentID === "string" ? info.parentID : undefined;
77
+ };
78
+ }
79
+ /** The first text part holds the raw prompt (OpenCode appends parts for @-mentions). */
80
+ function firstTextPart(parts) {
81
+ const first = parts?.find((part) => part.type === "text");
82
+ return typeof first?.text === "string" ? first.text : undefined;
83
+ }
135
84
  function safeRun(hookName, fn) {
136
85
  return Promise.resolve()
137
86
  .then(fn)
@@ -142,6 +91,159 @@ function safeRun(hookName, fn) {
142
91
  /** Build OpenCode hooks from an existing tracer (used by the plugin and tests). */
143
92
  export function createHooksFromTracer(tracer) {
144
93
  const shutdownOnce = createShutdownOnce(tracer);
94
+ // session.status {type:"idle"} and the deprecated session.idle fire
95
+ // back-to-back for the same transition — coalesce per session. The mark is
96
+ // reset on any new activity (busy status, a new user message, a new
97
+ // generation step) so a genuinely new idle is never swallowed.
98
+ const IDLE_COALESCE_MS = 2000;
99
+ const IDLE_MARK_CAP = 500;
100
+ const idleHandledAt = new BoundedMap(IDLE_MARK_CAP);
101
+ const handleSessionIdle = async (sessionID) => {
102
+ if (sessionID) {
103
+ const now = Date.now();
104
+ const last = idleHandledAt.get(sessionID);
105
+ if (last !== undefined && now - last < IDLE_COALESCE_MS)
106
+ return;
107
+ idleHandledAt.set(sessionID, now);
108
+ }
109
+ log("info", "Flushing spans");
110
+ // Scoped finalize: a sub-agent session going idle must not end the
111
+ // parent session's in-flight spans. Without a sessionID (old hosts),
112
+ // fall back to finalizing everything.
113
+ tracer.finalizeSessionTracing(sessionID);
114
+ await tracer.forceFlush();
115
+ };
116
+ const handleEvent = async (event) => {
117
+ if (event.type === "session.idle") {
118
+ const props = ("properties" in event
119
+ ? event.properties
120
+ : undefined);
121
+ await handleSessionIdle(props?.sessionID);
122
+ }
123
+ if (event.type === "session.status" && "properties" in event) {
124
+ const props = event.properties;
125
+ if (props?.status?.type === "idle") {
126
+ await handleSessionIdle(props.sessionID);
127
+ }
128
+ else if (props?.sessionID && props.status?.type) {
129
+ // New activity: the next idle is a fresh transition, not a duplicate.
130
+ idleHandledAt.delete(props.sessionID);
131
+ }
132
+ }
133
+ if ((event.type === "session.created" || event.type === "session.updated") &&
134
+ "properties" in event) {
135
+ const info = event.properties
136
+ ?.info;
137
+ // Session genealogy: task-tool child sessions carry parentID here.
138
+ if (info?.id)
139
+ tracer.registerSessionInfo(info.id, info.parentID);
140
+ }
141
+ if (event.type === "session.deleted" && "properties" in event) {
142
+ const info = event.properties
143
+ ?.info;
144
+ if (info?.id) {
145
+ // End the deleted session's in-flight spans before dropping its
146
+ // relationship data, so nothing leaks open until dispose.
147
+ tracer.finalizeSessionTracing(info.id);
148
+ tracer.evictSession(info.id);
149
+ idleHandledAt.delete(info.id);
150
+ }
151
+ }
152
+ if (event.type === "server.instance.disposed") {
153
+ tracer.finalizeSessionTracing();
154
+ await shutdownOnce();
155
+ }
156
+ if (event.type === "session.error" && "properties" in event) {
157
+ const props = event.properties;
158
+ if (props?.sessionID) {
159
+ tracer.traceSessionError({
160
+ sessionID: props.sessionID,
161
+ error: props.error,
162
+ });
163
+ }
164
+ }
165
+ if (event.type === "message.part.updated" && "properties" in event) {
166
+ const part = event.properties.part;
167
+ tracer.rememberAssistantPart(part);
168
+ tracer.traceReasoningPart(part);
169
+ tracer.traceToolPart(part);
170
+ }
171
+ if (event.type === "session.next.step.started") {
172
+ // Activity without a chat.message (queued work, sub-agent turns): the
173
+ // next idle is a fresh transition, not a duplicate of the last one.
174
+ // If a step.started were ever delivered *between* the paired idle
175
+ // events, this defeats their coalescing — costing one extra forceFlush,
176
+ // not a span, since the first finalize already cleared the turn.
177
+ idleHandledAt.delete(event.properties.sessionID);
178
+ tracer.startActiveGenerationStep({
179
+ sessionID: event.properties.sessionID,
180
+ agent: event.properties.agent,
181
+ model: event.properties.model,
182
+ started: event.properties.timestamp,
183
+ snapshot: event.properties.snapshot,
184
+ });
185
+ }
186
+ if (event.type === "session.next.step.failed") {
187
+ tracer.traceFailedGenerationStep({
188
+ id: event.id,
189
+ sessionID: event.properties.sessionID,
190
+ completed: event.properties.timestamp,
191
+ error: event.properties.error,
192
+ });
193
+ }
194
+ if (event.type === "session.next.retried") {
195
+ tracer.traceEvent({
196
+ id: event.id,
197
+ sessionID: event.properties.sessionID,
198
+ name: "opencode.generation.retry",
199
+ timestamp: event.properties.timestamp,
200
+ output: event.properties.error,
201
+ metadata: { attempt: event.properties.attempt },
202
+ });
203
+ }
204
+ if (event.type === "session.next.reasoning.ended") {
205
+ tracer.traceReasoning({
206
+ reasoningID: event.properties.reasoningID,
207
+ sessionID: event.properties.sessionID,
208
+ timestamp: event.properties.timestamp,
209
+ text: event.properties.text,
210
+ messageID: event.properties.assistantMessageID,
211
+ source: "session.next.reasoning.ended",
212
+ });
213
+ }
214
+ if (event.type === "session.next.compaction.ended") {
215
+ tracer.traceEvent({
216
+ id: event.id,
217
+ sessionID: event.properties.sessionID,
218
+ name: "opencode.generation.compaction",
219
+ timestamp: event.properties.timestamp,
220
+ output: { text: event.properties.text },
221
+ metadata: {
222
+ include: event.properties.include ?? null,
223
+ },
224
+ });
225
+ }
226
+ if (event.type === "message.updated" && "properties" in event) {
227
+ const message = event.properties.info;
228
+ if (message.role !== "assistant" || !message.time.completed) {
229
+ return;
230
+ }
231
+ tracer.traceGeneration({
232
+ sessionID: message.sessionID,
233
+ messageID: message.id,
234
+ parentID: message.parentID,
235
+ modelID: message.modelID,
236
+ providerID: message.providerID,
237
+ agent: message.mode,
238
+ mode: message.mode,
239
+ created: message.time.created,
240
+ completed: message.time.completed,
241
+ finish: message.finish,
242
+ cost: message.cost,
243
+ tokens: message.tokens,
244
+ });
245
+ }
246
+ };
145
247
  return {
146
248
  dispose: () => safeRun("dispose", async () => {
147
249
  tracer.finalizeSessionTracing();
@@ -154,13 +256,13 @@ export function createHooksFromTracer(tracer) {
154
256
  }),
155
257
  event: ({ event }) => safeRun("event", async () => {
156
258
  try {
157
- await handleEvent(tracer, event, shutdownOnce);
259
+ await handleEvent(event);
158
260
  }
159
261
  catch (error) {
160
262
  // forceFlush often rejects with [Error] when thelake is slow;
161
263
  // spans may already have been accepted via BatchSpanProcessor.
162
264
  // Log and continue so session lifecycle is not blocked on OTLP.
163
- if (event.type === "session.idle" &&
265
+ if ((event.type === "session.idle" || event.type === "session.status") &&
164
266
  (Array.isArray(error) ||
165
267
  (error instanceof Error && /timed out/i.test(error.message)))) {
166
268
  log("warn", `flush after session.idle: ${formatHookError(error)}`);
@@ -169,13 +271,28 @@ export function createHooksFromTracer(tracer) {
169
271
  throw error;
170
272
  }
171
273
  }),
172
- "chat.message": (input, output) => safeRun("chat.message", () => {
274
+ "chat.message": (input, output) => safeRun("chat.message", async () => {
275
+ const parts = output.parts;
276
+ // A new turn is fresh activity — the next idle is not a duplicate.
277
+ idleHandledAt.delete(input.sessionID);
278
+ // Classify (root vs sub-agent child) before the turn span is created;
279
+ // spans cannot be re-parented afterwards. Classification is a pure
280
+ // enhancement: its failure must never cost the turn itself.
281
+ try {
282
+ await tracer.ensureSessionClassified(input.sessionID, {
283
+ agent: input.agent,
284
+ promptText: firstTextPart(parts),
285
+ });
286
+ }
287
+ catch (error) {
288
+ log("warn", `session classification failed: ${formatHookError(error)}`);
289
+ }
173
290
  tracer.traceUserMessage({
174
291
  sessionID: input.sessionID,
175
292
  messageID: input.messageID,
176
293
  agent: input.agent,
177
294
  model: input.model,
178
- parts: output.parts,
295
+ parts,
179
296
  });
180
297
  }),
181
298
  "tool.execute.before": (input, output) => safeRun("tool.execute.before", () => {
@@ -187,18 +304,26 @@ export function createHooksFromTracer(tracer) {
187
304
  });
188
305
  }),
189
306
  "tool.execute.after": (input, output) => safeRun("tool.execute.after", () => {
307
+ // Registry tools pass `{ title, output }`. MCP tools currently fire
308
+ // this hook with raw CallToolResult (`{ content }`) *before*
309
+ // OpenCode normalizes/truncates — ending here would record
310
+ // `sp.output: "{}"` and block message.part.updated (canonical
311
+ // truncated `part.state.output`) via tracedToolCallIds. Defer when
312
+ // `output` is not a string so traceToolPart owns the end.
313
+ if (typeof output?.output !== "string")
314
+ return;
190
315
  tracer.traceToolEnd({
191
316
  sessionID: input.sessionID,
192
317
  callID: input.callID,
193
318
  tool: input.tool,
194
319
  args: input.args,
195
- title: output.title,
320
+ title: output.title || input.tool,
196
321
  output: output.output,
197
322
  });
198
323
  }),
199
324
  };
200
325
  }
201
- export const SoftprobePlugin = async () => {
326
+ export const SoftprobePlugin = async (input) => {
202
327
  let tracer;
203
328
  try {
204
329
  const credentials = await loadSoftprobeCredentials();
@@ -209,6 +334,7 @@ export const SoftprobePlugin = async () => {
209
334
  environment: credentials.environment,
210
335
  userId: credentials.userId,
211
336
  serviceName: credentials.serviceName ?? "opencode",
337
+ sessionLookup: createSessionLookup(input?.client),
212
338
  });
213
339
  log("info", `OTLP tracing initialized → ${credentials.otlpEndpoint}`);
214
340
  }
@@ -231,5 +357,6 @@ export default {
231
357
  server: SoftprobePlugin,
232
358
  };
233
359
  export { SoftprobeSessionTracer, createSoftprobeSessionTracer, } from "./softprobe.js";
360
+ export { SessionGraph, parseTaskCallArgs } from "./session-graph.js";
234
361
  export { loadSoftprobeCredentials, MissingSoftprobeCredentialsError, defaultConfigDir, defaultConfigPath, } from "./config.js";
235
362
  export { inferToolKind } from "./tool-kind.js";
@@ -0,0 +1,72 @@
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
+ export type TaskCallArgs = {
22
+ prompt?: string;
23
+ subagent_type?: string;
24
+ task_id?: string;
25
+ };
26
+ export type TaskBinding = {
27
+ childSessionID: string;
28
+ parentSessionID: string;
29
+ taskCallID: string;
30
+ /** "metadata" is authoritative and never overwritten by "inferred". */
31
+ source: "metadata" | "inferred";
32
+ };
33
+ /** Extract the task-tool args relevant for child-session binding. */
34
+ export declare function parseTaskCallArgs(args: unknown): TaskCallArgs;
35
+ export declare class SessionGraph {
36
+ /** child sessionID → parent sessionID (from session events or API lookup). */
37
+ private readonly parents;
38
+ /** Sessions known to have no parent. */
39
+ private readonly roots;
40
+ /** child sessionID → dispatching task call. */
41
+ private readonly bindings;
42
+ /** task callID → child sessionID (reverse of bindings). */
43
+ private readonly childByTaskCall;
44
+ /** task callID → call record (args for inference, times for recency). */
45
+ private readonly taskCalls;
46
+ registerParent(childSessionID: string, parentSessionID: string): void;
47
+ markRoot(sessionID: string): void;
48
+ isRoot(sessionID: string): boolean;
49
+ parentOf(sessionID: string): string | undefined;
50
+ registerTaskCall(callID: string, sessionID: string, args: TaskCallArgs): void;
51
+ endTaskCall(callID: string): void;
52
+ bind(binding: TaskBinding): void;
53
+ bindingFor(childSessionID: string): TaskBinding | undefined;
54
+ childForTaskCall(callID: string): string | undefined;
55
+ /**
56
+ * Infer which task call in `parentSessionID` dispatched `childSessionID`.
57
+ * Returns undefined on any ambiguity — never guesses.
58
+ */
59
+ resolveTaskCall(childSessionID: string, parentSessionID: string, hints?: {
60
+ agent?: string;
61
+ prompt?: string;
62
+ }): string | undefined;
63
+ /**
64
+ * Drop relationship data owned by a deleted session: its own parent link,
65
+ * root mark, task calls, and bindings dispatched by it (its children lose
66
+ * their dispatcher).
67
+ */
68
+ evictSession(sessionID: string): void;
69
+ private dropBinding;
70
+ private isCandidate;
71
+ private pruneTaskCalls;
72
+ }