@softprobe/opencode-plugin 0.1.1 → 0.1.5
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 +16 -0
- package/dist/bounded.d.ts +29 -0
- package/dist/bounded.js +62 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +225 -98
- package/dist/session-graph.d.ts +72 -0
- package/dist/session-graph.js +204 -0
- package/dist/softprobe.d.ts +85 -7
- package/dist/softprobe.js +327 -39
- package/dist/types.d.ts +62 -0
- package/package.json +4 -3
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
|
+
}
|
package/dist/bounded.js
ADDED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
await
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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(
|
|
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
|
|
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
|
+
}
|