@yagni-app/code-staging 1.0.6-staging.1248.1 → 1.0.6-staging.1255.1

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 (47) hide show
  1. package/README.md +71 -46
  2. package/dist/cli.js +4 -4
  3. package/dist/doctor.d.ts +21 -8
  4. package/dist/doctor.js +52 -27
  5. package/dist/extension/condensedTools.d.ts +12 -1
  6. package/dist/extension/condensedTools.js +17 -9
  7. package/dist/extension/index.d.ts +10 -0
  8. package/dist/extension/index.js +127 -43
  9. package/dist/extension/permission/gate.d.ts +44 -2
  10. package/dist/extension/permission/gate.js +107 -25
  11. package/dist/extension/pipeline/invocation.d.ts +3 -6
  12. package/dist/extension/pipeline/invocation.js +3 -6
  13. package/dist/extension/pipeline/runner.d.ts +0 -1
  14. package/dist/extension/pipeline/runner.js +6 -14
  15. package/dist/extension/sandbox/bash.d.ts +99 -0
  16. package/dist/extension/sandbox/bash.js +190 -0
  17. package/dist/extension/sandbox/config.d.ts +114 -0
  18. package/dist/extension/sandbox/config.js +366 -0
  19. package/dist/extension/sandbox/manager.d.ts +98 -0
  20. package/dist/extension/sandbox/manager.js +216 -0
  21. package/dist/extension/sandbox/panel.d.ts +111 -0
  22. package/dist/extension/sandbox/panel.js +342 -0
  23. package/dist/extension/sandbox/session.d.ts +85 -0
  24. package/dist/extension/sandbox/session.js +775 -0
  25. package/dist/extension/telemetry/attrs.d.ts +72 -0
  26. package/dist/extension/telemetry/attrs.js +125 -0
  27. package/dist/extension/telemetry/config.d.ts +99 -0
  28. package/dist/extension/telemetry/config.js +193 -0
  29. package/dist/extension/telemetry/index.d.ts +7 -0
  30. package/dist/extension/telemetry/index.js +7 -0
  31. package/dist/extension/telemetry/probe.d.ts +29 -0
  32. package/dist/extension/telemetry/probe.js +122 -0
  33. package/dist/extension/telemetry/register.d.ts +40 -0
  34. package/dist/extension/telemetry/register.js +192 -0
  35. package/dist/extension/telemetry/sdk.d.ts +63 -0
  36. package/dist/extension/telemetry/sdk.js +207 -0
  37. package/dist/extension/telemetry/tracker.d.ts +131 -0
  38. package/dist/extension/telemetry/tracker.js +530 -0
  39. package/dist/goHeadless.d.ts +1 -1
  40. package/dist/goHeadless.js +2 -2
  41. package/dist/launch.d.ts +4 -3
  42. package/dist/launch.js +3 -4
  43. package/dist/otel.d.ts +67 -90
  44. package/dist/otel.js +152 -195
  45. package/dist/paths.d.ts +7 -0
  46. package/dist/paths.js +10 -0
  47. package/package.json +19 -3
package/README.md CHANGED
@@ -173,62 +173,87 @@ environment is recorded in `~/.yagni-code/config.json`. A pre-profiles
173
173
 
174
174
  ### OTel export (opt-in)
175
175
 
176
- Point sessions at **your own** OpenTelemetry collector (Datadog Agent, Grafana
177
- Alloy, an OTLP-native backend) and every session — including `/go` stage
178
- children and subagents emits a per-prompt span tree: interaction → LLM
179
- request tool calls, following the OTel GenAI semantic conventions. Nothing is
180
- exported unless you configure an endpoint.
181
-
182
- Enable it one of four ways (first match wins):
183
-
184
- - `OTEL_EXPORTER_OTLP_ENDPOINT=…` in the environment (a personal override
185
- handy for pointing one session at a scratch collector), or
186
- - `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=…` in the environment — the per-signal
187
- form Claude Code setups use (e.g.
188
- `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://otlp.datadoghq.com/v1/traces`
189
- with `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf`); the launcher
190
- bridges it to the generic var pi-otel reads, so a Claude Code OTel env
191
- lights up YAGNI Code traces with no extra config, or
176
+ Point sessions at **your own** OpenTelemetry collector (Datadog, Grafana
177
+ Cloud, a Datadog Agent or Grafana Alloy, any OTLP-native backend) and every
178
+ session including `/go` stage children and subagents exports three
179
+ signals in the shape Claude Code's telemetry uses, under a `yagni_code`
180
+ prefix instead of `claude_code`:
181
+
182
+ - **Traces:** a per-prompt span tree, `yagni_code.interaction`
183
+ `yagni_code.llm_request` and `yagni_code.tool`, carrying both the flat
184
+ Claude Code attributes (`model`, `input_tokens`, `cost_usd`, `tool_name`)
185
+ and the OTel GenAI conventions (`gen_ai.*`) that LLM-observability
186
+ products read.
187
+ - **Metrics:** `yagni_code.session.count`, `token.usage` (by `type` and
188
+ `model`), `cost.usage`, `lines_of_code.count`, `commit.count`,
189
+ `pull_request.count`, `code_edit_tool.decision`, `active_time.total`.
190
+ - **Log events:** `user_prompt`, `assistant_response`, `api_request`,
191
+ `api_error`, `tool_result`, `tool_decision`, `permission_mode_changed`
192
+ (as `event.name`, body `yagni_code.<name>`), with `session.id`,
193
+ `organization.id`, `terminal.type`, and a per-prompt `prompt.id`.
194
+
195
+ Nothing is exported unless you configure an endpoint. Enable it one of three
196
+ ways (first match wins):
197
+
198
+ - the standard OTel env in your shell — the generic
199
+ `OTEL_EXPORTER_OTLP_ENDPOINT` (a base URL; `/v1/<signal>` is appended for
200
+ http transports) or the per-signal
201
+ `OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT` a Claude Code setup
202
+ already carries — so a machine configured for Claude Code lights up with
203
+ no extra config (with per-signal endpoints only, exactly those signals
204
+ export; nothing falls back to a localhost collector), or
192
205
  - **workspace settings** (the zero-setup path): a workspace admin sets the
193
206
  endpoint, protocol, and any collector headers (e.g. a Datadog API key) once
194
- in the web app under Settings → YAGNI Code → Trace export. Every session in
195
- the workspace picks it up at launch — nothing to install or configure on
196
- developer machines. Header values are encrypted at rest server-side and the
197
- launch-time copy is cached at mode `0600`, the same posture as your device
198
- token. Or,
199
- - commit `{ "otel": { "endpoint": "http://<collector>:4317" } }` to the repo's
200
- `.pi/settings.json` so one repo's sessions export without per-machine setup.
201
-
202
- Standard OTel env vars are honored (`OTEL_EXPORTER_OTLP_PROTOCOL`,
203
- `OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_SERVICE_NAME` — defaults to `yagni-code`),
204
- and `PI_OTEL_DISABLED=1` is the kill switch. `yagni doctor` shows the current
205
- export state and probes the endpoint live (unreachable a warning with a
206
- hint, never an exit-code flip).
207
-
208
- Two recipes for common collectors:
209
-
210
- - **Datadog, direct SaaS (no agent):** endpoint
211
- `https://otlp.datadoghq.com/v1/traces` (US1; other sites have their own
212
- host `otlp.us3.datadoghq.com`, `otlp.us5.datadoghq.com`,
213
- `otlp.datadoghq.eu`, `otlp.ap1.datadoghq.com`), protocol `http/protobuf`
214
- (Datadog's direct trace intake does not accept gRPC), header
215
- `dd-api-key=<your Datadog API key>`. Use the full path, not `/v1/` — the
216
- endpoint is passed through verbatim, no path is appended for you.
207
+ in the web app under Settings → YAGNI Code → Telemetry export. Every
208
+ session in the workspace picks it up at launch — nothing to install or
209
+ configure on developer machines. Header values are encrypted at rest
210
+ server-side and the launch-time copy is cached at mode `0600`, the same
211
+ posture as your device token. Or,
212
+ - commit `{ "otel": { "endpoint": "http://<collector>:4317" } }` (optionally
213
+ with `protocol`, `headers`, `serviceName`) to the repo's `.pi/settings.json`
214
+ so one repo's sessions export without per-machine setup. Header values
215
+ must not contain commas (they travel as a comma-separated list); one that
216
+ does is dropped.
217
+
218
+ The rest of the standard OTel env is honored the way Claude Code honors it:
219
+ `OTEL_EXPORTER_OTLP_PROTOCOL` (and per-signal), `OTEL_EXPORTER_OTLP_HEADERS`
220
+ (and per-signal), `OTEL_SERVICE_NAME` (defaults to `yagni-code`),
221
+ `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_{METRICS,LOGS,TRACES}_EXPORTER=none` to
222
+ switch one signal off, `OTEL_METRIC_EXPORT_INTERVAL` /
223
+ `OTEL_LOGS_EXPORT_INTERVAL`, `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE`
224
+ (default `delta`), and the `OTEL_METRICS_INCLUDE_*` cardinality flags.
225
+ `YAGNI_OTEL_DISABLED=1` is the kill switch. `yagni doctor` shows the current
226
+ export state and sends one real record per signal, printing the collector's
227
+ own verdict (a 403 is a bad key, a 404 a bad path or site).
228
+
229
+ Three recipes for common collectors:
230
+
231
+ - **Datadog, direct SaaS (no agent):** endpoint `https://otlp.datadoghq.com`
232
+ (US1; other sites have their own host — `otlp.us3.datadoghq.com`,
233
+ `otlp.us5.datadoghq.com`, `otlp.datadoghq.eu`, `otlp.ap1.datadoghq.com`),
234
+ protocol `http/protobuf` (Datadog's direct intake does not accept gRPC),
235
+ header `dd-api-key=<your Datadog API key>`. Add `dd-otlp-source=llmobs`
236
+ (the workspace preset does) to have the traces routed into LLM
237
+ Observability as well as APM; the launcher scopes that header to the
238
+ traces request. Traces show in APM under service `yagni-code`, metrics
239
+ under `yagni_code.*`, events in Log Explorer as `service:yagni-code`.
217
240
  - **Datadog Agent:** endpoint `http://<agent-host>:4317`, protocol `grpc`, no
218
241
  key (the agent forwards with its own). OTLP ingest ships in the agent but
219
242
  is off by default — enable it with
220
243
  `otlp_config.receiver.protocols.grpc.endpoint: 0.0.0.0:4317` in
221
244
  `datadog.yaml`.
222
- - **Grafana Cloud:** endpoint `https://otlp-gateway-prod-…-0.grafana.net:443/otlp`,
223
- protocol `http/protobuf`, header `authorization=<the raw MTgx… value>` (the
224
- `Basic ` scheme is added for you).
245
+ - **Grafana Cloud:** endpoint `https://otlp-gateway-prod-<region>.grafana.net/otlp`
246
+ (the base URL from your stack's OTLP page — signals go to `/otlp/v1/traces`
247
+ and friends), protocol `http/protobuf`, header `authorization=<the raw
248
+ MTgx… value>` (the `Basic ` scheme is added for you).
225
249
 
226
250
  Two things are enforced and not configurable:
227
251
 
228
- - **Metadata only.** Token counts, cost, tier, finish reasons, and tool-call
229
- ids export; prompt and response text never do a settings file or env var
230
- asking for content capture is overridden.
231
- - **Cost is your contracted rate.** `pi.cost.usd` is computed from your
252
+ - **Metadata only.** Token counts, cost, tier, durations, tool names, and
253
+ payload sizes export; prompt text, model output, tool arguments, and tool
254
+ results never do `OTEL_LOG_USER_PROMPTS` and `OTEL_LOG_TOOL_DETAILS` are
255
+ deliberately not honored.
256
+ - **Cost is your contracted rate.** `cost_usd` is computed from your
232
257
  workspace's tier rate card, and the exported model name is the opaque tier
233
258
  id (`advanced`, `peak`, …), so traces never fingerprint the backing model.
234
259
 
package/dist/cli.js CHANGED
@@ -247,10 +247,10 @@ async function runDefault(passthroughArgs) {
247
247
  catch {
248
248
  compat = { argv: [], env: {} };
249
249
  }
250
- // OTel export: load pi-otel only when an OTLP endpoint is configured — the
251
- // user's env, the workspace's admin-set config (fetched fail-soft, cached),
252
- // or the repo's .pi/settings.json, in that order. undefined keeps the
253
- // launch untouched. See otel.ts for the policy.
250
+ // OTel export: on only when an OTLP endpoint is configured — the user's
251
+ // env, the workspace's admin-set config (fetched fail-soft, cached), or the
252
+ // repo's .pi/settings.json, in that order. undefined keeps the launch
253
+ // untouched. See otel.ts for the policy.
254
254
  const otel = await resolveOtelLaunchWithWorkspace({
255
255
  env: process.env,
256
256
  cwd: process.cwd(),
package/dist/doctor.d.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * Advisory checks (loose perms, missing `gh`) never flip the exit code.
14
14
  */
15
15
  import { type TokenExpiryStatus } from "./launch.js";
16
- import { probeOtelEndpoint, type OtelLaunchConfig } from "./otel.js";
16
+ import { type OtelLaunchConfig } from "./otel.js";
17
17
  import { type Profile } from "./profiles.js";
18
18
  export type CheckStatus = "ok" | "warn" | "fail";
19
19
  export interface CheckResult {
@@ -61,16 +61,25 @@ export declare function checkCliUpdate(probe: {
61
61
  latest: string | null;
62
62
  }): CheckResult;
63
63
  export declare function checkGh(onPath: boolean): CheckResult;
64
+ /** One signal's verdict from the extension's probe (see telemetry/probe.ts). */
65
+ export interface OtelProbeSignal {
66
+ signal: "traces" | "metrics" | "logs";
67
+ url: string;
68
+ protocol: string;
69
+ ok: boolean;
70
+ error?: string;
71
+ }
64
72
  /**
65
- * Advisory OTel-export line: says whether sessions will stream traces to an
66
- * OTLP collector, from which config source, and — when a collector is
67
- * configured — whether the endpoint answers TCP right now (scheme-default
68
- * ports included: https with no port probes 443). Never flips the exit code —
69
- * most machines have no collector, and that is the healthy default.
73
+ * Advisory OTel-export line: says whether sessions will stream telemetry to
74
+ * an OTLP collector, from which config source, and — when a collector is
75
+ * configured — whether it ACCEPTED a real record on each signal (a wrong API
76
+ * key, a wrong path, or a protocol mismatch shows up as the collector's own
77
+ * error). Never flips the exit code — most machines have no collector, and
78
+ * that is the healthy default.
70
79
  */
71
80
  export declare function checkOtelExport(config: OtelLaunchConfig | undefined, deps?: {
72
- probeEndpoint?: typeof probeOtelEndpoint;
73
- probeTimeoutMs?: number;
81
+ probe?: (env: NodeJS.ProcessEnv) => Promise<OtelProbeSignal[]>;
82
+ baseEnv?: NodeJS.ProcessEnv;
74
83
  }): Promise<CheckResult>;
75
84
  /** Config-only MCP snapshot (no connections — health checks live in `mcp list`/`get`). */
76
85
  export interface McpProbe {
@@ -120,6 +129,10 @@ export interface DoctorDeps {
120
129
  probeLatestVersion?: () => Promise<string | null>;
121
130
  /** MCP config snapshot (config-only, no connections). */
122
131
  probeMcp?: () => Promise<McpProbe>;
132
+ /** OTel gate resolution (env / workspace / repo settings); tests stub it. */
133
+ resolveOtel?: (profile: Profile) => Promise<OtelLaunchConfig | undefined>;
134
+ /** OTel export probe: one real record per signal against the session env. */
135
+ probeOtel?: (env: NodeJS.ProcessEnv) => Promise<OtelProbeSignal[]>;
123
136
  log?: (msg: string) => void;
124
137
  }
125
138
  /** Whether a `gh` executable is resolvable on PATH (no subprocess spawn). */
package/dist/doctor.js CHANGED
@@ -17,8 +17,8 @@ import { delimiter, join } from "node:path";
17
17
  import { credentialsDir } from "./credentials.js";
18
18
  import { currentCliVersion, fetchLatestVersion, isNewerVersion } from "./upgrade.js";
19
19
  import { classifyTokenExpiry } from "./launch.js";
20
- import { probeOtelEndpoint, resolveOtelLaunchWithWorkspace, } from "./otel.js";
21
- import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir } from "./paths.js";
20
+ import { otelChildEnv, resolveOtelLaunchWithWorkspace } from "./otel.js";
21
+ import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir, resolveTelemetryProbePath } from "./paths.js";
22
22
  import { readActiveProfile } from "./profiles.js";
23
23
  import { resolveMcpConfigPath } from "./mcpCommand.js";
24
24
  // ── Pure check builders ─────────────────────────────────────────────────────
@@ -201,11 +201,22 @@ export function checkGh(onPath) {
201
201
  };
202
202
  }
203
203
  /**
204
- * Advisory OTel-export line: says whether sessions will stream traces to an
205
- * OTLP collector, from which config source, and when a collector is
206
- * configured whether the endpoint answers TCP right now (scheme-default
207
- * ports included: https with no port probes 443). Never flips the exit code
208
- * most machines have no collector, and that is the healthy default.
204
+ * Run the extension's telemetry probe against the exact env a session would
205
+ * get: one real span, one metric point, one log record, each reported with
206
+ * the collector's actual verdict. Dynamic import so `doctor` on a machine
207
+ * with no collector never loads the OTel SDK graph.
208
+ */
209
+ async function defaultProbeOtel(env) {
210
+ const mod = (await import(resolveTelemetryProbePath()));
211
+ return (await mod.probeTelemetryExport(env)).results;
212
+ }
213
+ /**
214
+ * Advisory OTel-export line: says whether sessions will stream telemetry to
215
+ * an OTLP collector, from which config source, and — when a collector is
216
+ * configured — whether it ACCEPTED a real record on each signal (a wrong API
217
+ * key, a wrong path, or a protocol mismatch shows up as the collector's own
218
+ * error). Never flips the exit code — most machines have no collector, and
219
+ * that is the healthy default.
209
220
  */
210
221
  export async function checkOtelExport(config, deps = {}) {
211
222
  if (!config) {
@@ -222,23 +233,32 @@ export async function checkOtelExport(config, deps = {}) {
222
233
  ? "workspace settings"
223
234
  : ".pi/settings.json";
224
235
  const base = `on → ${config.endpoint} (${source}, metadata-only)`;
225
- const doProbe = deps.probeEndpoint ?? probeOtelEndpoint;
236
+ const baseEnv = deps.baseEnv ?? process.env;
237
+ const env = { ...baseEnv, ...otelChildEnv(config, baseEnv) };
238
+ const doProbe = deps.probe ?? defaultProbeOtel;
226
239
  try {
227
- const reachable = await doProbe(config.endpoint, deps.probeTimeoutMs ?? 1_000);
228
- return reachable
229
- ? {
240
+ const results = await doProbe(env);
241
+ const accepted = results.filter((r) => r.ok).map((r) => r.signal);
242
+ const failed = results.filter((r) => !r.ok);
243
+ const summary = results.map((r) => `${r.signal} ${r.ok ? "accepted" : "FAILED"}`).join(", ");
244
+ if (failed.length === 0) {
245
+ return {
230
246
  name: "otel export (optional)",
231
247
  status: "ok",
232
- detail: `${base}, TCP reachable`,
233
- required: false,
234
- }
235
- : {
236
- name: "otel export (optional)",
237
- status: "warn",
238
- detail: `${base}, endpoint not reachable`,
239
- hint: "the collector is down, unreachable from this machine, or the URL is wrong — traces will not export until it answers",
248
+ detail: `${base}; ${summary}`,
240
249
  required: false,
241
250
  };
251
+ }
252
+ const reasons = failed.map((r) => `${r.signal} → ${r.url} (${r.protocol}): ${r.error ?? "rejected"}`).join("; ");
253
+ return {
254
+ name: "otel export (optional)",
255
+ status: "warn",
256
+ detail: `${base}; ${summary}`,
257
+ hint: `${reasons}. A 401/403 means the collector key is wrong, a 404 the path or site is wrong, ` +
258
+ `a connection error the collector is unreachable from this machine` +
259
+ (accepted.length > 0 ? `; ${accepted.join(" and ")} still export` : ""),
260
+ required: false,
261
+ };
242
262
  }
243
263
  catch {
244
264
  // The probe itself errored (not "endpoint down" — the check could not
@@ -247,8 +267,8 @@ export async function checkOtelExport(config, deps = {}) {
247
267
  return {
248
268
  name: "otel export (optional)",
249
269
  status: "warn",
250
- detail: `${base}, reachability probe failed`,
251
- hint: "the reachability check could not run (network error) — export is configured but unverified; traces may still export normally",
270
+ detail: `${base}, export probe failed`,
271
+ hint: "the export probe could not run (the bundled extension is missing or the OTel SDK failed to load) — export is configured but unverified; sessions may still export normally",
252
272
  required: false,
253
273
  };
254
274
  }
@@ -516,12 +536,17 @@ export async function gatherChecks(deps = {}) {
516
536
  checks.push(checkStateDir(probeStateDir()));
517
537
  checks.push(checkGh(ghOnPath()));
518
538
  checks.push(checkMcpConfig(await probeMcp()));
519
- checks.push(await checkOtelExport(await resolveOtelLaunchWithWorkspace({
520
- env: process.env,
521
- cwd: process.cwd(),
522
- creds: profile.token ? { baseUrl: profile.baseUrl, token: profile.token } : null,
523
- profileName: profile.name,
524
- })));
539
+ const resolveOtel = deps.resolveOtel ??
540
+ ((p) => resolveOtelLaunchWithWorkspace({
541
+ env: process.env,
542
+ cwd: process.cwd(),
543
+ creds: p.token ? { baseUrl: p.baseUrl, token: p.token } : null,
544
+ profileName: p.name,
545
+ }));
546
+ checks.push(await checkOtelExport(await resolveOtel(profile), {
547
+ ...(deps.probeOtel ? { probe: deps.probeOtel } : {}),
548
+ baseEnv: process.env,
549
+ }));
525
550
  return checks;
526
551
  }
527
552
  /**
@@ -24,7 +24,7 @@
24
24
  * pattern) so tests run against plain text. Renderer exceptions are swallowed
25
25
  * by pi (degrading to the built-in fallback), so everything stays total.
26
26
  */
27
- import { SettingsManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
+ import { SettingsManager, type ExtensionAPI, type ToolDefinition } from "@earendil-works/pi-coding-agent";
28
28
  import type { RenderTheme } from "./subagentRender.js";
29
29
  import { ToolRunTracker } from "./toolRuns.js";
30
30
  /** Lines of write content shown collapsed (mirrors Claude Code's preview). */
@@ -87,6 +87,17 @@ export interface RegisterCondensedToolsDeps {
87
87
  * image autoResize. Injectable so tests never touch the config dir.
88
88
  */
89
89
  loadSettings?: (cwd: string) => SettingsManager;
90
+ /**
91
+ * sandbox composition: when present, the bash definition is passed
92
+ * through this wrap before registration — the sandbox adds its schema field
93
+ * (dangerouslyDisableSandbox), wrap-on-execute, and violation annotation
94
+ * while condensed keeps its rendering. The compose fn is built by
95
+ * sandbox/session.ts (makeSandboxBashComposition) and is a no-op when the
96
+ * sandbox is disabled. Condensed registers LAST among the bash-defining
97
+ * sites, so composing HERE is what makes the sandbox actually reach the
98
+ * model's tool calls.
99
+ */
100
+ wrapBash?: (def: ToolDefinition) => ToolDefinition;
90
101
  }
91
102
  /**
92
103
  * Re-register the seven built-ins with condensed renderers. Returns the
@@ -291,15 +291,23 @@ export function registerCondensedTools(pi, deps = {}) {
291
291
  catch {
292
292
  toolOptions = { bash: {}, read: {} };
293
293
  }
294
- const buildDefinitions = (cwd) => ({
295
- read: createReadToolDefinition(cwd, toolOptions.read),
296
- bash: createBashToolDefinition(cwd, toolOptions.bash),
297
- edit: createEditToolDefinition(cwd),
298
- write: createWriteToolDefinition(cwd),
299
- grep: createGrepToolDefinition(cwd),
300
- find: createFindToolDefinition(cwd),
301
- ls: createLsToolDefinition(cwd),
302
- });
294
+ const buildDefinitions = (cwd) => {
295
+ const defs = {
296
+ read: createReadToolDefinition(cwd, toolOptions.read),
297
+ bash: createBashToolDefinition(cwd, toolOptions.bash),
298
+ edit: createEditToolDefinition(cwd),
299
+ write: createWriteToolDefinition(cwd),
300
+ grep: createGrepToolDefinition(cwd),
301
+ find: createFindToolDefinition(cwd),
302
+ ls: createLsToolDefinition(cwd),
303
+ };
304
+ // the sandbox wraps the bash DEFINITION (not the registration
305
+ // wrapper) so the cwd-delegating execute below still lands on the
306
+ // sandbox-aware tool for every cwd the session visits.
307
+ if (defs.bash && deps.wrapBash)
308
+ defs.bash = deps.wrapBash(defs.bash);
309
+ return defs;
310
+ };
303
311
  const definitionsByCwd = new Map();
304
312
  const definitionsFor = (cwd) => {
305
313
  let defs = definitionsByCwd.get(cwd);
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { type SpendResponse } from "./costHud.js";
3
3
  import { runInitPass as defaultRunInitPass } from "./initPass.js";
4
4
  import { startMcp as defaultStartMcp } from "./mcp/startup.js";
5
+ import { type GuardianGateEvent } from "./permission/gate.js";
5
6
  import { type FlushOutcome, type SpoolClientOpts } from "./spool.js";
6
7
  import { type TokenProvider } from "./tokenProvider.js";
7
8
  import { type CatalogResult, type ContextBrief } from "./config.js";
@@ -115,6 +116,15 @@ export declare function buildRunSpendUrl(baseUrl: string, runId: string): string
115
116
  * Returns null on anything that doesn't look like the real shape.
116
117
  */
117
118
  export declare function parseSpendResponse(data: unknown): SpendResponse | null;
119
+ /**
120
+ * Local trail for EVERY terminal gate outcome (sandbox auto-allow included),
121
+ * always on so e2e and support can see what the gate did even when the
122
+ * storage tier is off. Decision K: the always-on line carries outcome
123
+ * metadata ONLY — no command content. The command prefix and the exec
124
+ * justification are command/model content and ride the debug tier (never
125
+ * uploaded by /feedback) plus the opt-in storage stream.
126
+ */
127
+ export declare function logGateOutcomeTrail(ev: GuardianGateEvent, env: NodeJS.ProcessEnv): void;
118
128
  export declare function registerYagni(pi: ExtensionAPI, deps?: RegisterYagniDeps): Promise<void>;
119
129
  export default function (pi: ExtensionAPI): Promise<void>;
120
130
  export { makeAskYagniTool } from "./askYagniTool.js";
@@ -36,6 +36,9 @@ import { startMcp as defaultStartMcp, wireShutdown, deriveStartupConnectivityNot
36
36
  import { registerGoCommand } from "./pipeline/goCommand.js";
37
37
  import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
38
38
  import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission/gate.js";
39
+ import { registerSandbox } from "./sandbox/session.js";
40
+ import { sandboxAutoAllowDecision } from "./sandbox/bash.js";
41
+ import { registerTelemetry } from "./telemetry/register.js";
39
42
  import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
40
43
  import { loadPermissionRules } from "./permissionRules/loadConfig.js";
41
44
  import { registerSubagents } from "./subagents.js";
@@ -102,6 +105,39 @@ export function parseSpendResponse(data) {
102
105
  return null;
103
106
  return d;
104
107
  }
108
+ /**
109
+ * Local trail for EVERY terminal gate outcome (sandbox auto-allow included),
110
+ * always on so e2e and support can see what the gate did even when the
111
+ * storage tier is off. Decision K: the always-on line carries outcome
112
+ * metadata ONLY — no command content. The command prefix and the exec
113
+ * justification are command/model content and ride the debug tier (never
114
+ * uploaded by /feedback) plus the opt-in storage stream.
115
+ */
116
+ export function logGateOutcomeTrail(ev, env) {
117
+ logEvent({
118
+ source: "guardian",
119
+ level: "info",
120
+ event: "gate_outcome",
121
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
122
+ fields: {
123
+ outcome: ev.outcome,
124
+ mode: ev.mode,
125
+ consulted: ev.consulted,
126
+ },
127
+ });
128
+ if (isDebug(env)) {
129
+ logEvent({
130
+ source: "guardian",
131
+ level: "debug",
132
+ event: "gate_outcome",
133
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
134
+ fields: {
135
+ commandPrefix: storagePrefix(ev.command),
136
+ ...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
137
+ },
138
+ });
139
+ }
140
+ }
105
141
  export async function registerYagni(pi, deps = {}) {
106
142
  const baseUrl = deps.baseUrl ?? resolveBaseUrl();
107
143
  const now = deps.now ?? (() => Date.now());
@@ -379,6 +415,18 @@ export async function registerYagni(pi, deps = {}) {
379
415
  // Mutating MCP tools join write/edit/bash in the gate policy: plan mode
380
416
  // holds them, review mode confirms them.
381
417
  const modeHolder = createModeHolder();
418
+ // OTel export (traces, metrics, log events; Claude Code parity). A no-op
419
+ // unless the launcher's gate set YAGNI_OTEL_EXPORT=1 — see the CLI's
420
+ // otel.ts for the sources and src/telemetry for the exporter.
421
+ const telemetry = registerTelemetry(pi, { env });
422
+ {
423
+ let previousMode = modeHolder.get();
424
+ modeHolder.onSet((m) => {
425
+ if (m !== previousMode)
426
+ telemetry.permissionModeChanged(previousMode, m);
427
+ previousMode = m;
428
+ });
429
+ }
382
430
  const footerInvalidateHandle = { invalidateGit: () => { }, requestRender: () => { } };
383
431
  const guardianState = makeGuardianState();
384
432
  // Disabled by the local env override OR the workspace kill switch
@@ -454,11 +502,35 @@ export async function registerYagni(pi, deps = {}) {
454
502
  const hookRunner = evalMode ? null : makeHookRunner({ config: hooksConfig });
455
503
  if (!evalMode)
456
504
  registerHooks(pi, { config: hooksConfig });
505
+ // OS-level bash sandboxing. Registers the sandbox-aware bash tool
506
+ // (dangerouslyDisableSandbox param + wrap + violation annotation) and the
507
+ // session lifecycle (init/reset, fail-soft unless failIfUnavailable).
508
+ // Null on unsupported platforms — stock bash stays. Shares the loaded
509
+ // permission rules so sandbox config and the gate read one rule source.
510
+ const sandboxHandle = evalMode
511
+ ? null
512
+ : registerSandbox(pi, {
513
+ cwd: process.cwd(),
514
+ env,
515
+ hasUI: (ctx) => ctx.hasUI,
516
+ // Anchors project-protected paths (.yagni-code + its config.json in
517
+ // denyWrite) and project-sourced permission rules to the repo the
518
+ // session runs in — same root the gate uses for its rule anchoring.
519
+ projectRoot: process.cwd(),
520
+ });
521
+ if (sandboxHandle)
522
+ sandboxHandle.setRules(loadedRules.rules);
457
523
  registerPermissionGate(pi, {
524
+ ...(sandboxHandle
525
+ ? {
526
+ sandboxAutoAllow: (toolName, params) => sandboxAutoAllowDecision(toolName, params, sandboxHandle.manager, sandboxHandle.settings()),
527
+ }
528
+ : {}),
458
529
  modeHolder,
459
530
  guardianState,
460
531
  guardianLimits,
461
532
  guardianTier,
533
+ onToolDecision: (ev) => telemetry.toolDecision(ev),
462
534
  ...(loadedRules.rules.length > 0
463
535
  ? {
464
536
  permissionRules: loadedRules.rules,
@@ -576,51 +648,50 @@ export async function registerYagni(pi, deps = {}) {
576
648
  // rationale. Fire-and-forget: one attempt, short timeout, failures logged
577
649
  // fail-soft to the unified sink (source:"guardian") — a storage outage
578
650
  // never touches the session.
579
- onGuardianEvent: guardianStorageTier === "off" || evalMode
580
- ? undefined
581
- : (ev) => {
582
- void (async () => {
583
- try {
584
- const body = {
585
- sessionId: env.YAGNI_SESSION_ID ?? null,
586
- commandHash: createHash("sha256").update(ev.command).digest("hex"),
587
- commandPrefix: storagePrefix(ev.command),
588
- outcome: ev.outcome,
589
- mode: ev.mode,
590
- ...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
591
- ...(ev.riskLevel ? { riskLevel: ev.riskLevel } : {}),
592
- ...(ev.tier ? { tier: ev.tier } : {}),
593
- ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
594
- };
595
- if (guardianStorageTier === "raw") {
596
- body.command = redactCommand(ev.command);
597
- if (ev.rationale)
598
- body.rationale = redactCommand(ev.rationale);
599
- }
600
- const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-events`, {
601
- method: "POST",
602
- headers: {
603
- "content-type": "application/json",
604
- authorization: `Bearer ${getTokenFn() ?? ""}`,
605
- ...attributionHeaders(deps.env),
606
- },
607
- body: JSON.stringify(body),
608
- }, {
609
- fetchImpl: authedFetch,
610
- policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
611
- });
612
- if (!res.ok) {
613
- guardianLogSink({ event: "guardian_event_post_failed", status: res.status });
614
- }
651
+ onGuardianEvent: (ev) => {
652
+ logGateOutcomeTrail(ev, env);
653
+ // Opt-in storage stream (YAG-510): tier decides what leaves the machine.
654
+ if (guardianStorageTier === "off" || evalMode)
655
+ return;
656
+ void (async () => {
657
+ try {
658
+ const body = {
659
+ sessionId: env.YAGNI_SESSION_ID ?? null,
660
+ commandHash: createHash("sha256").update(ev.command).digest("hex"),
661
+ commandPrefix: storagePrefix(ev.command),
662
+ outcome: ev.outcome,
663
+ mode: ev.mode,
664
+ ...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
665
+ ...(ev.riskLevel ? { riskLevel: ev.riskLevel } : {}),
666
+ ...(ev.tier ? { tier: ev.tier } : {}),
667
+ ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
668
+ };
669
+ if (guardianStorageTier === "raw") {
670
+ body.command = redactCommand(ev.command);
671
+ if (ev.rationale)
672
+ body.rationale = redactCommand(ev.rationale);
615
673
  }
616
- catch {
617
- guardianLogSink({
618
- event: "guardian_event_post_failed",
619
- kind: "network",
620
- });
674
+ const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-events`, {
675
+ method: "POST",
676
+ headers: {
677
+ "content-type": "application/json",
678
+ authorization: `Bearer ${getTokenFn() ?? ""}`,
679
+ ...attributionHeaders(deps.env),
680
+ },
681
+ body: JSON.stringify(body),
682
+ }, {
683
+ fetchImpl: authedFetch,
684
+ policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
685
+ });
686
+ if (!res.ok) {
687
+ guardianLogSink({ event: "guardian_event_post_failed", status: res.status });
621
688
  }
622
- })();
623
- },
689
+ }
690
+ catch {
691
+ guardianLogSink({ event: "guardian_event_post_failed", kind: "network" });
692
+ }
693
+ })();
694
+ },
624
695
  ...(mcpMutatingSet.size > 0 || mcpOutcome.manager
625
696
  ? {
626
697
  policy: {
@@ -852,12 +923,25 @@ export async function registerYagni(pi, deps = {}) {
852
923
  try {
853
924
  registerCondensedTools(pi, {
854
925
  ...(scratchpadDirPath ? { scratchpadDir: scratchpadDirPath } : {}),
926
+ // condensed owns the FINAL bash registration (it re-registers
927
+ // built-ins last), so the sandbox composes INTO it here. Identity when
928
+ // the sandbox is off.
929
+ ...(sandboxHandle ? { wrapBash: sandboxHandle.composeBash } : {}),
855
930
  });
856
931
  }
857
932
  catch {
858
933
  // Rendering must never break activation; pi's built-ins remain.
859
934
  }
860
935
  }
936
+ else if (sandboxHandle && env.YAGNI_CLASSIC_TOOL_ROWS !== "1") {
937
+ // Condensed inactive (eval mode / desktop): the sandbox's own
938
+ // registration is the one that reaches the tool registry. NOT under the
939
+ // classic-rows escape hatch — classic bash must stay pi's stock
940
+ // registration (the hatch's contract); a competing one would replace
941
+ // classic rendering with the sandbox's wrap. Desktop/eval still need
942
+ // the sandbox to reach bash, so the hatch is the one carve-out.
943
+ sandboxHandle.registerOwnBash();
944
+ }
861
945
  // Own the identity + inject live company context (and repo rules) on every
862
946
  // turn. The extension loads identically in every pi process this app spawns —
863
947
  // the interactive driver AND every `/go` stage child, subagent, and advisor