@oh-my-pi/pi-coding-agent 17.0.1 → 17.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 (179) hide show
  1. package/CHANGELOG.md +162 -40
  2. package/dist/cli.js +4559 -4543
  3. package/dist/types/advisor/config.d.ts +14 -6
  4. package/dist/types/advisor/runtime.d.ts +20 -11
  5. package/dist/types/autolearn/controller.d.ts +1 -0
  6. package/dist/types/config/model-discovery.d.ts +17 -0
  7. package/dist/types/config/model-resolver.d.ts +6 -2
  8. package/dist/types/config/settings-schema.d.ts +39 -10
  9. package/dist/types/config/settings.d.ts +50 -0
  10. package/dist/types/cursor.d.ts +11 -0
  11. package/dist/types/discovery/helpers.d.ts +5 -2
  12. package/dist/types/eval/agent-bridge.d.ts +7 -19
  13. package/dist/types/exec/bash-executor.d.ts +2 -0
  14. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  15. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  16. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  17. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +55 -1
  18. package/dist/types/extensibility/shared-events.d.ts +6 -0
  19. package/dist/types/extensibility/utils.d.ts +2 -2
  20. package/dist/types/lsp/client.d.ts +2 -0
  21. package/dist/types/lsp/types.d.ts +3 -0
  22. package/dist/types/mcp/transports/stdio.d.ts +42 -1
  23. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  24. package/dist/types/modes/components/agent-hub.d.ts +15 -0
  25. package/dist/types/modes/components/hook-editor.d.ts +7 -0
  26. package/dist/types/modes/components/model-hub.d.ts +5 -2
  27. package/dist/types/modes/components/session-selector.d.ts +2 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  30. package/dist/types/modes/print-mode.d.ts +1 -1
  31. package/dist/types/modes/warp-events.d.ts +24 -0
  32. package/dist/types/modes/warp-events.test.d.ts +1 -0
  33. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  34. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  35. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  36. package/dist/types/registry/persisted-agents.d.ts +3 -0
  37. package/dist/types/sdk.d.ts +27 -5
  38. package/dist/types/session/agent-session.d.ts +34 -10
  39. package/dist/types/session/session-entries.d.ts +6 -1
  40. package/dist/types/session/session-history-format.d.ts +10 -0
  41. package/dist/types/session/session-manager.d.ts +19 -0
  42. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  43. package/dist/types/task/executor.d.ts +26 -1
  44. package/dist/types/task/index.d.ts +1 -1
  45. package/dist/types/task/label.d.ts +1 -1
  46. package/dist/types/task/parallel.d.ts +14 -0
  47. package/dist/types/task/structured-subagent.d.ts +111 -0
  48. package/dist/types/task/types.d.ts +51 -0
  49. package/dist/types/telemetry-export.d.ts +34 -9
  50. package/dist/types/tools/approval.d.ts +8 -0
  51. package/dist/types/tools/bash.d.ts +2 -0
  52. package/dist/types/tools/essential-tools.d.ts +29 -0
  53. package/dist/types/tools/fetch.d.ts +4 -5
  54. package/dist/types/tools/hub/messaging.d.ts +5 -1
  55. package/dist/types/tools/image-gen.d.ts +2 -1
  56. package/dist/types/tools/index.d.ts +17 -1
  57. package/dist/types/tools/todo.d.ts +31 -0
  58. package/dist/types/tools/xdev.d.ts +11 -2
  59. package/dist/types/tui/tree-list.d.ts +7 -0
  60. package/dist/types/utils/markit.d.ts +11 -0
  61. package/dist/types/utils/title-generator.d.ts +2 -1
  62. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  63. package/dist/types/web/search/types.d.ts +1 -1
  64. package/package.json +21 -16
  65. package/src/advisor/__tests__/advisor.test.ts +1304 -42
  66. package/src/advisor/__tests__/config.test.ts +58 -2
  67. package/src/advisor/config.ts +76 -24
  68. package/src/advisor/runtime.ts +445 -92
  69. package/src/autolearn/controller.ts +23 -28
  70. package/src/cli/file-processor.ts +1 -2
  71. package/src/cli.ts +5 -1
  72. package/src/config/model-discovery.ts +81 -21
  73. package/src/config/model-registry.ts +25 -6
  74. package/src/config/model-resolver.ts +14 -7
  75. package/src/config/settings-schema.ts +46 -9
  76. package/src/config/settings.ts +405 -25
  77. package/src/cursor.ts +20 -3
  78. package/src/debug/report-bundle.ts +40 -4
  79. package/src/discovery/helpers.ts +28 -5
  80. package/src/eval/__tests__/agent-bridge.test.ts +133 -47
  81. package/src/eval/__tests__/prelude-agent.test.ts +29 -0
  82. package/src/eval/agent-bridge.ts +104 -477
  83. package/src/eval/jl/prelude.jl +7 -6
  84. package/src/eval/js/shared/prelude.txt +5 -4
  85. package/src/eval/py/prelude.py +11 -39
  86. package/src/eval/rb/prelude.rb +5 -6
  87. package/src/exec/bash-executor.ts +14 -5
  88. package/src/extensibility/custom-tools/loader.ts +3 -3
  89. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  90. package/src/extensibility/extensions/loader.ts +3 -3
  91. package/src/extensibility/extensions/managed-timers.ts +83 -0
  92. package/src/extensibility/extensions/runner.ts +26 -0
  93. package/src/extensibility/extensions/types.ts +18 -0
  94. package/src/extensibility/extensions/wrapper.ts +2 -1
  95. package/src/extensibility/hooks/loader.ts +3 -3
  96. package/src/extensibility/legacy-pi-coding-agent-shim.ts +149 -8
  97. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  98. package/src/extensibility/plugins/manager.ts +2 -2
  99. package/src/extensibility/shared-events.ts +6 -0
  100. package/src/extensibility/utils.ts +91 -25
  101. package/src/irc/bus.ts +22 -3
  102. package/src/launch/broker.ts +3 -2
  103. package/src/launch/client.ts +2 -2
  104. package/src/launch/presence.ts +2 -2
  105. package/src/lsp/client.ts +58 -1
  106. package/src/lsp/index.ts +62 -6
  107. package/src/lsp/types.ts +3 -0
  108. package/src/main.ts +11 -8
  109. package/src/mcp/manager.ts +9 -3
  110. package/src/mcp/oauth-flow.ts +20 -0
  111. package/src/mcp/transports/stdio.test.ts +269 -1
  112. package/src/mcp/transports/stdio.ts +255 -24
  113. package/src/modes/components/advisor-config.ts +65 -3
  114. package/src/modes/components/agent-hub.ts +1 -72
  115. package/src/modes/components/ask-dialog.ts +1 -1
  116. package/src/modes/components/bash-execution.ts +7 -3
  117. package/src/modes/components/eval-execution.ts +3 -1
  118. package/src/modes/components/hook-editor.ts +18 -3
  119. package/src/modes/components/model-hub.ts +138 -42
  120. package/src/modes/components/session-selector.ts +4 -0
  121. package/src/modes/components/status-line/component.test.ts +1 -0
  122. package/src/modes/components/status-line/segments.ts +21 -6
  123. package/src/modes/controllers/command-controller.ts +167 -47
  124. package/src/modes/controllers/event-controller.ts +5 -0
  125. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  126. package/src/modes/controllers/input-controller.ts +12 -12
  127. package/src/modes/controllers/selector-controller.ts +191 -31
  128. package/src/modes/interactive-mode.ts +169 -62
  129. package/src/modes/print-mode.ts +3 -3
  130. package/src/modes/rpc/host-tools.ts +2 -1
  131. package/src/modes/rpc/rpc-mode.ts +19 -4
  132. package/src/modes/warp-events.test.ts +794 -0
  133. package/src/modes/warp-events.ts +232 -0
  134. package/src/plan-mode/model-transition.test.ts +60 -0
  135. package/src/plan-mode/model-transition.ts +51 -0
  136. package/src/prompts/system/system-prompt.md +1 -1
  137. package/src/prompts/tools/eval.md +5 -2
  138. package/src/prompts/tools/read.md +1 -1
  139. package/src/prompts/tools/task.md +12 -8
  140. package/src/prompts/tools/write.md +1 -1
  141. package/src/registry/agent-lifecycle.ts +133 -18
  142. package/src/registry/persisted-agents.ts +74 -0
  143. package/src/sdk.ts +343 -122
  144. package/src/session/agent-session.ts +1359 -368
  145. package/src/session/session-entries.ts +6 -1
  146. package/src/session/session-history-format.ts +20 -5
  147. package/src/session/session-manager.ts +57 -0
  148. package/src/session/streaming-output.ts +41 -1
  149. package/src/slash-commands/builtin-registry.ts +7 -0
  150. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  151. package/src/system-prompt.ts +7 -2
  152. package/src/task/executor.ts +100 -22
  153. package/src/task/index.ts +258 -429
  154. package/src/task/label.ts +2 -0
  155. package/src/task/parallel.ts +43 -0
  156. package/src/task/persisted-revive.ts +19 -7
  157. package/src/task/structured-subagent.ts +642 -0
  158. package/src/task/types.ts +58 -0
  159. package/src/telemetry-export.ts +453 -97
  160. package/src/tools/__tests__/eval-description.test.ts +1 -1
  161. package/src/tools/approval.ts +11 -0
  162. package/src/tools/bash.ts +71 -38
  163. package/src/tools/essential-tools.ts +45 -0
  164. package/src/tools/fetch.ts +28 -105
  165. package/src/tools/gh.ts +169 -2
  166. package/src/tools/hub/messaging.ts +16 -3
  167. package/src/tools/image-gen.ts +69 -7
  168. package/src/tools/index.ts +50 -15
  169. package/src/tools/path-utils.ts +1 -0
  170. package/src/tools/read.ts +62 -29
  171. package/src/tools/todo.ts +126 -13
  172. package/src/tools/write.ts +22 -4
  173. package/src/tools/xdev.ts +14 -3
  174. package/src/tui/tree-list.ts +39 -0
  175. package/src/utils/markit.ts +12 -0
  176. package/src/utils/title-generator.ts +15 -4
  177. package/src/utils/zip.ts +16 -1
  178. package/src/web/search/providers/kimi.ts +18 -12
  179. package/src/web/search/types.ts +6 -1
@@ -1,144 +1,500 @@
1
1
  /**
2
- * OTLP trace export bootstrap.
2
+ * OTLP telemetry export bootstrap.
3
3
  *
4
4
  * oh-my-pi's agent core (`@oh-my-pi/pi-agent-core`) emits OpenTelemetry GenAI
5
- * spans through the global `@opentelemetry/api` tracer, but only when a
6
- * TracerProvider is registered in the process otherwise the API returns a
7
- * no-op tracer and the spans are silently dropped. The shipped CLI never
8
- * registered one, so headless / embedded hosts (e.g. an ACP harness that
9
- * spawns `omp` as a child process) had no way to collect omp's internal traces.
5
+ * spans through the global `@opentelemetry/api` tracer, and exposes run-level
6
+ * callbacks for metrics/log pipelines. This module registers the OTLP/proto
7
+ * trace, log, and metric SDK providers when the standard `OTEL_*` endpoint env
8
+ * vars are set so `omp` can be observed by any OTLP collector without vendor
9
+ * coupling.
10
10
  *
11
- * This module registers a NodeTracerProvider with an OTLP/proto exporter when
12
- * the standard `OTEL_EXPORTER_OTLP_ENDPOINT` (or `..._TRACES_ENDPOINT`) env var
13
- * is set, following the zero-code OTEL env contract: the exporter reads its
14
- * endpoint, headers, and timeout from `OTEL_EXPORTER_OTLP_*` itself. The
15
- * consuming process configures the destination entirely through env; omp stays
16
- * provider-agnostic and ships no vendor coupling. Only the `http/protobuf`
17
- * transport is supported — an `OTEL_EXPORTER_OTLP*_PROTOCOL` of `grpc` or
18
- * `http/json` declines rather than misrouting spans.
19
- *
20
- * The OTLP/proto exporter on the 2.x line is used deliberately: the 1.x line
21
- * deadlocks under Bun — its `req.on('close')` handler fires a spurious failure
22
- * after the success path. `exporter-trace-otlp-proto@0.218` paired with
23
- * `sdk-trace-base@2.7` exports cleanly on Bun.
11
+ * Only the `http/protobuf` transport is supported an
12
+ * `OTEL_EXPORTER_OTLP*_PROTOCOL` of `grpc` or `http/json` declines rather than
13
+ * misrouting protobuf payloads. The exporter line is pinned to the 0.218/2.7
14
+ * family validated under Bun; the 1.x OTLP line deadlocks when its
15
+ * `req.on("close")` handler fires after a successful export.
24
16
  */
17
+ import type {
18
+ AgentRunCoverage,
19
+ AgentRunSummary,
20
+ AgentTelemetryConfig,
21
+ AgentTelemetryWarning,
22
+ ChatUsageEvent,
23
+ ToolStatus,
24
+ } from "@oh-my-pi/pi-agent-core";
25
25
  import { logger, postmortem } from "@oh-my-pi/pi-utils";
26
- import type * as TraceNode from "@opentelemetry/sdk-trace-node";
26
+ import {
27
+ type Attributes,
28
+ type AttributeValue,
29
+ type Counter,
30
+ context,
31
+ type Histogram,
32
+ type Meter,
33
+ metrics,
34
+ } from "@opentelemetry/api";
35
+ import { type LogAttributes, logs, type Logger as OtelLogger, SeverityNumber } from "@opentelemetry/api-logs";
36
+ import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
37
+ import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
38
+ import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
39
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
40
+ import { resourceFromAttributes } from "@opentelemetry/resources";
41
+ import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
42
+ import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
43
+ import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
44
+ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
27
45
 
28
46
  /**
29
47
  * Periodic flush interval. A long-lived `omp` process (the ACP server is
30
48
  * spawned once and reused across many turns) would otherwise hold finished
31
- * spans until the batch window elapses or the process exits.
49
+ * telemetry until a batch window elapses or the process exits.
32
50
  */
33
51
  const FLUSH_INTERVAL_MS = 30_000;
34
52
 
35
- let provider: TraceNode.NodeTracerProvider | undefined;
53
+ const SERVICE_NAME = "oh-my-pi";
54
+
55
+ type TelemetrySignal = "trace" | "log" | "metric";
56
+ type OtelLogLevel = "none" | logger.LogLevel;
57
+
58
+ interface SignalConfig {
59
+ readonly trace: boolean;
60
+ readonly log: boolean;
61
+ readonly metric: boolean;
62
+ }
63
+
64
+ const LOG_SEVERITY: Record<logger.LogLevel, SeverityNumber> = {
65
+ error: SeverityNumber.ERROR,
66
+ warn: SeverityNumber.WARN,
67
+ info: SeverityNumber.INFO,
68
+ debug: SeverityNumber.DEBUG,
69
+ };
70
+
71
+ const LOG_LEVEL_WEIGHT: Record<logger.LogLevel, number> = {
72
+ error: 0,
73
+ warn: 1,
74
+ info: 2,
75
+ debug: 3,
76
+ };
77
+
78
+ const TOOL_STATUSES = ["ok", "error", "skipped", "blocked", "timeout", "aborted"] satisfies readonly ToolStatus[];
79
+
80
+ let traceProvider: NodeTracerProvider | undefined;
81
+ let logProvider: LoggerProvider | undefined;
82
+ let meterProvider: MeterProvider | undefined;
83
+ let metricRecorder: AgentMetricRecorder | undefined;
84
+ let otelLogger: OtelLogger | undefined;
85
+ let unregisterLogSink: (() => void) | undefined;
36
86
  let initPromise: Promise<void> | undefined;
37
87
 
38
88
  /**
39
- * Whether {@link initTelemetryExport} registered a real provider. The CLI uses
40
- * this to decide whether to switch on the agent loop's telemetry config — there
41
- * is no point emitting spans into a no-op tracer.
89
+ * Whether {@link initTelemetryExport} registered any real OTLP signal provider.
90
+ * The CLI uses this to decide whether to switch on the agent loop's telemetry
91
+ * hooks; metrics and structured logs need those callbacks even when traces are
92
+ * disabled.
42
93
  */
43
94
  export function isTelemetryExportEnabled(): boolean {
44
- return provider !== undefined;
95
+ if (traceProvider) return true;
96
+ if (logProvider) return true;
97
+ if (meterProvider) return true;
98
+ return false;
45
99
  }
46
100
 
47
101
  /**
48
- * Register the global TracerProvider + OTLP exporter when an OTLP endpoint is
49
- * configured via env. Idempotent, and a no-op when no endpoint is set (or when
50
- * the OTEL kill-switches are engaged), so it is safe to call unconditionally at
51
- * startup.
102
+ * Merge OTLP metrics/log hooks into an existing agent telemetry config.
103
+ *
104
+ * The caller still owns content-capture policy, cost estimation, and custom
105
+ * attributes. This only appends host-level metrics/log forwarding for the
106
+ * providers registered by {@link initTelemetryExport}.
107
+ */
108
+ export function createTelemetryExportConfig(
109
+ config: AgentTelemetryConfig | undefined,
110
+ ): AgentTelemetryConfig | undefined {
111
+ if (!isTelemetryExportEnabled()) return config;
112
+ return {
113
+ ...config,
114
+ onChatUsage: async event => {
115
+ await config?.onChatUsage?.(event);
116
+ metricRecorder?.recordChatUsage(event);
117
+ },
118
+ onRunEnd: (summary, coverage) => {
119
+ config?.onRunEnd?.(summary, coverage);
120
+ metricRecorder?.recordRun(summary, coverage);
121
+ emitRunSummaryLog(summary, coverage);
122
+ },
123
+ onTelemetryWarning: warning => {
124
+ config?.onTelemetryWarning?.(warning);
125
+ emitTelemetryWarningLog(warning);
126
+ },
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Register global trace/log/meter providers when OTLP endpoints are configured
132
+ * through env. Idempotent, and a no-op when no signal has an endpoint (or when
133
+ * the OTEL kill-switches are engaged), so startup can call it unconditionally.
52
134
  */
53
135
  export async function initTelemetryExport(): Promise<void> {
54
- if (provider) return;
136
+ if (isTelemetryExportEnabled()) return;
55
137
  if (initPromise) return initPromise;
56
138
 
57
- // The OTEL env contract parses booleans and enum lists case-insensitively, so
58
- // OTEL_SDK_DISABLED=TRUE and OTEL_TRACES_EXPORTER=None must also disable export.
59
139
  if (process.env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true") return;
60
- if (tracesExporterDisabled(process.env.OTEL_TRACES_EXPORTER)) return;
61
-
62
- const endpoint = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
63
- if (!endpoint) return;
64
-
65
- // We only ship the http/protobuf transport (the line validated on Bun). The
66
- // OTEL contract lets OTEL_EXPORTER_OTLP*_PROTOCOL select grpc / http/json;
67
- // rather than silently send protobuf-over-HTTP to a grpc :4317 port and lose
68
- // every span, decline when an unsupported protocol is requested.
69
- const protocol = (process.env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL)
70
- ?.trim()
71
- .toLowerCase();
72
- if (protocol && protocol !== "http/protobuf") {
73
- logger.warn(
74
- `OTEL trace export disabled: OTEL_EXPORTER_OTLP_PROTOCOL=${protocol} is unsupported (only http/protobuf)`,
75
- );
76
- return;
77
- }
78
140
 
79
- initPromise = registerProvider();
141
+ const signalConfig = resolveSignalConfig();
142
+ if (!signalConfig.trace && !signalConfig.log && !signalConfig.metric) return;
143
+
144
+ initPromise = registerProviders(signalConfig);
80
145
  return initPromise;
81
146
  }
82
147
 
83
- async function registerProvider(): Promise<void> {
84
- const [
85
- { AsyncLocalStorageContextManager },
86
- { OTLPTraceExporter },
87
- { resourceFromAttributes },
88
- { BatchSpanProcessor },
89
- { NodeTracerProvider },
90
- ] = await Promise.all([
91
- import("@opentelemetry/context-async-hooks"),
92
- import("@opentelemetry/exporter-trace-otlp-proto"),
93
- import("@opentelemetry/resources"),
94
- import("@opentelemetry/sdk-trace-base"),
95
- import("@opentelemetry/sdk-trace-node"),
96
- ]);
97
-
98
- // The exporter reads endpoint/headers/timeout from OTEL_EXPORTER_OTLP_* itself,
99
- // so there is nothing to thread through here.
100
- const exporter = new OTLPTraceExporter();
101
- const tracerProvider = new NodeTracerProvider({
102
- resource: resourceFromAttributes({
103
- "service.name": process.env.OTEL_SERVICE_NAME ?? "oh-my-pi",
104
- }),
105
- spanProcessors: [new BatchSpanProcessor(exporter)],
148
+ async function registerProviders(signalConfig: SignalConfig): Promise<void> {
149
+ const resource = resourceFromAttributes({
150
+ "service.name": process.env.OTEL_SERVICE_NAME ?? SERVICE_NAME,
106
151
  });
107
- // register() installs the global tracer provider and the W3C trace-context +
108
- // baggage propagators; the explicit AsyncLocalStorage context manager keeps
109
- // parent/child span linkage working under Bun.
110
- tracerProvider.register({ contextManager: new AsyncLocalStorageContextManager().enable() });
111
- provider = tracerProvider;
152
+
153
+ if (signalConfig.trace) {
154
+ const exporter = new OTLPTraceExporter();
155
+ traceProvider = new NodeTracerProvider({
156
+ resource,
157
+ spanProcessors: [new BatchSpanProcessor(exporter)],
158
+ });
159
+ traceProvider.register({ contextManager: new AsyncLocalStorageContextManager().enable() });
160
+ }
161
+
162
+ if (signalConfig.metric) {
163
+ const exporter = new OTLPMetricExporter();
164
+ meterProvider = new MeterProvider({
165
+ resource,
166
+ readers: [new PeriodicExportingMetricReader({ exporter })],
167
+ });
168
+ metrics.setGlobalMeterProvider(meterProvider);
169
+ metricRecorder = new AgentMetricRecorder(metrics.getMeter("@oh-my-pi/pi-coding-agent"));
170
+ }
171
+
172
+ if (signalConfig.log) {
173
+ const exporter = new OTLPLogExporter();
174
+ logProvider = new LoggerProvider({
175
+ resource,
176
+ processors: [new BatchLogRecordProcessor({ exporter })],
177
+ });
178
+ logs.setGlobalLoggerProvider(logProvider);
179
+ otelLogger = logProvider.getLogger("@oh-my-pi/pi-coding-agent");
180
+ unregisterLogSink = logger.registerLogSink(event => {
181
+ emitOtelLog(
182
+ event.level,
183
+ event.message,
184
+ logAttributesFromContext(event.context),
185
+ "pi.omp.log",
186
+ event.timestamp,
187
+ );
188
+ });
189
+ }
112
190
 
113
191
  const flushTimer = setInterval(() => {
114
- provider?.forceFlush().catch(() => {});
192
+ flushTelemetryExport().catch(() => {});
115
193
  }, FLUSH_INTERVAL_MS);
116
194
  flushTimer.unref();
117
195
 
118
- // Shut down through postmortem rather than a bare signal listener. postmortem
119
- // owns SIGINT/SIGTERM/SIGHUP/exit and quit(), and awaits registered cleanups
120
- // before calling process.exit — so the batch processor's final OTLP export
121
- // completes instead of being cut off mid-flight on the shutdown path.
122
- postmortem.register("otel-trace-export", async () => {
196
+ postmortem.register("otel-export", async () => {
123
197
  clearInterval(flushTimer);
124
- await provider?.shutdown();
198
+ unregisterLogSink?.();
199
+ unregisterLogSink = undefined;
200
+ const shutdowns: Promise<void>[] = [];
201
+ if (traceProvider) shutdowns.push(traceProvider.shutdown());
202
+ if (logProvider) shutdowns.push(logProvider.shutdown());
203
+ if (meterProvider) shutdowns.push(meterProvider.shutdown());
204
+ await Promise.all(shutdowns);
125
205
  });
126
206
  }
127
207
 
128
- /**
129
- * Parse the `OTEL_TRACES_EXPORTER` selection. The value is a case-insensitive,
130
- * comma-separated list; the literal `none` disables span export entirely.
131
- */
132
- function tracesExporterDisabled(raw: string | undefined): boolean {
133
- if (!raw) return false;
134
- return raw.split(",").some(entry => entry.trim().toLowerCase() === "none");
208
+ function resolveSignalConfig(): SignalConfig {
209
+ const signalConfig: SignalConfig = {
210
+ trace: signalEnabled(
211
+ "trace",
212
+ process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
213
+ process.env.OTEL_TRACES_EXPORTER,
214
+ process.env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL,
215
+ ),
216
+ log: signalEnabled(
217
+ "log",
218
+ process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
219
+ process.env.OTEL_LOGS_EXPORTER,
220
+ process.env.OTEL_EXPORTER_OTLP_LOGS_PROTOCOL ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL,
221
+ ),
222
+ metric: signalEnabled(
223
+ "metric",
224
+ process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
225
+ process.env.OTEL_METRICS_EXPORTER,
226
+ process.env.OTEL_EXPORTER_OTLP_METRICS_PROTOCOL ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL,
227
+ ),
228
+ };
229
+ return signalConfig;
230
+ }
231
+
232
+ function signalEnabled(
233
+ signal: TelemetrySignal,
234
+ endpoint: string | undefined,
235
+ exporterSelection: string | undefined,
236
+ protocolSelection: string | undefined,
237
+ ): boolean {
238
+ if (exporterSelection) {
239
+ for (const entry of exporterSelection.split(",")) {
240
+ if (entry.trim().toLowerCase() === "none") return false;
241
+ }
242
+ }
243
+ if (!endpoint) return false;
244
+
245
+ const protocol = protocolSelection?.trim().toLowerCase();
246
+ if (protocol && protocol !== "http/protobuf") {
247
+ logger.warn(`OTEL ${signal} export disabled: OTEL_EXPORTER_OTLP_PROTOCOL=${protocol} is unsupported`, {
248
+ supported: "http/protobuf",
249
+ });
250
+ return false;
251
+ }
252
+ return true;
253
+ }
254
+
255
+ class AgentMetricRecorder {
256
+ readonly #tokenUsage: Histogram<Attributes>;
257
+ readonly #chatCostUsd: Counter<Attributes>;
258
+ readonly #runs: Counter<Attributes>;
259
+ readonly #steps: Counter<Attributes>;
260
+ readonly #chatCalls: Counter<Attributes>;
261
+ readonly #chatDurationMs: Histogram<Attributes>;
262
+ readonly #toolCalls: Counter<Attributes>;
263
+ readonly #toolDurationMs: Histogram<Attributes>;
264
+ readonly #errors: Counter<Attributes>;
265
+
266
+ constructor(meter: Meter) {
267
+ this.#tokenUsage = meter.createHistogram("gen_ai.client.token.usage", {
268
+ description: "Token usage reported by GenAI chat calls.",
269
+ unit: "{token}",
270
+ });
271
+ this.#chatCostUsd = meter.createCounter("pi.omp.agent.chat.cost.estimated_usd", {
272
+ description: "Estimated USD cost for completed chat calls.",
273
+ unit: "USD",
274
+ });
275
+ this.#runs = meter.createCounter("pi.omp.agent.runs", {
276
+ description: "Completed agent runs.",
277
+ unit: "{run}",
278
+ });
279
+ this.#steps = meter.createCounter("pi.omp.agent.steps", {
280
+ description: "Agent loop steps completed inside a run.",
281
+ unit: "{step}",
282
+ });
283
+ this.#chatCalls = meter.createCounter("pi.omp.agent.chat.calls", {
284
+ description: "Chat calls completed inside agent runs.",
285
+ unit: "{call}",
286
+ });
287
+ this.#chatDurationMs = meter.createHistogram("pi.omp.agent.chat.duration", {
288
+ description: "Total chat latency observed in an agent run.",
289
+ unit: "ms",
290
+ });
291
+ this.#toolCalls = meter.createCounter("pi.omp.agent.tool.calls", {
292
+ description: "Tool calls completed inside agent runs.",
293
+ unit: "{call}",
294
+ });
295
+ this.#toolDurationMs = meter.createHistogram("pi.omp.agent.tool.duration", {
296
+ description: "Total tool latency observed in an agent run.",
297
+ unit: "ms",
298
+ });
299
+ this.#errors = meter.createCounter("pi.omp.agent.errors", {
300
+ description: "Errors observed in chat and tool execution.",
301
+ unit: "{error}",
302
+ });
303
+ }
304
+
305
+ recordChatUsage(event: ChatUsageEvent): void {
306
+ const baseAttrs = metricAttributes({
307
+ "gen_ai.operation.name": "chat",
308
+ "gen_ai.provider.name": event.provider,
309
+ "gen_ai.request.model": event.model,
310
+ "gen_ai.response.service_tier": event.serviceTier,
311
+ "pi.gen_ai.agent.id": event.agent?.id,
312
+ "pi.gen_ai.agent.name": event.agent?.name,
313
+ });
314
+
315
+ this.#recordToken(event.usage.inputTokens, baseAttrs, "input");
316
+ this.#recordToken(event.usage.outputTokens, baseAttrs, "output");
317
+ this.#recordToken(event.usage.totalTokens, baseAttrs, "total");
318
+ this.#recordToken(event.usage.cachedInputTokens, baseAttrs, "cache_read_input");
319
+ this.#recordToken(event.usage.cacheWriteTokens, baseAttrs, "cache_write_input");
320
+ this.#recordToken(event.usage.reasoningOutputTokens, baseAttrs, "reasoning_output");
321
+
322
+ if (event.cost && "usd" in event.cost && event.cost.usd > 0) {
323
+ this.#chatCostUsd.add(event.cost.usd, baseAttrs);
324
+ }
325
+ }
326
+
327
+ recordRun(summary: AgentRunSummary, coverage: AgentRunCoverage): void {
328
+ const runAttrs = metricAttributes({
329
+ "pi.omp.agent.models_used.count": coverage.modelsUsed.length,
330
+ "pi.omp.agent.providers_used.count": coverage.providersUsed.length,
331
+ "pi.omp.agent.tools_available.count": coverage.toolsAvailable.length,
332
+ "pi.omp.agent.tools_invoked.count": coverage.toolsInvoked.length,
333
+ "pi.omp.agent.tools_unused.count": coverage.toolsUnused.length,
334
+ });
335
+
336
+ this.#runs.add(1, runAttrs);
337
+ if (summary.stepCount > 0) this.#steps.add(summary.stepCount, runAttrs);
338
+ if (summary.chats.totalLatencyMs > 0) this.#chatDurationMs.record(summary.chats.totalLatencyMs, runAttrs);
339
+
340
+ for (const reason in summary.chats.byStopReason) {
341
+ const count = summary.chats.byStopReason[reason];
342
+ if (count > 0)
343
+ this.#chatCalls.add(count, metricAttributes({ ...runAttrs, "gen_ai.response.finish_reason": reason }));
344
+ }
345
+ for (const toolName in summary.tools.byName) {
346
+ const counters = summary.tools.byName[toolName];
347
+ const toolAttrs = metricAttributes({ ...runAttrs, "gen_ai.tool.name": toolName });
348
+ if (counters.totalLatencyMs > 0) this.#toolDurationMs.record(counters.totalLatencyMs, toolAttrs);
349
+ for (const status of TOOL_STATUSES) {
350
+ const count = counters[status];
351
+ if (count > 0) this.#toolCalls.add(count, metricAttributes({ ...toolAttrs, "pi.omp.tool.status": status }));
352
+ }
353
+ }
354
+ for (const errorType in summary.errors.byType) {
355
+ const count = summary.errors.byType[errorType];
356
+ if (count > 0) this.#errors.add(count, metricAttributes({ ...runAttrs, "error.type": errorType }));
357
+ }
358
+ }
359
+
360
+ #recordToken(value: number | undefined, baseAttrs: Attributes, tokenType: string): void {
361
+ if (!value || value <= 0) return;
362
+ this.#tokenUsage.record(value, metricAttributes({ ...baseAttrs, "gen_ai.token.type": tokenType }));
363
+ }
364
+ }
365
+
366
+ function metricAttributes(fields: Readonly<Record<string, unknown>>): Attributes {
367
+ const out: Attributes = {};
368
+ for (const key in fields) {
369
+ const value = fields[key];
370
+ if (value === undefined || value === null) continue;
371
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
372
+ out[key] = value;
373
+ continue;
374
+ }
375
+ const text = String(value);
376
+ if (text.length > 0) out[key] = text;
377
+ }
378
+ return out;
379
+ }
380
+
381
+ function emitRunSummaryLog(summary: AgentRunSummary, coverage: AgentRunCoverage): void {
382
+ emitOtelLog(
383
+ "info",
384
+ "agent run completed",
385
+ {
386
+ "pi.omp.agent.step_count": summary.stepCount,
387
+ "pi.omp.agent.chats.total": summary.chats.total,
388
+ "pi.omp.agent.chats.total_latency_ms": summary.chats.totalLatencyMs,
389
+ "pi.omp.agent.tools.total": summary.tools.total,
390
+ "pi.omp.agent.tools.ok": summary.tools.ok,
391
+ "pi.omp.agent.tools.error": summary.tools.error,
392
+ "pi.omp.agent.tools.skipped": summary.tools.skipped,
393
+ "pi.omp.agent.tools.blocked": summary.tools.blocked,
394
+ "pi.omp.agent.tools.timeout": summary.tools.timeout,
395
+ "pi.omp.agent.tools.aborted": summary.tools.aborted,
396
+ "pi.omp.agent.tools.total_latency_ms": summary.tools.totalLatencyMs,
397
+ "pi.omp.agent.usage.input_tokens": summary.usage.inputTokens,
398
+ "pi.omp.agent.usage.output_tokens": summary.usage.outputTokens,
399
+ "pi.omp.agent.usage.cached_input_tokens": summary.usage.cachedInputTokens,
400
+ "pi.omp.agent.usage.cache_write_tokens": summary.usage.cacheWriteTokens,
401
+ "pi.omp.agent.usage.reasoning_output_tokens": summary.usage.reasoningOutputTokens,
402
+ "pi.omp.agent.usage.total_tokens": summary.usage.totalTokens,
403
+ "pi.omp.agent.cost.estimated_usd": summary.cost.estimatedUsd,
404
+ "pi.omp.agent.cost.unavailable_reasons": summary.cost.unavailableReasons.join(","),
405
+ "pi.omp.agent.errors.total": summary.errors.total,
406
+ "pi.omp.agent.coverage.tools_available": coverage.toolsAvailable.join(","),
407
+ "pi.omp.agent.coverage.tools_invoked": coverage.toolsInvoked.join(","),
408
+ "pi.omp.agent.coverage.tools_unused": coverage.toolsUnused.join(","),
409
+ "pi.omp.agent.coverage.models_used": coverage.modelsUsed.join(","),
410
+ "pi.omp.agent.coverage.providers_used": coverage.providersUsed.join(","),
411
+ },
412
+ "pi.omp.agent.run.completed",
413
+ );
414
+ }
415
+
416
+ function emitTelemetryWarningLog(warning: AgentTelemetryWarning): void {
417
+ const attrs = logAttributesFromContext({
418
+ code: warning.code,
419
+ error: warning.error,
420
+ });
421
+ emitOtelLog("warn", warning.message, attrs, "pi.omp.telemetry.warning");
422
+ }
423
+
424
+ function emitOtelLog(
425
+ level: logger.LogLevel,
426
+ body: string,
427
+ attributes: LogAttributes,
428
+ eventName: string,
429
+ timestamp = new Date(),
430
+ ): void {
431
+ if (!otelLogger) return;
432
+ const minLevel = parseOtelLogLevel(process.env.OTEL_LOG_LEVEL);
433
+ if (minLevel === "none") return;
434
+ if (LOG_LEVEL_WEIGHT[level] > LOG_LEVEL_WEIGHT[minLevel]) return;
435
+ otelLogger.emit({
436
+ eventName,
437
+ timestamp,
438
+ observedTimestamp: new Date(),
439
+ severityNumber: LOG_SEVERITY[level],
440
+ severityText: level.toUpperCase(),
441
+ body,
442
+ attributes,
443
+ context: context.active(),
444
+ });
445
+ }
446
+
447
+ function parseOtelLogLevel(raw: string | undefined): OtelLogLevel {
448
+ if (!raw) return "info";
449
+ switch (raw.trim().toLowerCase()) {
450
+ case "none":
451
+ return "none";
452
+ case "error":
453
+ return "error";
454
+ case "warn":
455
+ case "warning":
456
+ return "warn";
457
+ case "debug":
458
+ return "debug";
459
+ default:
460
+ return "info";
461
+ }
462
+ }
463
+
464
+ function logAttributesFromContext(input: Record<string, unknown> | undefined): LogAttributes {
465
+ const out: LogAttributes = { "process.pid": process.pid };
466
+ if (!input) return out;
467
+ for (const key in input) {
468
+ const attr = logAttributeValue(input[key]);
469
+ if (attr !== undefined) out[key] = attr;
470
+ }
471
+ return out;
472
+ }
473
+
474
+ function logAttributeValue(value: unknown): AttributeValue | undefined {
475
+ if (value === undefined || value === null) return undefined;
476
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
477
+ if (value instanceof Error) {
478
+ return `${value.name}: ${value.message}`;
479
+ }
480
+ try {
481
+ const text = JSON.stringify(value);
482
+ if (text && text.length > 0) return text;
483
+ } catch {
484
+ return String(value);
485
+ }
486
+ return String(value);
135
487
  }
136
488
 
137
489
  /**
138
- * Flush any buffered spans to the exporter. No-op when export is disabled.
490
+ * Flush buffered spans, log records, and metrics. No-op when export is disabled.
139
491
  * Hosts embedding the agent can call this at natural boundaries (e.g. the end
140
- * of a turn) so traces surface promptly rather than on the batch interval.
492
+ * of a turn) so telemetry surfaces promptly rather than on the batch interval.
141
493
  */
142
494
  export async function flushTelemetryExport(): Promise<void> {
143
- await provider?.forceFlush();
495
+ const flushes: Promise<void>[] = [];
496
+ if (traceProvider) flushes.push(traceProvider.forceFlush());
497
+ if (logProvider) flushes.push(logProvider.forceFlush());
498
+ if (meterProvider) flushes.push(meterProvider.forceFlush());
499
+ await Promise.all(flushes);
144
500
  }
@@ -6,7 +6,7 @@ describe("eval tool description", () => {
6
6
  const description = getEvalToolDescription({ py: true, js: false, spawns: "fact-finder,oracle" });
7
7
 
8
8
  expect(description).toContain('agent(prompt, agent?="fact-finder"');
9
- expect(description).toContain("Allowed: `fact-finder`, `oracle`.");
9
+ expect(description).toContain("Allowed agents: `fact-finder`, `oracle`.");
10
10
  });
11
11
 
12
12
  it("omits agent() when spawning is disabled", () => {
@@ -75,6 +75,17 @@ function getToolDecision(tool: ApprovalSubject, args: unknown): Omit<ResolvedApp
75
75
  return normalizeDecision(decision);
76
76
  }
77
77
 
78
+ /**
79
+ * Evaluate a tool's own approval declaration against `args` and return the
80
+ * resulting capability tier, defaulting to `exec` when the tool omits an
81
+ * approval. Unlike reading `tool.approval` directly, this runs function-valued
82
+ * approvals — the write tool's `xd://` gate uses it to take a mounted device's
83
+ * argument-dependent tier instead of falling back to `exec`.
84
+ */
85
+ export function resolveToolTier(tool: ApprovalSubject, args: unknown): ToolTier {
86
+ return getToolDecision(tool, args).tier;
87
+ }
88
+
78
89
  function modeApprovesTier(mode: ApprovalMode, tier: ToolTier): boolean {
79
90
  return TIER_RANK[tier] <= TIER_RANK[APPROVAL_MODE_MAX_TIER[mode]];
80
91
  }