@kodrunhq/opencode-autopilot 1.3.0 → 1.4.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kodrunhq/opencode-autopilot",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Curated agents, skills, and commands for the OpenCode AI coding CLI — autonomous orchestrator, multi-agent code review, model fallback, and in-session asset creation tools.",
5
5
  "main": "src/index.ts",
6
6
  "keywords": [
package/src/index.ts CHANGED
@@ -3,6 +3,16 @@ import { configHook } from "./agents";
3
3
  import { isFirstLoad, loadConfig } from "./config";
4
4
  import { runHealthChecks } from "./health/runner";
5
5
  import { installAssets } from "./installer";
6
+ import { ContextMonitor } from "./observability/context-monitor";
7
+ import {
8
+ createObservabilityEventHandler,
9
+ createToolExecuteAfterHandler as createObsToolAfterHandler,
10
+ createToolExecuteBeforeHandler,
11
+ } from "./observability/event-handlers";
12
+ import { SessionEventStore } from "./observability/event-store";
13
+ import { writeSessionLog } from "./observability/log-writer";
14
+ import { pruneOldLogs } from "./observability/retention";
15
+ import type { SessionEvent } from "./observability/types";
6
16
  import type { SdkOperations } from "./orchestrator/fallback";
7
17
  import {
8
18
  createChatMessageHandler,
@@ -24,11 +34,15 @@ import { ocCreateCommand } from "./tools/create-command";
24
34
  import { ocCreateSkill } from "./tools/create-skill";
25
35
  import { ocDoctor } from "./tools/doctor";
26
36
  import { ocForensics } from "./tools/forensics";
37
+ import { ocLogs } from "./tools/logs";
38
+ import { ocMockFallback } from "./tools/mock-fallback";
27
39
  import { ocOrchestrate } from "./tools/orchestrate";
28
40
  import { ocPhase } from "./tools/phase";
41
+ import { ocPipelineReport } from "./tools/pipeline-report";
29
42
  import { ocPlan } from "./tools/plan";
30
43
  import { ocQuick } from "./tools/quick";
31
44
  import { ocReview } from "./tools/review";
45
+ import { ocSessionStats } from "./tools/session-stats";
32
46
  import { ocState } from "./tools/state";
33
47
 
34
48
  let openCodeConfig: Config | null = null;
@@ -70,6 +84,15 @@ const plugin: Plugin = async (input) => {
70
84
  // Health check failures are non-fatal — oc_doctor provides manual diagnostics
71
85
  });
72
86
 
87
+ // --- Observability subsystem initialization ---
88
+ const eventStore = new SessionEventStore();
89
+ const contextMonitor = new ContextMonitor();
90
+
91
+ // Retention pruning on load (non-blocking per D-14)
92
+ pruneOldLogs().catch((err) => {
93
+ console.error("[opencode-autopilot]", err);
94
+ });
95
+
73
96
  // --- Fallback subsystem initialization ---
74
97
  const sdkOps: SdkOperations = {
75
98
  abortSession: async (sessionID) => {
@@ -123,6 +146,32 @@ const plugin: Plugin = async (input) => {
123
146
  const chatMessageHandler = createChatMessageHandler(manager);
124
147
  const toolExecuteAfterHandler = createToolExecuteAfterHandler(manager);
125
148
 
149
+ // --- Observability handlers ---
150
+ const toolStartTimes = new Map<string, number>();
151
+ const observabilityEventHandler = createObservabilityEventHandler({
152
+ eventStore,
153
+ contextMonitor,
154
+ showToast: sdkOps.showToast,
155
+ writeSessionLog: async (sessionData) => {
156
+ if (!sessionData) return;
157
+ // Filter to schema-valid event types that match SessionEvent discriminated union
158
+ const schemaEvents: SessionEvent[] = sessionData.events.filter(
159
+ (e): e is SessionEvent =>
160
+ e.type === "fallback" ||
161
+ e.type === "error" ||
162
+ e.type === "decision" ||
163
+ e.type === "model_switch",
164
+ );
165
+ await writeSessionLog({
166
+ sessionId: sessionData.sessionId,
167
+ startedAt: sessionData.startedAt,
168
+ events: schemaEvents,
169
+ });
170
+ },
171
+ });
172
+ const obsToolBeforeHandler = createToolExecuteBeforeHandler(toolStartTimes);
173
+ const obsToolAfterHandler = createObsToolAfterHandler(eventStore, toolStartTimes);
174
+
126
175
  return {
127
176
  tool: {
128
177
  oc_configure: ocConfigure,
@@ -138,8 +187,16 @@ const plugin: Plugin = async (input) => {
138
187
  oc_quick: ocQuick,
139
188
  oc_forensics: ocForensics,
140
189
  oc_review: ocReview,
190
+ oc_logs: ocLogs,
191
+ oc_session_stats: ocSessionStats,
192
+ oc_pipeline_report: ocPipelineReport,
193
+ oc_mock_fallback: ocMockFallback,
141
194
  },
142
195
  event: async ({ event }) => {
196
+ // 1. Observability: collect (pure observer, no side effects on session)
197
+ await observabilityEventHandler({ event });
198
+
199
+ // 2. First-load toast
143
200
  if (event.type === "session.created" && isFirstLoad(config)) {
144
201
  await sdkOps.showToast(
145
202
  "Welcome to OpenCode Autopilot!",
@@ -148,7 +205,7 @@ const plugin: Plugin = async (input) => {
148
205
  );
149
206
  }
150
207
 
151
- // Fallback event handling (runs for all events)
208
+ // 3. Fallback event handling
152
209
  if (fallbackConfig.enabled) {
153
210
  await fallbackEventHandler({ event });
154
211
  }
@@ -173,6 +230,12 @@ const plugin: Plugin = async (input) => {
173
230
  await chatMessageHandler(hookInput, output);
174
231
  }
175
232
  },
233
+ "tool.execute.before": async (
234
+ input: { tool: string; sessionID: string; callID: string },
235
+ output: { args: unknown },
236
+ ) => {
237
+ obsToolBeforeHandler({ ...input, args: output.args });
238
+ },
176
239
  "tool.execute.after": async (
177
240
  hookInput: {
178
241
  readonly tool: string;
@@ -182,6 +245,10 @@ const plugin: Plugin = async (input) => {
182
245
  },
183
246
  output: { title: string; output: string; metadata: unknown },
184
247
  ) => {
248
+ // Observability: record tool execution (pure observer)
249
+ obsToolAfterHandler(hookInput, output);
250
+
251
+ // Fallback handling
185
252
  if (fallbackConfig.enabled) {
186
253
  await toolExecuteAfterHandler(hookInput, output);
187
254
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Context utilization tracking with one-time warning per session.
3
+ *
4
+ * Pure function `checkContextUtilization` computes utilization ratio and warning signal.
5
+ * `ContextMonitor` class tracks per-session warned state and context limits.
6
+ *
7
+ * The toast itself is NOT fired here -- that happens in the event handler
8
+ * (separation of concerns). This module only computes whether to warn.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ /** Threshold at which context utilization triggers a warning (80%). */
14
+ const CONTEXT_WARNING_THRESHOLD = 0.8;
15
+
16
+ /**
17
+ * Result of a context utilization check.
18
+ */
19
+ export interface ContextUtilizationResult {
20
+ readonly utilization: number;
21
+ readonly shouldWarn: boolean;
22
+ }
23
+
24
+ /**
25
+ * Pure function that computes context utilization and whether to warn.
26
+ *
27
+ * - Returns utilization as a ratio (0.0 - 1.0)
28
+ * - Returns shouldWarn=true when utilization >= 0.80 and not already warned
29
+ * - Returns shouldWarn=false when already warned (fires once per D-36)
30
+ * - Handles zero contextLimit gracefully (returns 0 utilization)
31
+ *
32
+ * @param latestInputTokens - Current cumulative input tokens for the session
33
+ * @param contextLimit - The model's context window size in tokens
34
+ * @param alreadyWarned - Whether this session has already been warned
35
+ */
36
+ export function checkContextUtilization(
37
+ latestInputTokens: number,
38
+ contextLimit: number,
39
+ alreadyWarned: boolean,
40
+ ): ContextUtilizationResult {
41
+ if (contextLimit <= 0) {
42
+ return { utilization: 0, shouldWarn: false };
43
+ }
44
+
45
+ const utilization = latestInputTokens / contextLimit;
46
+ const shouldWarn = !alreadyWarned && utilization >= CONTEXT_WARNING_THRESHOLD;
47
+
48
+ return { utilization, shouldWarn };
49
+ }
50
+
51
+ /**
52
+ * Per-session state for context monitoring.
53
+ */
54
+ interface SessionContextState {
55
+ readonly contextLimit: number;
56
+ warned: boolean;
57
+ }
58
+
59
+ /**
60
+ * Tracks context utilization per session with one-time warning.
61
+ *
62
+ * - `initSession` sets the context limit for a session
63
+ * - `processMessage` checks utilization and updates warned state
64
+ * - `cleanup` removes session tracking data
65
+ */
66
+ export class ContextMonitor {
67
+ private readonly sessions: Map<string, SessionContextState> = new Map();
68
+
69
+ /**
70
+ * Initializes tracking for a session with its model's context limit.
71
+ */
72
+ initSession(sessionID: string, contextLimit: number): void {
73
+ this.sessions.set(sessionID, { contextLimit, warned: false });
74
+ }
75
+
76
+ /**
77
+ * Checks context utilization for a session and updates warned state.
78
+ * Returns utilization 0 for unknown sessions.
79
+ */
80
+ processMessage(sessionID: string, inputTokens: number): ContextUtilizationResult {
81
+ const state = this.sessions.get(sessionID);
82
+ if (!state) {
83
+ return { utilization: 0, shouldWarn: false };
84
+ }
85
+
86
+ const result = checkContextUtilization(inputTokens, state.contextLimit, state.warned);
87
+
88
+ // Update warned flag if warning triggered (one-time per session)
89
+ if (result.shouldWarn) {
90
+ this.sessions.set(sessionID, { ...state, warned: true });
91
+ }
92
+
93
+ return result;
94
+ }
95
+
96
+ /**
97
+ * Removes session tracking data.
98
+ */
99
+ cleanup(sessionID: string): void {
100
+ this.sessions.delete(sessionID);
101
+ }
102
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Typed event emitter helper functions for observability events.
3
+ *
4
+ * Each function constructs a frozen ObservabilityEvent with a timestamp.
5
+ * Pure functions: take args, return frozen event object.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import type { ObservabilityEvent } from "./event-store";
11
+
12
+ /**
13
+ * Constructs a fallback event.
14
+ */
15
+ export function emitFallbackEvent(
16
+ sessionId: string,
17
+ failedModel: string,
18
+ nextModel: string,
19
+ reason: string,
20
+ success: boolean,
21
+ ): ObservabilityEvent {
22
+ return Object.freeze({
23
+ type: "fallback" as const,
24
+ timestamp: new Date().toISOString(),
25
+ sessionId,
26
+ failedModel,
27
+ nextModel,
28
+ reason,
29
+ success,
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Constructs an error event.
35
+ */
36
+ export function emitErrorEvent(
37
+ sessionId: string,
38
+ errorType:
39
+ | "rate_limit"
40
+ | "quota_exceeded"
41
+ | "service_unavailable"
42
+ | "missing_api_key"
43
+ | "model_not_found"
44
+ | "content_filter"
45
+ | "context_length"
46
+ | "unknown",
47
+ message: string,
48
+ model = "unknown",
49
+ statusCode?: number,
50
+ ): ObservabilityEvent {
51
+ return Object.freeze({
52
+ type: "error" as const,
53
+ timestamp: new Date().toISOString(),
54
+ sessionId,
55
+ errorType,
56
+ message,
57
+ model,
58
+ ...(statusCode !== undefined ? { statusCode } : {}),
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Constructs a decision event (per D-27, D-28).
64
+ */
65
+ export function emitDecisionEvent(
66
+ sessionId: string,
67
+ phase: string,
68
+ agent: string,
69
+ decision: string,
70
+ rationale: string,
71
+ ): ObservabilityEvent {
72
+ return Object.freeze({
73
+ type: "decision" as const,
74
+ timestamp: new Date().toISOString(),
75
+ sessionId,
76
+ phase,
77
+ agent,
78
+ decision,
79
+ rationale,
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Constructs a model_switch event.
85
+ */
86
+ export function emitModelSwitchEvent(
87
+ sessionId: string,
88
+ fromModel: string,
89
+ toModel: string,
90
+ trigger: "fallback" | "config" | "user",
91
+ ): ObservabilityEvent {
92
+ return Object.freeze({
93
+ type: "model_switch" as const,
94
+ timestamp: new Date().toISOString(),
95
+ sessionId,
96
+ fromModel,
97
+ toModel,
98
+ trigger,
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Constructs a tool_complete event.
104
+ */
105
+ export function emitToolCompleteEvent(
106
+ sessionId: string,
107
+ tool: string,
108
+ durationMs: number,
109
+ success: boolean,
110
+ ): ObservabilityEvent {
111
+ return Object.freeze({
112
+ type: "tool_complete" as const,
113
+ timestamp: new Date().toISOString(),
114
+ sessionId,
115
+ tool,
116
+ durationMs,
117
+ success,
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Constructs a phase_transition event.
123
+ */
124
+ export function emitPhaseTransition(
125
+ sessionId: string,
126
+ fromPhase: string,
127
+ toPhase: string,
128
+ ): ObservabilityEvent {
129
+ return Object.freeze({
130
+ type: "phase_transition" as const,
131
+ timestamp: new Date().toISOString(),
132
+ sessionId,
133
+ fromPhase,
134
+ toPhase,
135
+ });
136
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Hook handler factories for OpenCode event system integration.
3
+ *
4
+ * Creates handlers for:
5
+ * - event hook: session.created/error/idle/deleted/compacted, message.updated
6
+ * - tool.execute.before: records start timestamp
7
+ * - tool.execute.after: computes duration, records metrics
8
+ *
9
+ * All handlers are pure observers: they read event data, append to store,
10
+ * and never modify session state or output objects (Pitfall 5).
11
+ *
12
+ * @module
13
+ */
14
+
15
+ import { classifyErrorType, getErrorMessage } from "../orchestrator/fallback/error-classifier";
16
+ import type { ContextMonitor } from "./context-monitor";
17
+ import { emitErrorEvent, emitToolCompleteEvent } from "./event-emitter";
18
+ import type { ObservabilityEvent, SessionEventStore, SessionEvents } from "./event-store";
19
+ import { accumulateTokensFromMessage, createEmptyTokenAggregate } from "./token-tracker";
20
+
21
+ /**
22
+ * Dependencies for the observability event handler.
23
+ */
24
+ export interface ObservabilityHandlerDeps {
25
+ readonly eventStore: SessionEventStore;
26
+ readonly contextMonitor: ContextMonitor;
27
+ readonly showToast: (
28
+ title: string,
29
+ message: string,
30
+ variant: "info" | "warning" | "error",
31
+ ) => Promise<void>;
32
+ readonly writeSessionLog: (sessionData: SessionEvents | undefined) => Promise<void>;
33
+ }
34
+
35
+ /**
36
+ * Extracts a session ID from event properties.
37
+ * Supports properties.sessionID, properties.info.sessionID, and properties.info.id.
38
+ */
39
+ function extractSessionId(properties: Record<string, unknown>): string | undefined {
40
+ if (typeof properties.sessionID === "string") return properties.sessionID;
41
+ if (properties.info !== null && typeof properties.info === "object") {
42
+ const info = properties.info as Record<string, unknown>;
43
+ if (typeof info.sessionID === "string") return info.sessionID;
44
+ if (typeof info.id === "string") return info.id;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ /**
50
+ * Checks if an object has AssistantMessage token shape.
51
+ */
52
+ function hasTokenShape(obj: unknown): obj is {
53
+ tokens: {
54
+ input: number;
55
+ output: number;
56
+ reasoning: number;
57
+ cache: { read: number; write: number };
58
+ };
59
+ cost: number;
60
+ } {
61
+ if (obj === null || typeof obj !== "object") return false;
62
+ const o = obj as Record<string, unknown>;
63
+ if (typeof o.cost !== "number") return false;
64
+ if (o.tokens === null || typeof o.tokens !== "object") return false;
65
+ const tokens = o.tokens as Record<string, unknown>;
66
+ if (typeof tokens.input !== "number") return false;
67
+ if (typeof tokens.output !== "number") return false;
68
+ if (typeof tokens.reasoning !== "number") return false;
69
+ if (tokens.cache === null || typeof tokens.cache !== "object") return false;
70
+ const cache = tokens.cache as Record<string, unknown>;
71
+ return typeof cache.read === "number" && typeof cache.write === "number";
72
+ }
73
+
74
+ /**
75
+ * Creates the main observability event handler.
76
+ *
77
+ * Routes OpenCode events to the session event store:
78
+ * - session.created: init session, append session_start
79
+ * - session.error: classify error, append error event (D-37, D-38)
80
+ * - message.updated: accumulate tokens, check context utilization
81
+ * - session.idle: flush to disk (fire-and-forget, Pitfall 2)
82
+ * - session.deleted: final flush + cleanup
83
+ * - session.compacted: append event, intermediate flush
84
+ */
85
+ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps) {
86
+ const { eventStore, contextMonitor, showToast, writeSessionLog } = deps;
87
+
88
+ return async (input: {
89
+ readonly event: { readonly type: string; readonly [key: string]: unknown };
90
+ }): Promise<void> => {
91
+ const { event } = input;
92
+ const properties = (event.properties ?? {}) as Record<string, unknown>;
93
+
94
+ switch (event.type) {
95
+ case "session.created": {
96
+ const info = properties.info as { id?: string; model?: string } | undefined;
97
+ if (!info?.id) return;
98
+
99
+ eventStore.initSession(info.id);
100
+
101
+ // Init context monitor with a default context limit.
102
+ // The actual limit would come from provider metadata if available.
103
+ contextMonitor.initSession(info.id, 200000);
104
+
105
+ // Append session_start event
106
+ const startEvent: ObservabilityEvent = Object.freeze({
107
+ type: "session_start" as const,
108
+ timestamp: new Date().toISOString(),
109
+ sessionId: info.id,
110
+ });
111
+ eventStore.appendEvent(info.id, startEvent);
112
+ return;
113
+ }
114
+
115
+ case "session.error": {
116
+ const sessionId = extractSessionId(properties);
117
+ if (!sessionId) return;
118
+
119
+ const error = properties.error;
120
+ const rawErrorType = classifyErrorType(error);
121
+ const errorType = (
122
+ [
123
+ "rate_limit",
124
+ "quota_exceeded",
125
+ "service_unavailable",
126
+ "missing_api_key",
127
+ "model_not_found",
128
+ "content_filter",
129
+ "context_length",
130
+ ] as const
131
+ ).includes(rawErrorType as never)
132
+ ? (rawErrorType as
133
+ | "rate_limit"
134
+ | "quota_exceeded"
135
+ | "service_unavailable"
136
+ | "missing_api_key"
137
+ | "model_not_found"
138
+ | "content_filter"
139
+ | "context_length")
140
+ : ("unknown" as const);
141
+ const message = getErrorMessage(error);
142
+
143
+ const errorEvent = emitErrorEvent(sessionId, errorType, message);
144
+ eventStore.appendEvent(sessionId, errorEvent);
145
+ return;
146
+ }
147
+
148
+ case "message.updated": {
149
+ const info = properties.info as Record<string, unknown> | undefined;
150
+ if (!info) return;
151
+
152
+ const sessionId = typeof info.sessionID === "string" ? info.sessionID : undefined;
153
+ if (!sessionId) return;
154
+
155
+ // Accumulate tokens if message has AssistantMessage shape
156
+ if (hasTokenShape(info)) {
157
+ const empty = createEmptyTokenAggregate();
158
+ const accumulated = accumulateTokensFromMessage(empty, info);
159
+ eventStore.accumulateTokens(sessionId, accumulated);
160
+
161
+ // Check context utilization
162
+ const utilResult = contextMonitor.processMessage(sessionId, info.tokens.input);
163
+ if (utilResult.shouldWarn) {
164
+ const pct = Math.round(utilResult.utilization * 100);
165
+ // Append context_warning event
166
+ const warningEvent: ObservabilityEvent = Object.freeze({
167
+ type: "context_warning" as const,
168
+ timestamp: new Date().toISOString(),
169
+ sessionId,
170
+ utilization: utilResult.utilization,
171
+ contextLimit: 200000,
172
+ inputTokens: info.tokens.input,
173
+ });
174
+ eventStore.appendEvent(sessionId, warningEvent);
175
+
176
+ // Fire toast (per D-35)
177
+ showToast(
178
+ "Context Warning",
179
+ `Context at ${pct}% -- consider compacting`,
180
+ "warning",
181
+ ).catch((err) => {
182
+ console.error("[opencode-autopilot]", err);
183
+ });
184
+ }
185
+ }
186
+ return;
187
+ }
188
+
189
+ case "session.idle": {
190
+ const sessionId = extractSessionId(properties);
191
+ if (!sessionId) return;
192
+
193
+ // Snapshot to disk (fire-and-forget per Pitfall 2) — session continues
194
+ const sessionData = eventStore.getSession(sessionId);
195
+ writeSessionLog(sessionData).catch((err) => {
196
+ console.error("[opencode-autopilot]", err);
197
+ });
198
+ return;
199
+ }
200
+
201
+ case "session.deleted": {
202
+ const sessionId = extractSessionId(properties);
203
+ if (!sessionId) return;
204
+
205
+ // Final flush — session is done, remove from store
206
+ const sessionData = eventStore.flush(sessionId);
207
+ writeSessionLog(sessionData).catch((err) => {
208
+ console.error("[opencode-autopilot]", err);
209
+ });
210
+
211
+ // Clean up context monitor
212
+ contextMonitor.cleanup(sessionId);
213
+ return;
214
+ }
215
+
216
+ case "session.compacted": {
217
+ const sessionId = extractSessionId(properties);
218
+ if (!sessionId) return;
219
+
220
+ // Append compaction decision event (not session_start)
221
+ const compactEvent: ObservabilityEvent = Object.freeze({
222
+ type: "decision" as const,
223
+ timestamp: new Date().toISOString(),
224
+ sessionId,
225
+ phase: "COMPACT",
226
+ agent: "system",
227
+ decision: "Session compacted",
228
+ rationale: "Context window compaction triggered",
229
+ });
230
+ eventStore.appendEvent(sessionId, compactEvent);
231
+
232
+ // Snapshot to disk — session continues after compaction
233
+ const sessionData = eventStore.getSession(sessionId);
234
+ writeSessionLog(sessionData).catch((err) => {
235
+ console.error("[opencode-autopilot]", err);
236
+ });
237
+ return;
238
+ }
239
+
240
+ default:
241
+ return;
242
+ }
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Creates a tool.execute.before handler that records start timestamps.
248
+ *
249
+ * @param startTimes - Map to store callID -> start timestamp
250
+ */
251
+ export function createToolExecuteBeforeHandler(startTimes: Map<string, number>) {
252
+ return (hookInput: {
253
+ readonly tool: string;
254
+ readonly sessionID: string;
255
+ readonly callID: string;
256
+ readonly args: unknown;
257
+ }): void => {
258
+ startTimes.set(hookInput.callID, Date.now());
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Determines if a tool execution succeeded based on the output.
264
+ */
265
+ function isToolSuccess(output: {
266
+ readonly title: string;
267
+ readonly output: string;
268
+ readonly metadata: unknown;
269
+ }): boolean {
270
+ // Check metadata for explicit error flag
271
+ if (output.metadata !== null && typeof output.metadata === "object") {
272
+ const meta = output.metadata as Record<string, unknown>;
273
+ if (meta.error === true) return false;
274
+ }
275
+ // Check title for error indicator
276
+ if (output.title.toLowerCase().startsWith("error")) return false;
277
+ // Check output for error prefix
278
+ if (output.output.startsWith("Error:")) return false;
279
+ return true;
280
+ }
281
+
282
+ /**
283
+ * Creates a tool.execute.after handler that computes duration and records metrics.
284
+ *
285
+ * Never modifies the output object (pure observer per Pitfall 5).
286
+ *
287
+ * @param eventStore - The session event store
288
+ * @param startTimes - Map of callID -> start timestamp (shared with before handler)
289
+ */
290
+ export function createToolExecuteAfterHandler(
291
+ eventStore: SessionEventStore,
292
+ startTimes: Map<string, number>,
293
+ ) {
294
+ return (
295
+ hookInput: {
296
+ readonly tool: string;
297
+ readonly sessionID: string;
298
+ readonly callID: string;
299
+ readonly args: unknown;
300
+ },
301
+ output: { readonly title: string; readonly output: string; readonly metadata: unknown },
302
+ ): void => {
303
+ const startTime = startTimes.get(hookInput.callID);
304
+ const durationMs = startTime !== undefined ? Date.now() - startTime : 0;
305
+ const success = isToolSuccess(output);
306
+
307
+ // Clean up start time entry
308
+ startTimes.delete(hookInput.callID);
309
+
310
+ // Record tool execution metric (per D-39, D-40)
311
+ eventStore.recordToolExecution(hookInput.sessionID, hookInput.tool, durationMs, success);
312
+
313
+ // Append tool_complete event
314
+ const toolEvent = emitToolCompleteEvent(
315
+ hookInput.sessionID,
316
+ hookInput.tool,
317
+ durationMs,
318
+ success,
319
+ );
320
+ eventStore.appendEvent(hookInput.sessionID, toolEvent);
321
+ };
322
+ }