@senad-d/observme 0.1.4 → 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.
Files changed (71) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +24 -8
  3. package/docs/agent-subagent-observability-requirements.md +4 -3
  4. package/docs/compatibility-matrix.md +14 -3
  5. package/docs/configuration.md +2 -2
  6. package/docs/extension-integration.md +20 -13
  7. package/docs/reference/03-pi-event-and-session-model.md +6 -6
  8. package/docs/reference/04-telemetry-semantic-conventions.md +1 -0
  9. package/docs/reference/06-security-privacy-redaction.md +13 -6
  10. package/docs/reference/08-query-grafana-integration.md +30 -7
  11. package/docs/reference/11-deployment-runbooks.md +21 -0
  12. package/docs/reference/12-configuration-reference.md +30 -4
  13. package/docs/review-validation.md +10 -0
  14. package/examples/integrations/subagent-runner.ts +8 -5
  15. package/examples/observme.yaml +2 -2
  16. package/package.json +12 -4
  17. package/src/commands/obs-agents.ts +17 -12
  18. package/src/commands/obs-backfill.ts +2 -2
  19. package/src/commands/obs-command-support.ts +2 -1
  20. package/src/commands/obs-cost.ts +15 -7
  21. package/src/commands/obs-health.ts +22 -2
  22. package/src/commands/obs-loki-summary.ts +4 -8
  23. package/src/commands/obs-session.ts +35 -28
  24. package/src/commands/obs-status.ts +56 -13
  25. package/src/commands/obs-tools.ts +19 -8
  26. package/src/commands/obs-trace.ts +9 -0
  27. package/src/commands/obs.ts +6 -1
  28. package/src/config/bootstrap-project-config.ts +49 -32
  29. package/src/config/defaults.ts +0 -2
  30. package/src/config/load-config.ts +270 -92
  31. package/src/config/project-paths.ts +318 -6
  32. package/src/config/query-limits.ts +10 -0
  33. package/src/config/schema.ts +9 -8
  34. package/src/config/transport-security.ts +107 -0
  35. package/src/config/validate.ts +68 -11
  36. package/src/extension.ts +2 -12
  37. package/src/integration.ts +6 -2
  38. package/src/otel/logs.ts +24 -13
  39. package/src/otel/metrics.ts +24 -13
  40. package/src/otel/otlp-endpoint.ts +41 -2
  41. package/src/otel/otlp-http-options.ts +11 -0
  42. package/src/otel/sdk.ts +155 -9
  43. package/src/otel/shutdown.ts +39 -11
  44. package/src/otel/traces.ts +34 -16
  45. package/src/pi/agent-tree-tracker.ts +14 -1
  46. package/src/pi/compatibility.ts +30 -0
  47. package/src/pi/event-handlers/agent-turn.ts +16 -9
  48. package/src/pi/event-handlers/lifecycle.ts +207 -75
  49. package/src/pi/event-handlers/llm.ts +17 -9
  50. package/src/pi/event-handlers/session-events.ts +53 -19
  51. package/src/pi/event-handlers/tool-bash.ts +21 -27
  52. package/src/pi/handler-internals.ts +16 -26
  53. package/src/pi/handler-runtime.ts +142 -55
  54. package/src/pi/handler-types.ts +30 -15
  55. package/src/pi/handlers.ts +12 -1
  56. package/src/pi/integration-api.ts +27 -15
  57. package/src/pi/session-correlation.ts +167 -0
  58. package/src/pi/subagent-spawn.ts +164 -31
  59. package/src/privacy/redact.ts +574 -68
  60. package/src/privacy/secret-patterns.ts +6 -1
  61. package/src/query/grafana-readiness.ts +18 -2
  62. package/src/query/grafana-transport.ts +150 -27
  63. package/src/query/grafana-url.ts +36 -0
  64. package/src/query/grafana.ts +4 -136
  65. package/src/query/loki.ts +2 -4
  66. package/src/query/prometheus.ts +8 -10
  67. package/src/query/tempo.ts +2 -4
  68. package/src/query/trace-link.ts +186 -0
  69. package/src/safety/display-bounds.ts +84 -0
  70. package/src/semconv/metrics.ts +1 -0
  71. package/src/semconv/values.ts +2 -0
@@ -1,5 +1,9 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { ObservMeConfig } from "../config/schema.ts";
3
+ import { readDiagnosticMessage, sanitizeDiagnosticText } from "../diagnostics/sanitize.ts";
4
+ import { buildGrafanaTraceLink } from "../query/trace-link.ts";
2
5
  import { completeObsSubcommand, isExactObsSubcommandRequest } from "./obs-args.ts";
6
+ import { notifyObsCommand } from "./obs-command-support.ts";
3
7
 
4
8
  export interface ObsSessionCommandContext {
5
9
  readonly ui: {
@@ -15,6 +19,7 @@ export interface ObsSessionSnapshot {
15
19
  readonly toolCalls: number;
16
20
  readonly costUsd: number;
17
21
  readonly traceLink?: string;
22
+ readonly traceLinkError?: string;
18
23
  }
19
24
 
20
25
  export interface ObsSessionRuntimeStatePatch {
@@ -25,12 +30,13 @@ export interface ObsSessionRuntimeStatePatch {
25
30
  readonly toolCalls?: number;
26
31
  readonly costUsd?: number;
27
32
  readonly traceLink?: string;
33
+ readonly traceLinkError?: string;
28
34
  }
29
35
 
30
36
  export interface StartObsSessionRuntimeStateOptions {
31
37
  readonly sessionId?: string;
32
38
  readonly traceId?: string;
33
- readonly traceUrlTemplate?: string;
39
+ readonly config?: ObservMeConfig;
34
40
  }
35
41
 
36
42
  export type ObsSessionProvider = (ctx: ObsSessionCommandContext) => Promise<ObsSessionSnapshot> | ObsSessionSnapshot;
@@ -47,6 +53,12 @@ interface MutableObsSessionRuntimeState {
47
53
  toolCalls: number;
48
54
  costUsd: number;
49
55
  traceLink?: string;
56
+ traceLinkError?: string;
57
+ }
58
+
59
+ interface ObsSessionTraceLinkState {
60
+ readonly traceLink?: string;
61
+ readonly traceLinkError?: string;
50
62
  }
51
63
 
52
64
  const OBS_COMMAND_NAME = "obs";
@@ -71,15 +83,15 @@ export async function handleObsSessionCommand(
71
83
  options: RegisterObsSessionCommandOptions = {},
72
84
  ): Promise<void> {
73
85
  if (!isObsSessionRequest(args)) {
74
- await notifySession(ctx, "Usage: /obs session", "warning");
86
+ await notifyObsCommand(ctx, "Usage: /obs session", "warning");
75
87
  return;
76
88
  }
77
89
 
78
90
  try {
79
91
  const snapshot = await resolveObsSessionSnapshot(ctx, options);
80
- await notifySession(ctx, renderObsSession(snapshot), "info");
92
+ await notifyObsCommand(ctx, renderObsSession(snapshot), snapshot.traceLinkError ? "warning" : "info");
81
93
  } catch (error) {
82
- await notifySession(ctx, `ObservMe session unavailable: ${formatError(error)}`, "error");
94
+ await notifyObsCommand(ctx, `ObservMe session unavailable: ${formatError(error)}`, "error");
83
95
  }
84
96
  }
85
97
 
@@ -103,12 +115,13 @@ export function renderObsSession(snapshot: ObsSessionSnapshot): string {
103
115
  ];
104
116
 
105
117
  if (normalized.traceLink) lines.push(`Open trace: ${normalized.traceLink}`);
118
+ else if (normalized.traceLinkError) lines.push(`Trace link unavailable: ${normalized.traceLinkError}`);
106
119
  return lines.join("\n");
107
120
  }
108
121
 
109
122
  export function startObsSessionRuntimeState(options: StartObsSessionRuntimeStateOptions): void {
110
123
  const traceId = normalizeTraceId(options.traceId);
111
- const traceLink = buildObsSessionTraceLink(traceId, options.traceUrlTemplate);
124
+ const traceLinkState = createObsSessionTraceLinkState(options.config, traceId);
112
125
 
113
126
  replaceObsSessionRuntimeState({
114
127
  sessionId: normalizeString(options.sessionId),
@@ -117,10 +130,23 @@ export function startObsSessionRuntimeState(options: StartObsSessionRuntimeState
117
130
  llmCalls: 0,
118
131
  toolCalls: 0,
119
132
  costUsd: 0,
120
- traceLink,
133
+ ...traceLinkState,
121
134
  });
122
135
  }
123
136
 
137
+ function createObsSessionTraceLinkState(
138
+ config: ObservMeConfig | undefined,
139
+ traceId: string | undefined,
140
+ ): ObsSessionTraceLinkState {
141
+ if (!config || !traceId) return {};
142
+
143
+ try {
144
+ return { traceLink: buildGrafanaTraceLink(config, traceId) };
145
+ } catch (error) {
146
+ return { traceLinkError: sanitizeDiagnosticText(readDiagnosticMessage(error)) };
147
+ }
148
+ }
149
+
124
150
  export function updateObsSessionRuntimeState(patch: ObsSessionRuntimeStatePatch): void {
125
151
  if (patch.sessionId !== undefined) runtimeSessionState.sessionId = normalizeString(patch.sessionId);
126
152
  if (patch.traceId !== undefined) runtimeSessionState.traceId = normalizeTraceId(patch.traceId);
@@ -129,6 +155,7 @@ export function updateObsSessionRuntimeState(patch: ObsSessionRuntimeStatePatch)
129
155
  if (patch.toolCalls !== undefined) runtimeSessionState.toolCalls = normalizeCount(patch.toolCalls);
130
156
  if (patch.costUsd !== undefined) runtimeSessionState.costUsd = normalizeCost(patch.costUsd);
131
157
  if (patch.traceLink !== undefined) runtimeSessionState.traceLink = normalizeString(patch.traceLink);
158
+ if (patch.traceLinkError !== undefined) runtimeSessionState.traceLinkError = normalizeString(patch.traceLinkError);
132
159
  }
133
160
 
134
161
  export function recordObsSessionTurn(count = 1): void {
@@ -152,20 +179,6 @@ export function clearObsSessionRuntimeState(): void {
152
179
  replaceObsSessionRuntimeState(createEmptyObsSessionRuntimeState());
153
180
  }
154
181
 
155
- export function buildObsSessionTraceLink(traceId: string | undefined, traceUrlTemplate: string | undefined): string | undefined {
156
- const template = normalizeString(traceUrlTemplate);
157
- if (!traceId || !template) return undefined;
158
-
159
- if (template.includes("{{traceId}}")) return template.replaceAll("{{traceId}}", encodeURIComponent(traceId));
160
- if (template.includes("${traceId}")) return template.replaceAll("${traceId}", encodeURIComponent(traceId));
161
- if (template.includes("{traceId}")) return template.replaceAll("{traceId}", encodeURIComponent(traceId));
162
- if (template.includes("$traceId")) return template.replaceAll("$traceId", encodeURIComponent(traceId));
163
- if (template.includes("%TRACE_ID%")) return template.replaceAll("%TRACE_ID%", encodeURIComponent(traceId));
164
- if (template.includes("__TRACE_ID__")) return template.replaceAll("__TRACE_ID__", encodeURIComponent(traceId));
165
- if (template.includes("...")) return template.replaceAll("...", encodeURIComponent(traceId));
166
- return undefined;
167
- }
168
-
169
182
  class ObsSessionCommand {
170
183
  readonly #options: RegisterObsSessionCommandOptions;
171
184
 
@@ -194,6 +207,7 @@ function replaceObsSessionRuntimeState(state: MutableObsSessionRuntimeState): vo
194
207
  runtimeSessionState.toolCalls = state.toolCalls;
195
208
  runtimeSessionState.costUsd = state.costUsd;
196
209
  runtimeSessionState.traceLink = state.traceLink;
210
+ runtimeSessionState.traceLinkError = state.traceLinkError;
197
211
  }
198
212
 
199
213
  function createEmptyObsSessionRuntimeState(): MutableObsSessionRuntimeState {
@@ -214,6 +228,7 @@ function normalizeObsSessionSnapshot(snapshot: ObsSessionSnapshot): ObsSessionSn
214
228
  toolCalls: normalizeCount(snapshot.toolCalls),
215
229
  costUsd: normalizeCost(snapshot.costUsd),
216
230
  traceLink: normalizeString(snapshot.traceLink),
231
+ traceLinkError: normalizeString(snapshot.traceLinkError),
217
232
  };
218
233
  }
219
234
 
@@ -246,14 +261,6 @@ function isObsSessionRequest(args: string): boolean {
246
261
  return isExactObsSubcommandRequest(args, OBS_SESSION_SUBCOMMAND);
247
262
  }
248
263
 
249
- async function notifySession(
250
- ctx: ObsSessionCommandContext,
251
- message: string,
252
- type: "info" | "warning" | "error",
253
- ): Promise<void> {
254
- await ctx.ui?.notify?.(message, type);
255
- }
256
-
257
264
  function formatError(error: unknown): string {
258
265
  return error instanceof Error ? error.message : String(error);
259
266
  }
@@ -1,9 +1,21 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import type { LoadSessionConfigOptions, SessionConfigDiagnostics, SessionConfigEffectiveSource } from "../config/load-config.ts";
2
+ import type {
3
+ LoadSessionConfigOptions,
4
+ SessionConfigDiagnostics,
5
+ SessionConfigEffectiveSource,
6
+ SessionConfigEnvFileStatus,
7
+ SessionConfigEnvironmentStatus,
8
+ SessionConfigProjectStatus,
9
+ } from "../config/load-config.ts";
3
10
  import { loadSessionConfigWithDiagnostics } from "../config/load-config.ts";
4
11
  import type { CaptureConfig, ObservMeConfig } from "../config/schema.ts";
12
+ import {
13
+ describeGrafanaTransportSecurity,
14
+ describeOtlpTransportSecurity,
15
+ } from "../config/transport-security.ts";
5
16
  import { getGrafanaQueryReadiness } from "../query/grafana-readiness.ts";
6
17
  import { completeObsSubcommand, isExactObsSubcommandRequest } from "./obs-args.ts";
18
+ import { notifyObsCommand } from "./obs-command-support.ts";
7
19
  import { sanitizeObsDiagnosticText } from "./obs-diagnostics.ts";
8
20
 
9
21
  export interface ObsStatusCommandContext {
@@ -93,15 +105,15 @@ export async function handleObsStatusCommand(
93
105
  options: RegisterObsStatusCommandOptions = {},
94
106
  ): Promise<void> {
95
107
  if (!isObsStatusRequest(args)) {
96
- await notifyStatus(ctx, "Usage: /obs status", "warning");
108
+ await notifyObsCommand(ctx, "Usage: /obs status", "warning");
97
109
  return;
98
110
  }
99
111
 
100
112
  try {
101
113
  const snapshot = await resolveObsStatusSnapshot(ctx, options);
102
- await notifyStatus(ctx, renderObsStatus(snapshot), "info");
114
+ await notifyObsCommand(ctx, renderObsStatus(snapshot), "info");
103
115
  } catch (error) {
104
- await notifyStatus(ctx, `ObservMe status unavailable: ${sanitizeObsDiagnosticText(formatError(error))}`, "error");
116
+ await notifyObsCommand(ctx, `ObservMe status unavailable: ${sanitizeObsDiagnosticText(formatError(error))}`, "error");
105
117
  }
106
118
  }
107
119
 
@@ -129,8 +141,10 @@ export function renderObsStatus(snapshot: ObsStatusSnapshot): string {
129
141
  const lines = [
130
142
  `ObservMe: ${formatEnabled(config.enabled)}`,
131
143
  `OTLP endpoint: ${formatSafeConfiguredEndpoint(config.otlp.endpoint)}`,
144
+ `OTLP transport security: ${describeOtlpTransportSecurity(config)}`,
132
145
  ...formatConfigDiagnosticsLines(snapshot.configDiagnostics),
133
146
  `Grafana URL: ${formatSafeConfiguredUrl(config.query.grafana.url)}`,
147
+ `Grafana transport security: ${describeGrafanaTransportSecurity(config)}`,
134
148
  `Grafana query readiness: ${formatGrafanaQueryReadiness(config)}`,
135
149
  `Traces: ${formatEnabled(signalEnabled(config, config.traces.enabled))}`,
136
150
  `Metrics: ${formatEnabled(signalEnabled(config, config.metrics.enabled))}`,
@@ -237,10 +251,6 @@ function isObsStatusRequest(args: string): boolean {
237
251
  return isExactObsSubcommandRequest(args, OBS_STATUS_SUBCOMMAND, { allowEmpty: true });
238
252
  }
239
253
 
240
- async function notifyStatus(ctx: ObsStatusCommandContext, message: string, type: "info" | "warning" | "error"): Promise<void> {
241
- await ctx.ui?.notify?.(message, type);
242
- }
243
-
244
254
  function formatCaptureLines(capture: CaptureConfig): string[] {
245
255
  return [
246
256
  `Prompt capture: ${formatEnabled(capture.prompts)}`,
@@ -259,7 +269,10 @@ function formatConfigDiagnosticsLines(diagnostics: SessionConfigDiagnostics | un
259
269
 
260
270
  return [
261
271
  `Config source: ${formatConfigEffectiveSource(diagnostics.effectiveSource)}`,
262
- `Project config: ${formatProjectConfigStatus(diagnostics)}`,
272
+ ...formatGlobalConfigStatus(diagnostics),
273
+ `Project config: ${formatProjectConfigStatus(diagnostics.projectConfigStatus)}`,
274
+ ...formatEnvFileStatus(diagnostics.envFileStatus),
275
+ ...formatEnvironmentStatus(diagnostics.environmentStatus),
263
276
  ...formatConfigRejectionLines(diagnostics),
264
277
  ];
265
278
  }
@@ -281,13 +294,43 @@ function formatConfigEffectiveSource(source: SessionConfigEffectiveSource): stri
281
294
  return "defaults";
282
295
  }
283
296
 
284
- function formatProjectConfigStatus(diagnostics: SessionConfigDiagnostics): string {
285
- if (diagnostics.projectConfigStatus === "loaded") return "loaded (trusted .pi/observme.yaml)";
286
- if (diagnostics.projectConfigStatus === "skipped_untrusted") {
297
+ function formatGlobalConfigStatus(diagnostics: SessionConfigDiagnostics): string[] {
298
+ if (!diagnostics.globalConfigStatus) return [];
299
+ return [`Global config: ${formatFileSourceStatus(diagnostics.globalConfigStatus, "global config")}`];
300
+ }
301
+
302
+ function formatProjectConfigStatus(status: SessionConfigProjectStatus): string {
303
+ if (status === "loaded") return "loaded (trusted .pi/observme.yaml)";
304
+ if (status === "skipped_untrusted") {
287
305
  return "skipped (project is untrusted; safe defaults/global/env only)";
288
306
  }
307
+ if (status === "missing") return "missing (trusted project has no .pi/observme.yaml)";
308
+ return formatFileSourceStatus(status, "trusted .pi/observme.yaml");
309
+ }
310
+
311
+ function formatEnvFileStatus(status: SessionConfigEnvFileStatus | undefined): string[] {
312
+ if (!status) return [];
313
+ if (status === "skipped_untrusted") return ["Project .env: skipped (project is untrusted)"];
314
+ if (status === "skipped_disabled") return ["Project .env: skipped (loading is disabled)"];
315
+ return [`Project .env: ${formatFileSourceStatus(status, "trusted project .env")}`];
316
+ }
317
+
318
+ function formatEnvironmentStatus(status: SessionConfigEnvironmentStatus | undefined): string[] {
319
+ if (!status) return [];
320
+ if (status === "loaded") return ["Process environment: loaded"];
321
+ if (status === "rejected") return ["Process environment: rejected (malformed supported override)"];
322
+ return ["Process environment: no ObservMe values"];
323
+ }
289
324
 
290
- return "missing (trusted project has no .pi/observme.yaml)";
325
+ function formatFileSourceStatus(
326
+ status: Exclude<SessionConfigProjectStatus, "skipped_untrusted">,
327
+ label: string,
328
+ ): string {
329
+ if (status === "loaded") return "loaded";
330
+ if (status === "missing") return "missing";
331
+ if (status === "malformed") return `ignored (${label} is malformed)`;
332
+ if (status === "unreadable") return `ignored (${label} is unreadable)`;
333
+ return `ignored (${label} was structurally rejected)`;
291
334
  }
292
335
 
293
336
  function formatGrafanaQueryReadiness(config: ObservMeConfig): string {
@@ -3,6 +3,11 @@ import type { LoadSessionConfigOptions } from "../config/load-config.ts";
3
3
  import type { ObservMeConfig } from "../config/schema.ts";
4
4
  import type { PrometheusFetch, PrometheusMetricSeries, QueryResult } from "../query/prometheus.ts";
5
5
  import { createPrometheusQueryClient } from "../query/prometheus.ts";
6
+ import {
7
+ boundObsCommandOutput,
8
+ normalizeObsBackendLabel,
9
+ selectObsCommandRows,
10
+ } from "../safety/display-bounds.ts";
6
11
  import { completeObsSubcommand, isExactObsSubcommandRequest } from "./obs-args.ts";
7
12
  import { loadObsCommandConfig, notifyObsCommand } from "./obs-command-support.ts";
8
13
  import { appendObsRecoveryHint, formatObsCommandFailure } from "./obs-diagnostics.ts";
@@ -126,22 +131,29 @@ export async function getObsToolsSnapshot(
126
131
  export function renderObsTools(snapshot: ObsToolsSnapshot): string {
127
132
  const calls = snapshot.calls.map(normalizeObsToolCallRow).filter(isObsToolCallRow);
128
133
  const failures = snapshot.failures.map(normalizeObsToolFailureRow).filter(isObsToolFailureRow);
129
- const lines = [`Tool calls by tool (last ${snapshot.window})`];
134
+ const callSelection = selectObsCommandRows(calls);
135
+ const failureSelection = selectObsCommandRows(failures);
136
+ const window = normalizeObsBackendLabel(snapshot.window) ?? OBS_TOOLS_WINDOW;
137
+ const lines = [`Tool calls by tool (last ${window})`];
130
138
 
131
139
  if (calls.length === 0) {
132
140
  lines.push(appendObsRecoveryHint("No tool call metrics found.", OBS_TOOLS_NO_CALLS_NEXT_ACTION));
133
141
  } else {
134
- lines.push(...calls.map(renderObsToolCallRow));
142
+ lines.push(...callSelection.rows.map(renderObsToolCallRow));
143
+ if (callSelection.omittedCount > 0) lines.push(`… ${callSelection.omittedCount} tool call row(s) omitted`);
135
144
  }
136
145
 
137
- lines.push(`Tool failures by tool/error (last ${snapshot.window})`);
146
+ lines.push(`Tool failures by tool/error (last ${window})`);
138
147
  if (failures.length === 0) {
139
148
  lines.push(appendObsRecoveryHint("No tool failure metrics found.", OBS_TOOLS_NO_FAILURES_NEXT_ACTION));
140
149
  } else {
141
- lines.push(...failures.map(renderObsToolFailureRow));
150
+ lines.push(...failureSelection.rows.map(renderObsToolFailureRow));
151
+ if (failureSelection.omittedCount > 0) {
152
+ lines.push(`… ${failureSelection.omittedCount} tool failure row(s) omitted`);
153
+ }
142
154
  }
143
155
 
144
- return lines.join("\n");
156
+ return boundObsCommandOutput(lines.join("\n"));
145
157
  }
146
158
 
147
159
  class ObsToolsCommand {
@@ -231,12 +243,11 @@ function parseRatePerSecond(value: string | number | undefined): number | undefi
231
243
  }
232
244
 
233
245
  function normalizeMetricLabel(value: string | undefined): string {
234
- return normalizeOptionalString(value) ?? "unknown";
246
+ return normalizeObsBackendLabel(value) ?? "unknown";
235
247
  }
236
248
 
237
249
  function normalizeOptionalString(value: string | undefined): string | undefined {
238
- const trimmed = value?.trim();
239
- return trimmed || undefined;
250
+ return normalizeObsBackendLabel(value);
240
251
  }
241
252
 
242
253
  function renderObsToolCallRow(row: ObsToolCallRow): string {
@@ -92,6 +92,8 @@ const OBS_COMMAND_NAME = "obs";
92
92
  const OBS_TRACE_SUBCOMMAND = "trace";
93
93
  const OBS_TRACE_USAGE = "Usage: /obs trace [--last-turn|--session <session-id>]";
94
94
  const OBS_TRACE_TEMPO_ERROR_NEXT_ACTION = "run /obs health and verify query.grafana.url, Grafana credentials, and the Tempo datasource UID.";
95
+ const OBS_TRACE_LINK_CONFIG_ERROR_NEXT_ACTION =
96
+ "set query.links.traceUrlTemplate to a supported trace-id placeholder or the ellipsis fallback, then verify query.grafana.url.";
95
97
  const OBS_TRACE_SESSION_ERROR_NEXT_ACTION = "wait for a Pi turn or query a generated session id with /obs trace --session <session-id>.";
96
98
  const OBS_TRACE_NOT_FOUND_NEXT_ACTION = "check the session id, wait for trace export, then verify Tempo datasource with /obs health.";
97
99
  const OBS_TRACE_ACTIVE_SESSION_NOTE = "Trace visibility: active sessions may show ended child spans before the root pi.session span; the root is exported after session_shutdown.";
@@ -398,6 +400,9 @@ export function resolveObsTraceDiagnostic(error: unknown): ObsCommandRecoveryHin
398
400
  const message = readObsDiagnosticMessage(error);
399
401
 
400
402
  if (isObsTraceSessionErrorMessage(message)) return { subsystem: "Session trace", nextAction: OBS_TRACE_SESSION_ERROR_NEXT_ACTION };
403
+ if (isObsTraceLinkConfigErrorMessage(message)) {
404
+ return { subsystem: "Grafana trace link", nextAction: OBS_TRACE_LINK_CONFIG_ERROR_NEXT_ACTION };
405
+ }
401
406
  if (isObsTraceNotFoundMessage(message)) return { subsystem: "Tempo", nextAction: OBS_TRACE_NOT_FOUND_NEXT_ACTION };
402
407
  return { subsystem: "Tempo", nextAction: OBS_TRACE_TEMPO_ERROR_NEXT_ACTION };
403
408
  }
@@ -406,6 +411,10 @@ function isObsTraceSessionErrorMessage(message: string): boolean {
406
411
  return /No current ObservMe session trace|No last-turn ObservMe trace|Unsafe ObservMe session id/u.test(message);
407
412
  }
408
413
 
414
+ function isObsTraceLinkConfigErrorMessage(message: string): boolean {
415
+ return /Grafana trace link configuration is invalid/u.test(message);
416
+ }
417
+
409
418
  function isObsTraceNotFoundMessage(message: string): boolean {
410
419
  return /No trace was found/u.test(message);
411
420
  }
@@ -22,6 +22,7 @@ import { handleObsToolsCommand } from "./obs-tools.ts";
22
22
  import type { ObsTraceCommandContext, RegisterObsTraceCommandOptions } from "./obs-trace.ts";
23
23
  import { handleObsTraceCommand } from "./obs-trace.ts";
24
24
  import { completeObsSubcommands, firstObsCommandToken, obsUsageWithError } from "./obs-args.ts";
25
+ import { notifyObsCommand } from "./obs-command-support.ts";
25
26
 
26
27
  export interface ObsCommandContext
27
28
  extends ObsStatusCommandContext,
@@ -153,7 +154,11 @@ export async function handleObsCommand(
153
154
  return;
154
155
  }
155
156
 
156
- await ctx.ui?.notify?.(obsUsageWithError(OBS_ROOT_USAGE, subcommand ? `Unknown subcommand: ${subcommand}.` : undefined), "warning");
157
+ await notifyObsCommand(
158
+ ctx,
159
+ obsUsageWithError(OBS_ROOT_USAGE, subcommand ? `Unknown subcommand: ${subcommand}.` : undefined),
160
+ "warning",
161
+ );
157
162
  }
158
163
 
159
164
  export function getObsRootCommandArgumentCompletions(prefix: string): Array<{ value: string; label: string }> | null {
@@ -1,9 +1,18 @@
1
1
  import { CONFIG_DIR_NAME, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
2
- import { mkdir, writeFile } from "node:fs/promises";
3
- import { dirname } from "node:path";
2
+ import type {
3
+ ExtensionContext,
4
+ SessionStartEvent,
5
+ } from "@earendil-works/pi-coding-agent";
4
6
  import { readDiagnosticMessage, sanitizeDiagnosticText } from "../diagnostics/sanitize.ts";
5
7
  import { RESOURCE_ATTRIBUTES } from "../semconv/attributes.ts";
6
- import { resolveProjectLocalFilePath } from "./project-paths.ts";
8
+ import {
9
+ createCanonicalProjectLocalFileExclusively,
10
+ resolveProjectLocalFilePath,
11
+ } from "./project-paths.ts";
12
+ import type {
13
+ ProjectLocalFileOperationHooks,
14
+ ProjectLocalFilePathOptions,
15
+ } from "./project-paths.ts";
7
16
 
8
17
  export type ProjectConfigBootstrapStatus = "created" | "exists" | "skipped_untrusted";
9
18
  export type ProjectConfigNotifyLevel = "info" | "warning" | "error";
@@ -30,14 +39,15 @@ export interface EnsureProjectConfigOptions {
30
39
  readonly configDirName?: string;
31
40
  readonly cwd?: string;
32
41
  readonly isProjectTrusted?: boolean | (() => boolean | Promise<boolean>);
42
+ readonly projectFileOperationHooks?: ProjectLocalFileOperationHooks;
33
43
  }
34
44
 
35
45
  export type EnsureProjectConfig = (options: EnsureProjectConfigOptions) => Promise<ProjectConfigBootstrapResult>;
36
46
 
37
- type ProjectConfigBootstrapHandler = (event: unknown, ctx: ProjectConfigBootstrapContext) => Promise<void> | void;
47
+ type ProjectConfigBootstrapHandler = (event: SessionStartEvent, ctx: ExtensionContext) => Promise<void> | void;
38
48
 
39
49
  interface ProjectConfigBootstrapPiApi {
40
- on: (eventName: string, handler: ProjectConfigBootstrapHandler) => void;
50
+ on: (eventName: "session_start", handler: ProjectConfigBootstrapHandler) => void;
41
51
  }
42
52
 
43
53
  const observmeYamlFileName = "observme.yaml";
@@ -46,7 +56,6 @@ export const PROJECT_OBSERVME_YAML_TEMPLATE = `observme:
46
56
  enabled: true
47
57
  environment: development
48
58
  tenant: local-dev
49
- replayOnStart: false
50
59
 
51
60
  otlp:
52
61
  endpoint: http://localhost:4318
@@ -54,7 +63,6 @@ export const PROJECT_OBSERVME_YAML_TEMPLATE = `observme:
54
63
  timeoutMs: 3000
55
64
  headers: {}
56
65
  tls:
57
- enabled: false
58
66
  insecureSkipVerify: false
59
67
  signalEndpoints:
60
68
  traces: http://localhost:4318/v1/traces
@@ -86,6 +94,8 @@ export const PROJECT_OBSERVME_YAML_TEMPLATE = `observme:
86
94
  propagateTraceContext: true
87
95
  propagateToSubagents: true
88
96
  capabilityEnv: OBSERVME_AGENT_CAPABILITY
97
+ # Opt in to one validated, branch-local custom entry for reload/resume recovery.
98
+ # This entry never participates in LLM context.
89
99
  writeCorrelationEntry: false
90
100
 
91
101
  traces:
@@ -192,11 +202,16 @@ export function registerProjectConfigBootstrap(
192
202
  export async function ensureProjectObservMeConfig(
193
203
  options: EnsureProjectConfigOptions = {},
194
204
  ): Promise<ProjectConfigBootstrapResult> {
195
- const configPath = resolveProjectObservMeConfigPath(options);
205
+ const pathOptions = createProjectObservMeConfigPathOptions(options);
206
+ const configPath = resolveProjectLocalFilePath(pathOptions);
196
207
  const projectTrusted = await resolveBootstrapProjectTrust(options.isProjectTrusted);
197
208
 
198
209
  if (!projectTrusted) return { path: configPath, status: "skipped_untrusted" };
199
- return createProjectObservMeConfigFile(configPath);
210
+ return createProjectObservMeConfigFile(
211
+ configPath,
212
+ pathOptions,
213
+ options.projectFileOperationHooks,
214
+ );
200
215
  }
201
216
 
202
217
  function createProjectConfigBootstrapHandler(
@@ -254,30 +269,40 @@ async function notifyProjectConfigBootstrapFailed(ctx: ProjectConfigBootstrapCon
254
269
  await ctx.ui?.notify?.(`ObservMe could not create the project config file: ${formatError(error)}`, "warning");
255
270
  }
256
271
 
257
- async function createProjectObservMeConfigFile(configPath: string): Promise<ProjectConfigBootstrapResult> {
258
- return withFileMutationQueue(configPath, createProjectObservMeConfigFileInQueue.bind(undefined, configPath));
272
+ async function createProjectObservMeConfigFile(
273
+ configPath: string,
274
+ pathOptions: ProjectLocalFilePathOptions,
275
+ hooks: ProjectLocalFileOperationHooks | undefined,
276
+ ): Promise<ProjectConfigBootstrapResult> {
277
+ return withFileMutationQueue(
278
+ configPath,
279
+ createProjectObservMeConfigFileInQueue.bind(undefined, configPath, pathOptions, hooks),
280
+ );
259
281
  }
260
282
 
261
- async function createProjectObservMeConfigFileInQueue(configPath: string): Promise<ProjectConfigBootstrapResult> {
262
- await mkdir(dirname(configPath), { recursive: true });
263
-
264
- try {
265
- await writeFile(configPath, PROJECT_OBSERVME_YAML_TEMPLATE, { encoding: "utf8", flag: "wx" });
266
- return { path: configPath, status: "created" };
267
- } catch (error) {
268
- if (isFileAlreadyExistsError(error)) return { path: configPath, status: "exists" };
269
- throw error;
270
- }
283
+ async function createProjectObservMeConfigFileInQueue(
284
+ configPath: string,
285
+ pathOptions: ProjectLocalFilePathOptions,
286
+ hooks: ProjectLocalFileOperationHooks | undefined,
287
+ ): Promise<ProjectConfigBootstrapResult> {
288
+ const status = await createCanonicalProjectLocalFileExclusively(
289
+ pathOptions,
290
+ PROJECT_OBSERVME_YAML_TEMPLATE,
291
+ hooks,
292
+ );
293
+ return { path: configPath, status };
271
294
  }
272
295
 
273
- function resolveProjectObservMeConfigPath(options: EnsureProjectConfigOptions): string {
274
- return resolveProjectLocalFilePath({
296
+ function createProjectObservMeConfigPathOptions(
297
+ options: EnsureProjectConfigOptions,
298
+ ): ProjectLocalFilePathOptions {
299
+ return {
275
300
  cwd: options.cwd,
276
301
  configDirName: options.configDirName,
277
302
  defaultConfigDirName: CONFIG_DIR_NAME,
278
303
  fileName: observmeYamlFileName,
279
304
  inputLabel: "project config path",
280
- });
305
+ };
281
306
  }
282
307
 
283
308
  async function resolveBootstrapProjectTrust(
@@ -288,14 +313,6 @@ async function resolveBootstrapProjectTrust(
288
313
  return false;
289
314
  }
290
315
 
291
- function isFileAlreadyExistsError(error: unknown): boolean {
292
- return isErrorWithCode(error) && error.code === "EEXIST";
293
- }
294
-
295
- function isErrorWithCode(error: unknown): error is NodeJS.ErrnoException {
296
- return typeof error === "object" && error !== null && "code" in error;
297
- }
298
-
299
316
  function formatError(error: unknown): string {
300
317
  return sanitizeDiagnosticText(readDiagnosticMessage(error));
301
318
  }
@@ -5,7 +5,6 @@ export const defaultObservMeConfig = {
5
5
  enabled: true,
6
6
  environment: "production",
7
7
  tenant: "platform",
8
- replayOnStart: false,
9
8
  otlp: {
10
9
  endpoint: "https://otel-collector.example.com:4318",
11
10
  protocol: "http/protobuf",
@@ -14,7 +13,6 @@ export const defaultObservMeConfig = {
14
13
  Authorization: "Bearer ${OBSERVME_OTLP_TOKEN}",
15
14
  },
16
15
  tls: {
17
- enabled: true,
18
16
  insecureSkipVerify: false,
19
17
  },
20
18
  },