@yagni-app/code-staging 1.0.0-staging.1180.1 → 1.0.0-staging.1184.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.
package/README.md CHANGED
@@ -171,6 +171,43 @@ Credentials live in `~/.yagni-code/profiles/<name>.json` (mode `0600`); the acti
171
171
  environment is recorded in `~/.yagni-code/config.json`. A pre-profiles
172
172
  `~/.yagni-code/credentials.json` is migrated automatically on first run.
173
173
 
174
+ ### OTel export (opt-in)
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 three ways (first match wins):
183
+
184
+ - `OTEL_EXPORTER_OTLP_ENDPOINT=http://<collector>:4317` in the environment
185
+ (a personal override — handy for pointing one session at a scratch
186
+ collector), or
187
+ - **workspace settings** (the zero-setup path): a workspace admin sets the
188
+ endpoint, protocol, and any collector headers (e.g. a Datadog API key) once
189
+ in the web app under Settings → YAGNI Code → Trace export. Every session in
190
+ the workspace picks it up at launch — nothing to install or configure on
191
+ developer machines. Header values are encrypted at rest server-side and the
192
+ launch-time copy is cached at mode `0600`, the same posture as your device
193
+ token. Or,
194
+ - commit `{ "otel": { "endpoint": "http://<collector>:4317" } }` to the repo's
195
+ `.pi/settings.json` so one repo's sessions export without per-machine setup.
196
+
197
+ Standard OTel env vars are honored (`OTEL_EXPORTER_OTLP_PROTOCOL`,
198
+ `OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_SERVICE_NAME` — defaults to `yagni-code`),
199
+ and `PI_OTEL_DISABLED=1` is the kill switch. `yagni doctor` shows the current
200
+ export state.
201
+
202
+ Two things are enforced and not configurable:
203
+
204
+ - **Metadata only.** Token counts, cost, tier, finish reasons, and tool-call
205
+ ids export; prompt and response text never do — a settings file or env var
206
+ asking for content capture is overridden.
207
+ - **Cost is your contracted rate.** `pi.cost.usd` is computed from your
208
+ workspace's tier rate card, and the exported model name is the opaque tier
209
+ id (`advanced`, `peak`, …), so traces never fingerprint the backing model.
210
+
174
211
  Device tokens are revocable from both ends: `yagni logout` revokes the current
175
212
  one, and a workspace admin can list every connected device and revoke any token
176
213
  from the web app (Settings, YAGNI Code, Connected devices). Tokens are stored
@@ -196,6 +233,11 @@ precisely:
196
233
  third-party crash service, so we can fix the crash before you have to report
197
234
  it. Disable with `YAGNI_DISABLE_CRASH_REPORTS=1`.
198
235
 
236
+ A third flow exists only when you turn it on: **OTel export** (above) sends
237
+ session *metadata* — never prompt or response text — to a collector **you**
238
+ configure and operate. It is off unless an OTLP endpoint is set, and YAGNI
239
+ never receives these traces.
240
+
199
241
  ## Troubleshooting
200
242
 
201
243
  - **`Not logged in to environment "<name>" … Run \`yagni login\` first.`** — no
package/dist/cli.js CHANGED
@@ -27,6 +27,7 @@ import { login } from "./login.js";
27
27
  import { logout } from "./logout.js";
28
28
  import { tokenCommand } from "./token.js";
29
29
  import { buildLaunch } from "./launch.js";
30
+ import { resolveOtelLaunchWithWorkspace } from "./otel.js";
30
31
  import { parseOutputFormat, parseJsonEvents, buildResultObject, readGuardianEvents, } from "./outputFormat.js";
31
32
  import { feedbackCommand } from "./feedback.js";
32
33
  import { runDoctor } from "./doctor.js";
@@ -245,6 +246,16 @@ async function runDefault(passthroughArgs) {
245
246
  catch {
246
247
  compat = { argv: [], env: {} };
247
248
  }
249
+ // OTel export: load pi-otel only when an OTLP endpoint is configured — the
250
+ // user's env, the workspace's admin-set config (fetched fail-soft, cached),
251
+ // or the repo's .pi/settings.json, in that order. undefined keeps the
252
+ // launch untouched. See otel.ts for the policy.
253
+ const otel = await resolveOtelLaunchWithWorkspace({
254
+ env: process.env,
255
+ cwd: process.cwd(),
256
+ creds,
257
+ profileName: profile.name,
258
+ });
248
259
  // buildLaunch runs the token-expiry preflight: it throws (with an actionable
249
260
  // login prompt) on an already-expired token so we never spawn a session that
250
261
  // immediately 401s, and returns non-fatal warnings (e.g. expiry approaching).
@@ -265,6 +276,7 @@ async function runDefault(passthroughArgs) {
265
276
  cliVersion: cliVersion(),
266
277
  baseEnv: process.env,
267
278
  ...(seededHideThinking ? { hideThinkingSeeded: true } : {}),
279
+ ...(otel ? { otel } : {}),
268
280
  });
269
281
  }
270
282
  catch (err) {
@@ -435,6 +447,14 @@ async function runWorktreeLaunch(passthroughArgs, worktreeName, loadSessionWorkt
435
447
  catch {
436
448
  compat = { argv: [], env: {} };
437
449
  }
450
+ // OTel gate reads the WORKTREE's .pi/settings.json — that is the session's
451
+ // cwd, so a repo-committed otel config applies to its worktrees too.
452
+ const otel = await resolveOtelLaunchWithWorkspace({
453
+ env: process.env,
454
+ cwd: result.worktreePath,
455
+ creds,
456
+ profileName: profile.name,
457
+ });
438
458
  let plan;
439
459
  try {
440
460
  plan = buildLaunch(creds, remainingArgs, {
@@ -447,6 +467,7 @@ async function runWorktreeLaunch(passthroughArgs, worktreeName, loadSessionWorkt
447
467
  stateDir: credentialsDir(),
448
468
  cliVersion: cliVersion(),
449
469
  baseEnv: process.env,
470
+ ...(otel ? { otel } : {}),
450
471
  });
451
472
  }
452
473
  catch (err) {
package/dist/doctor.d.ts CHANGED
@@ -13,6 +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 { type OtelLaunchConfig } from "./otel.js";
16
17
  import { type Profile } from "./profiles.js";
17
18
  export type CheckStatus = "ok" | "warn" | "fail";
18
19
  export interface CheckResult {
@@ -60,6 +61,12 @@ export declare function checkCliUpdate(probe: {
60
61
  latest: string | null;
61
62
  }): CheckResult;
62
63
  export declare function checkGh(onPath: boolean): CheckResult;
64
+ /**
65
+ * Advisory OTel-export line: says whether sessions will stream traces to an
66
+ * OTLP collector, and from which config source. Never flips the exit code —
67
+ * most machines have no collector, and that is the healthy default.
68
+ */
69
+ export declare function checkOtelExport(config: OtelLaunchConfig | undefined): CheckResult;
63
70
  /** What the Windows bash probe found (pi needs a bash — Git Bash — on win32). */
64
71
  export interface BashProbe {
65
72
  found: boolean;
package/dist/doctor.js CHANGED
@@ -17,6 +17,7 @@ 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 { resolveOtelLaunchWithWorkspace } from "./otel.js";
20
21
  import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir } from "./paths.js";
21
22
  import { readActiveProfile } from "./profiles.js";
22
23
  // ── Pure check builders ─────────────────────────────────────────────────────
@@ -198,6 +199,32 @@ export function checkGh(onPath) {
198
199
  required: false,
199
200
  };
200
201
  }
202
+ /**
203
+ * Advisory OTel-export line: says whether sessions will stream traces to an
204
+ * OTLP collector, and from which config source. Never flips the exit code —
205
+ * most machines have no collector, and that is the healthy default.
206
+ */
207
+ export function checkOtelExport(config) {
208
+ if (!config) {
209
+ return {
210
+ name: "otel export (optional)",
211
+ status: "ok",
212
+ detail: "off (no OTLP endpoint configured)",
213
+ required: false,
214
+ };
215
+ }
216
+ const source = config.source === "env"
217
+ ? "OTEL_EXPORTER_OTLP_ENDPOINT"
218
+ : config.source === "workspace"
219
+ ? "workspace settings"
220
+ : ".pi/settings.json";
221
+ return {
222
+ name: "otel export (optional)",
223
+ status: "ok",
224
+ detail: `on → ${config.endpoint} (${source}, metadata-only)`,
225
+ required: false,
226
+ };
227
+ }
201
228
  export function checkBash(probe) {
202
229
  if (!probe.found) {
203
230
  return {
@@ -379,6 +406,12 @@ export async function gatherChecks(deps = {}) {
379
406
  checks.push(checkBackend(backend));
380
407
  checks.push(checkStateDir(probeStateDir()));
381
408
  checks.push(checkGh(ghOnPath()));
409
+ checks.push(checkOtelExport(await resolveOtelLaunchWithWorkspace({
410
+ env: process.env,
411
+ cwd: process.cwd(),
412
+ creds: profile.token ? { baseUrl: profile.baseUrl, token: profile.token } : null,
413
+ profileName: profile.name,
414
+ })));
382
415
  return checks;
383
416
  }
384
417
  /**
@@ -109,7 +109,28 @@ export declare class ChipEditor extends CustomEditor {
109
109
  /** Drop all stashed images. Called after a successful submit so a sent image
110
110
  * is not re-attached to the next message. */
111
111
  clearStash(): void;
112
- /** Re-style the chip tokens so they read as chips; layout is untouched. */
112
+ /**
113
+ * Re-style chip tokens AND lay down the Kimi-style prompt box: a rounded
114
+ * border on all four sides, with a bold `›` caret on the first content
115
+ * line and continuation lines indented so wrapped text aligns under it.
116
+ *
117
+ * The base editor renders full-width lines carrying its own 1-column left
118
+ * padding, so we render it narrower, strip that padding, and re-wrap each
119
+ * line in the frame. Exact column layout (0-indexed):
120
+ *
121
+ * 0 ╭ │ ╰ border
122
+ * 1 space
123
+ * 2 › (first line) / space (continuation)
124
+ * 3 space
125
+ * 4… text — same column on every line
126
+ * width-2 space
127
+ * width-1 │ border
128
+ *
129
+ * Box-drawing glyphs are drawn centered in their cell while text glyphs
130
+ * start at the cell's left bearing, so no whole-column position lands the
131
+ * border ink exactly on the footer's text column: column 0 reads a hair
132
+ * outside it, column 1 a hair inside. Column 0 is the accepted tradeoff.
133
+ */
113
134
  render(width: number): string[];
114
135
  }
115
136
  /**
@@ -22,7 +22,7 @@
22
22
  * the extension's pi imports to the same module instance pi uses.
23
23
  */
24
24
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
25
- import { matchesKey } from "@earendil-works/pi-tui";
25
+ import { matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
26
26
  import { spawnSync } from "node:child_process";
27
27
  import { readFileSync, unlinkSync, existsSync } from "node:fs";
28
28
  import { tmpdir } from "node:os";
@@ -381,11 +381,64 @@ export class ChipEditor extends CustomEditor {
381
381
  clearStash() {
382
382
  this.stashed = [];
383
383
  }
384
- /** Re-style the chip tokens so they read as chips; layout is untouched. */
384
+ /**
385
+ * Re-style chip tokens AND lay down the Kimi-style prompt box: a rounded
386
+ * border on all four sides, with a bold `›` caret on the first content
387
+ * line and continuation lines indented so wrapped text aligns under it.
388
+ *
389
+ * The base editor renders full-width lines carrying its own 1-column left
390
+ * padding, so we render it narrower, strip that padding, and re-wrap each
391
+ * line in the frame. Exact column layout (0-indexed):
392
+ *
393
+ * 0 ╭ │ ╰ border
394
+ * 1 space
395
+ * 2 › (first line) / space (continuation)
396
+ * 3 space
397
+ * 4… text — same column on every line
398
+ * width-2 space
399
+ * width-1 │ border
400
+ *
401
+ * Box-drawing glyphs are drawn centered in their cell while text glyphs
402
+ * start at the cell's left bearing, so no whole-column position lands the
403
+ * border ink exactly on the footer's text column: column 0 reads a hair
404
+ * outside it, column 1 a hair inside. Column 0 is the accepted tradeoff.
405
+ */
385
406
  render(width) {
386
- return super
387
- .render(width)
388
- .map((line) => line.replace(CHIP_RE, (m) => `${CHIP_ON}${m}${CHIP_OFF}`));
407
+ const styled = super.render(Math.max(1, width - 4)).map((line) => line.replace(CHIP_RE, (m) => `${CHIP_ON}${m}${CHIP_OFF}`));
408
+ const border = (s) => this.borderColor(s);
409
+ const CARET = "\u001b[1m›\u001b[22m";
410
+ const FIRST_PREFIX = `${border("│")} ${CARET} `;
411
+ const NEXT_PREFIX = `${border("│")} `;
412
+ const SUFFIX = ` ${border("│")}`;
413
+ const contentWidth = Math.max(1, width - 6);
414
+ const out = [];
415
+ let borderCount = 0;
416
+ let firstContentLine = true;
417
+ for (const line of styled) {
418
+ const stripped = stripTerminalSequences(line);
419
+ if (/^─+$/.test(stripped) || /^─*\s*[↑↓]/.test(stripped)) {
420
+ borderCount += 1;
421
+ const [left, right] = borderCount === 1 ? ["╭", "╮"] : ["╰", "╯"];
422
+ // One corner + one dash on each side lands the row at `width`.
423
+ out.push(`${border(`${left}─`)}${line}${border(`─${right}`)}`);
424
+ continue;
425
+ }
426
+ // Strip the base editor's own left padding (the frame replaces it),
427
+ // then fit the body to the content width exactly so no line can
428
+ // exceed the terminal width.
429
+ let body = line.startsWith(" ") ? line.slice(1) : line;
430
+ if (visibleWidth(body) > contentWidth)
431
+ body = truncateToWidth(body, contentWidth, "");
432
+ const pad = " ".repeat(Math.max(0, contentWidth - visibleWidth(body)));
433
+ if (borderCount !== 1) {
434
+ // Autocomplete rows (after the bottom border): align under the text.
435
+ out.push(`${" ".repeat(4)}${body}${pad}${" ".repeat(2)}`);
436
+ continue;
437
+ }
438
+ out.push(`${firstContentLine ? FIRST_PREFIX : NEXT_PREFIX}${body}${pad}${SUFFIX}`);
439
+ firstContentLine = false;
440
+ }
441
+ return out;
389
442
  }
390
443
  }
391
444
  /**
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Committed branch diff stat for the footer's git badge.
3
+ *
4
+ * Counting follows Codex (`tui/src/branch_summary.rs`), not Kimi: it sums the
5
+ * line delta between `HEAD` and the merge base with the repository's *default*
6
+ * branch, so the badge answers "what does this branch contain" — the same
7
+ * number a GitHub PR UI would show. Uncommitted working-tree edits are
8
+ * intentionally ignored (Codex: "the status-line item summarizes the checked-
9
+ * out branch, not the current dirty worktree").
10
+ *
11
+ * Display follows Kimi: `[+N -M]` appended to the branch, dim grey.
12
+ *
13
+ * Refresh reuses Kimi's synchronous TTL model: re-read at most every 15s,
14
+ * demanded on each footer render. The only divergence from Codex is that their
15
+ * probe is `async`; ours is `spawnSync` because the footer render path is
16
+ * synchronous.
17
+ */
18
+ /** Committed branch delta relative to the default branch. */
19
+ export interface BranchDiffStat {
20
+ readonly added: number;
21
+ readonly deleted: number;
22
+ }
23
+ /** Diff-stat source with a workDir, callable on demand. */
24
+ export interface DiffStatCache {
25
+ /** Current stat, or `null` when workDir isn't a git repo. */
26
+ get(): BranchDiffStat | null;
27
+ }
28
+ /**
29
+ * Injectable git runner seam (tests stub it; production shells out to `git`).
30
+ * Returns stdout, or `null` on any failure.
31
+ */
32
+ export type GitRunner = (args: string[], cwd: string) => string | null;
33
+ /** True when `cwd` is inside a git work tree. Never throws. */
34
+ export declare function detectGitRepo(run: GitRunner, cwd: string): boolean;
35
+ /** Codex's `parseDiffNumstatCount`-equivalent: `-` (binary) → 0; non-numeric → 0. */
36
+ export declare function parseNumstatCount(value: string | undefined): number;
37
+ /**
38
+ * Resolve the default branch name, preferring remote-tracking refs over local
39
+ * branches so a stale local `main` doesn't inflate the diff (Codex's ordering).
40
+ * Falls back to common local names when no remote advertises a default.
41
+ * Returns null when nothing resolves.
42
+ */
43
+ export declare function resolveDefaultBranch(run: GitRunner, cwd: string): string | null;
44
+ /**
45
+ * Sum the committed line delta between `HEAD` and the merge base with the
46
+ * default branch: `git diff --numstat <merge-base>..HEAD`. Returns null when
47
+ * the default branch or merge base can't be resolved (hide the badge).
48
+ */
49
+ export declare function readDiffStat(run: GitRunner, cwd: string): BranchDiffStat | null;
50
+ /**
51
+ * Kimi-style display: `+N -M`, or null when the branch is even with its base
52
+ * (a fresh/unchanged branch shows no badge). Both-zero still renders nothing,
53
+ * matching "our style" (Kimi hides a clean tree; Codex would say "No changes").
54
+ */
55
+ export declare function formatDiffStat(diff: BranchDiffStat | null): string | null;
56
+ /**
57
+ * Time-bucketed cache: `get()` re-reads git only when the last read was more
58
+ * than 15s ago, null off-repo. All git failures → null (badge hidden), never
59
+ * throws.
60
+ */
61
+ export declare function createDiffStatCache(workDir: string, run?: GitRunner): DiffStatCache;
62
+ //# sourceMappingURL=diffStat.d.ts.map
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Committed branch diff stat for the footer's git badge.
3
+ *
4
+ * Counting follows Codex (`tui/src/branch_summary.rs`), not Kimi: it sums the
5
+ * line delta between `HEAD` and the merge base with the repository's *default*
6
+ * branch, so the badge answers "what does this branch contain" — the same
7
+ * number a GitHub PR UI would show. Uncommitted working-tree edits are
8
+ * intentionally ignored (Codex: "the status-line item summarizes the checked-
9
+ * out branch, not the current dirty worktree").
10
+ *
11
+ * Display follows Kimi: `[+N -M]` appended to the branch, dim grey.
12
+ *
13
+ * Refresh reuses Kimi's synchronous TTL model: re-read at most every 15s,
14
+ * demanded on each footer render. The only divergence from Codex is that their
15
+ * probe is `async`; ours is `spawnSync` because the footer render path is
16
+ * synchronous.
17
+ */
18
+ import { spawnSync } from "node:child_process";
19
+ /** Status refresh interval — same value Kimi uses (STATUS_TTL_MS). */
20
+ const STATUS_TTL_MS = 15_000;
21
+ /** git spawn timeout — same value Kimi uses (SPAWN_TIMEOUT_MS). */
22
+ const SPAWN_TIMEOUT_MS = 500;
23
+ const SPAWN_MAX_BUFFER = 4 * 1024 * 1024;
24
+ /** Production runner: `spawnSync` with a short timeout, all errors → null. */
25
+ function runGit(args, cwd) {
26
+ try {
27
+ const r = spawnSync("git", ["--no-optional-locks", ...args], {
28
+ cwd,
29
+ encoding: "utf8",
30
+ timeout: SPAWN_TIMEOUT_MS,
31
+ maxBuffer: SPAWN_MAX_BUFFER,
32
+ stdio: ["ignore", "pipe", "ignore"],
33
+ });
34
+ if (r.error || r.status !== 0)
35
+ return null;
36
+ return r.stdout;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ /** True when `cwd` is inside a git work tree. Never throws. */
43
+ export function detectGitRepo(run, cwd) {
44
+ const out = run(["rev-parse", "--is-inside-work-tree"], cwd);
45
+ return out !== null && out.trim() === "true";
46
+ }
47
+ /** Codex's `parseDiffNumstatCount`-equivalent: `-` (binary) → 0; non-numeric → 0. */
48
+ export function parseNumstatCount(value) {
49
+ if (value === undefined || value === "-")
50
+ return 0;
51
+ const n = Number.parseInt(value, 10);
52
+ return Number.isFinite(n) && n > 0 ? n : 0;
53
+ }
54
+ /** List remotes, `origin` first (mirrors Codex's `get_git_remotes`). */
55
+ function gitRemotes(run, cwd) {
56
+ const out = run(["remote"], cwd);
57
+ if (out === null)
58
+ return [];
59
+ const remotes = out.split("\n").map((l) => l.trim()).filter(Boolean);
60
+ const originIdx = remotes.indexOf("origin");
61
+ if (originIdx > 0) {
62
+ remotes.splice(originIdx, 1);
63
+ remotes.unshift("origin");
64
+ }
65
+ return remotes;
66
+ }
67
+ /**
68
+ * Resolve the default branch name, preferring remote-tracking refs over local
69
+ * branches so a stale local `main` doesn't inflate the diff (Codex's ordering).
70
+ * Falls back to common local names when no remote advertises a default.
71
+ * Returns null when nothing resolves.
72
+ */
73
+ export function resolveDefaultBranch(run, cwd) {
74
+ // Tier 1: remote default via `refs/remotes/<remote>/HEAD` symbolic ref.
75
+ for (const remote of gitRemotes(run, cwd)) {
76
+ const out = run(["symbolic-ref", "--quiet", `refs/remotes/${remote}/HEAD`], cwd);
77
+ if (out === null)
78
+ continue;
79
+ const ref = out.trim();
80
+ if (!ref)
81
+ continue;
82
+ // `refs/remotes/origin/main` → `main`.
83
+ const name = ref.replace(/^refs\/remotes\/[^/]+\//, "");
84
+ if (name && name !== "HEAD")
85
+ return name;
86
+ }
87
+ // Tier 2: local main/master/trunk/develop.
88
+ for (const candidate of ["main", "master", "trunk", "develop"]) {
89
+ const out = run(["rev-parse", "--verify", "--quiet", candidate], cwd);
90
+ if (out !== null && out.trim())
91
+ return candidate;
92
+ }
93
+ return null;
94
+ }
95
+ /**
96
+ * Sum the committed line delta between `HEAD` and the merge base with the
97
+ * default branch: `git diff --numstat <merge-base>..HEAD`. Returns null when
98
+ * the default branch or merge base can't be resolved (hide the badge).
99
+ */
100
+ export function readDiffStat(run, cwd) {
101
+ const defaultBranch = resolveDefaultBranch(run, cwd);
102
+ if (defaultBranch === null)
103
+ return null;
104
+ const mergeBase = run(["merge-base", "HEAD", defaultBranch], cwd);
105
+ if (mergeBase === null || !mergeBase.trim())
106
+ return null;
107
+ const out = run(["diff", "--numstat", `${mergeBase.trim()}..HEAD`, "--"], cwd);
108
+ if (out === null)
109
+ return null;
110
+ let added = 0;
111
+ let deleted = 0;
112
+ for (const line of out.split("\n")) {
113
+ if (!line)
114
+ continue;
115
+ const [addedText, deletedText] = line.split("\t");
116
+ added += parseNumstatCount(addedText);
117
+ deleted += parseNumstatCount(deletedText);
118
+ }
119
+ return { added, deleted };
120
+ }
121
+ /**
122
+ * Kimi-style display: `+N -M`, or null when the branch is even with its base
123
+ * (a fresh/unchanged branch shows no badge). Both-zero still renders nothing,
124
+ * matching "our style" (Kimi hides a clean tree; Codex would say "No changes").
125
+ */
126
+ export function formatDiffStat(diff) {
127
+ if (diff === null)
128
+ return null;
129
+ const parts = [];
130
+ if (diff.added > 0)
131
+ parts.push(`+${String(diff.added)}`);
132
+ if (diff.deleted > 0)
133
+ parts.push(`-${String(diff.deleted)}`);
134
+ return parts.length > 0 ? parts.join(" ") : null;
135
+ }
136
+ /**
137
+ * Time-bucketed cache: `get()` re-reads git only when the last read was more
138
+ * than 15s ago, null off-repo. All git failures → null (badge hidden), never
139
+ * throws.
140
+ */
141
+ export function createDiffStatCache(workDir, run = runGit) {
142
+ const isRepo = detectGitRepo(run, workDir);
143
+ let cached = null;
144
+ let fetchedAt = 0;
145
+ return {
146
+ get() {
147
+ if (!isRepo)
148
+ return null;
149
+ const now = Date.now();
150
+ if (cached === null || now - fetchedAt >= STATUS_TTL_MS) {
151
+ cached = readDiffStat(run, workDir);
152
+ fetchedAt = now;
153
+ }
154
+ return cached;
155
+ },
156
+ };
157
+ }
158
+ //# sourceMappingURL=diffStat.js.map
@@ -96,6 +96,8 @@ export declare function renderFooterLines(input: {
96
96
  model: string;
97
97
  /** Current permission mode; shown on line 2 to the left of the model as "<mode> mode". */
98
98
  mode?: PermissionMode | null;
99
+ /** Formatted `+N -M` / `±`, or null when clean — appended to the branch as `[ … ]`. */
100
+ diff?: string | null;
99
101
  usage: UsageTotals;
100
102
  contextPercent: number | null;
101
103
  statuses: string[];
@@ -42,6 +42,7 @@ import { spawnSync } from "node:child_process";
42
42
  import { statSync } from "node:fs";
43
43
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
44
44
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
45
+ import { createDiffStatCache, formatDiffStat } from "./diffStat.js";
45
46
  export const BRANCH_MAX_WIDTH = 60;
46
47
  const WORKTREE_MAX_WIDTH = 30;
47
48
  /** Section separator: single space + middle dot + single space. */
@@ -229,13 +230,18 @@ export function renderFooterLines(input, theme, width, padX = 0) {
229
230
  // terminal edge. Truncation runs against the reduced content width.
230
231
  const pad = " ".repeat(Math.max(0, Math.min(3, Math.floor(padX))));
231
232
  const contentWidth = Math.max(1, width - pad.length);
232
- // Line 1: folder · [worktree] · branch
233
+ // Line 1: folder · [worktree] · branch [+N -M]
233
234
  const line1Parts = [theme.fg("accent", input.git.folder)];
234
235
  if (input.git.inRepo) {
235
236
  if (input.git.worktree)
236
237
  line1Parts.push(theme.fg("warning", `[${input.git.worktree}]`));
237
- if (input.git.branch)
238
- line1Parts.push(theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH, dim("…"))));
238
+ if (input.git.branch) {
239
+ const branchSpan = theme.fg("border", truncateEnd(input.git.branch, BRANCH_MAX_WIDTH, dim("…")));
240
+ // Diff badge rides the branch segment (Kimi's `branch [ +N -M ]` shape),
241
+ // colored dim — the same grey as the cost stats on line 2.
242
+ const diffSpan = input.diff ? ` ${dim(`[${input.diff}]`)}` : "";
243
+ line1Parts.push(branchSpan + diffSpan);
244
+ }
239
245
  }
240
246
  const line1 = pad + truncateToWidth(line1Parts.join(sep), contentWidth, dim("…"));
241
247
  // Line 2: [mode ·] model · ↑in ↓out $cost · ctx%
@@ -269,17 +275,23 @@ export function renderFooterLines(input, theme, width, padX = 0) {
269
275
  export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
270
276
  return (_tui, theme, footerData) => {
271
277
  let gitCache;
272
- const unsubscribeBranch = footerData.onBranchChange?.(() => {
278
+ let diffStatCache;
279
+ const clearCaches = () => {
273
280
  gitCache = undefined;
274
- });
281
+ diffStatCache = undefined;
282
+ };
283
+ const unsubscribeBranch = footerData.onBranchChange?.(clearCaches);
275
284
  const gitInfo = () => {
276
285
  if (!gitCache) {
277
- gitCache = detectGitInfo(ctx.sessionManager.getCwd(), process.env.HOME || process.env.USERPROFILE);
286
+ // Read cwd once; both the git info and the diff cache key off it.
287
+ const dir = ctx.sessionManager.getCwd();
288
+ gitCache = detectGitInfo(dir, process.env.HOME || process.env.USERPROFILE);
289
+ diffStatCache = createDiffStatCache(dir);
278
290
  }
279
291
  return gitCache;
280
292
  };
281
293
  if (invalidateHandle) {
282
- invalidateHandle.invalidateGit = () => { gitCache = undefined; };
294
+ invalidateHandle.invalidateGit = clearCaches;
283
295
  invalidateHandle.requestRender = () => { _tui?.requestRender?.(); };
284
296
  }
285
297
  return {
@@ -291,13 +303,14 @@ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
291
303
  git: gitInfo(),
292
304
  model: ctx.model?.id ?? "no-model",
293
305
  mode: modeHolder?.get() ?? null,
306
+ diff: formatDiffStat(diffStatCache?.get() ?? null),
294
307
  usage: collectUsage(ctx.sessionManager),
295
308
  contextPercent: ctx.getContextUsage()?.percent ?? null,
296
309
  statuses,
297
310
  }, theme, width, resolveFooterPadX());
298
311
  },
299
312
  invalidate() {
300
- gitCache = undefined;
313
+ clearCaches();
301
314
  },
302
315
  dispose() {
303
316
  unsubscribeBranch?.();
@@ -65,9 +65,16 @@ export declare function buildStageInvocation(stage: PipelineStage, ctx: {
65
65
  * `yagni` provider) before a stage passthrough. Mirrors buildLaunch's
66
66
  * `userChoseProvider` guard so a passthrough that already chose a provider is
67
67
  * left untouched.
68
+ *
69
+ * `otelExtensionPath` (absent on a non-exporting launch) additionally loads
70
+ * pi-otel so child LLM spend is traced too — stage children are the bulk of a
71
+ * /go run's cost, and an OTel export that misses them undercounts. The path
72
+ * arrives via `YAGNI_OTEL_EXTENSION_PATH` from the launcher's gate (see the
73
+ * CLI's otel.ts); the capture-mode and endpoint env rides the inherited env.
68
74
  */
69
75
  export declare function groundedChildArgv(stageArgv: string[], opts: {
70
76
  piCli: string;
71
77
  extensionPath: string;
78
+ otelExtensionPath?: string;
72
79
  }): string[];
73
80
  //# sourceMappingURL=invocation.d.ts.map
@@ -84,6 +84,12 @@ export function buildStageInvocation(stage, ctx) {
84
84
  * `yagni` provider) before a stage passthrough. Mirrors buildLaunch's
85
85
  * `userChoseProvider` guard so a passthrough that already chose a provider is
86
86
  * left untouched.
87
+ *
88
+ * `otelExtensionPath` (absent on a non-exporting launch) additionally loads
89
+ * pi-otel so child LLM spend is traced too — stage children are the bulk of a
90
+ * /go run's cost, and an OTel export that misses them undercounts. The path
91
+ * arrives via `YAGNI_OTEL_EXTENSION_PATH` from the launcher's gate (see the
92
+ * CLI's otel.ts); the capture-mode and endpoint env rides the inherited env.
87
93
  */
88
94
  export function groundedChildArgv(stageArgv, opts) {
89
95
  const userChoseProvider = stageArgv.includes("--provider");
@@ -91,6 +97,7 @@ export function groundedChildArgv(stageArgv, opts) {
91
97
  opts.piCli,
92
98
  "-e",
93
99
  opts.extensionPath,
100
+ ...(opts.otelExtensionPath ? ["-e", opts.otelExtensionPath] : []),
94
101
  ...(userChoseProvider ? [] : ["--provider", "yagni"]),
95
102
  ...stageArgv,
96
103
  ];
@@ -32,6 +32,7 @@ export interface RunStageDeps {
32
32
  resolveChild?: () => {
33
33
  piCli: string;
34
34
  extensionPath: string;
35
+ otelExtensionPath?: string;
35
36
  };
36
37
  /** Optional environment for spawned child stages. Defaults to Node's inherited env. */
37
38
  env?: NodeJS.ProcessEnv;
@@ -61,11 +61,19 @@ async function defaultWritePrompt(body) {
61
61
  /**
62
62
  * Default child resolution: the parent process IS pi (so `process.argv[1]` is
63
63
  * pi's cli), and our compiled extension entry sits one dir up from this module.
64
+ *
65
+ * `otelExtensionPath` comes from `YAGNI_OTEL_EXTENSION_PATH`, set by the
66
+ * launcher only when an OTLP endpoint is configured (see the CLI's otel.ts) —
67
+ * children then load pi-otel so their LLM spend is traced. Guarded with
68
+ * existsSync so a stale env value degrades to an untraced child, never a
69
+ * child that fails to boot.
64
70
  */
65
71
  function defaultResolveChild() {
66
72
  const piCli = process.argv[1] ?? "pi";
67
73
  const extensionPath = fileURLToPath(new URL("../index.js", import.meta.url));
68
- return { piCli, extensionPath };
74
+ const otelPath = process.env.YAGNI_OTEL_EXTENSION_PATH;
75
+ const otelExtensionPath = otelPath && fs.existsSync(otelPath) ? otelPath : undefined;
76
+ return { piCli, extensionPath, ...(otelExtensionPath ? { otelExtensionPath } : {}) };
69
77
  }
70
78
  const EMPTY_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
71
79
  /** Truncate to PER_TASK_OUTPUT_CAP bytes, never exceeding the cap. */
@@ -116,8 +124,12 @@ export async function runStage(stage, ctx, deps) {
116
124
  lens: ctx.lens,
117
125
  ...(tierCap ? { tierCap } : {}),
118
126
  });
119
- const { piCli, extensionPath } = resolveChild();
120
- const argv = groundedChildArgv(passthrough, { piCli, extensionPath });
127
+ const { piCli, extensionPath, otelExtensionPath } = resolveChild();
128
+ const argv = groundedChildArgv(passthrough, {
129
+ piCli,
130
+ extensionPath,
131
+ ...(otelExtensionPath ? { otelExtensionPath } : {}),
132
+ });
121
133
  // Fold events into a fixed-size accumulator as they stream — never retain
122
134
  // the full array (that OOMs on a verbose stage; YAG-317 follow-up).
123
135
  const acc = newEventAccumulator();
@@ -15,6 +15,7 @@
15
15
  * runs locally and what the fleet executes are the same binary and the same
16
16
  * pipeline.
17
17
  */
18
+ import { type OtelLaunchConfig } from "./otel.js";
18
19
  /** Mirrors HEADLESS_GO_EXIT in the extension; duplicated to keep the packages independent. */
19
20
  export declare const GO_EXIT: {
20
21
  readonly verified: 0;
@@ -64,6 +65,8 @@ export declare function buildHeadlessChildEnv(opts: {
64
65
  workspaceId?: string;
65
66
  cliVersion?: string;
66
67
  sessionId?: string;
68
+ /** Resolved OTel export config; stage children load pi-otel when present. */
69
+ otel?: OtelLaunchConfig;
67
70
  }): NodeJS.ProcessEnv;
68
71
  /**
69
72
  * Run `yagni go`. Returns the process exit code: 0 only on a verified
@@ -20,6 +20,7 @@ import { mkdirSync } from "node:fs";
20
20
  import { pathToFileURL } from "node:url";
21
21
  import { agentDirEnvVar } from "./branding.js";
22
22
  import { agentDir, credentialsDir } from "./credentials.js";
23
+ import { otelChildEnv, resolveOtelLaunchWithWorkspace } from "./otel.js";
23
24
  import { credentialsFromProfile, profilePath, readActiveProfile } from "./profiles.js";
24
25
  import { resolveHeadlessGoPath, resolvePiCliPath } from "./paths.js";
25
26
  /** Mirrors HEADLESS_GO_EXIT in the extension; duplicated to keep the packages independent. */
@@ -54,6 +55,9 @@ export function buildHeadlessChildEnv(opts) {
54
55
  PI_CODING_AGENT_DIR: piAgentDir,
55
56
  PI_SKIP_VERSION_CHECK: "1",
56
57
  PI_TELEMETRY: "0",
58
+ // OTel export: pin metadata-only capture and forward pi-otel's path so
59
+ // every stage child spawns with it (see otel.ts for the policy).
60
+ ...(opts.otel ? otelChildEnv(opts.otel, opts.baseEnv) : {}),
57
61
  };
58
62
  }
59
63
  /**
@@ -88,6 +92,14 @@ export async function goCommand(args, deps = {}, cliVersion) {
88
92
  writeErr(`Not logged in to environment "${profile.name}" (${profile.baseUrl}). Run \`yagni login\` first.`);
89
93
  return GO_EXIT.usage;
90
94
  }
95
+ // Same OTel gate as an interactive launch — a headless /go run's stage
96
+ // children are LLM spend too, and the pilot's cost A/B needs to see them.
97
+ const otel = await resolveOtelLaunchWithWorkspace({
98
+ env: baseEnv,
99
+ cwd: deps.cwd ?? process.cwd(),
100
+ creds: { baseUrl, token },
101
+ profileName: profile.name,
102
+ });
91
103
  const childEnv = buildHeadlessChildEnv({
92
104
  baseEnv,
93
105
  token,
@@ -96,6 +108,7 @@ export async function goCommand(args, deps = {}, cliVersion) {
96
108
  ...(profile.expiresAt ? { expiresAt: profile.expiresAt } : {}),
97
109
  ...(profile.workspaceId ? { workspaceId: profile.workspaceId } : {}),
98
110
  ...(cliVersion ? { cliVersion } : {}),
111
+ ...(otel ? { otel } : {}),
99
112
  });
100
113
  // The hermetic agent dir must exist before a stage child tries to read it.
101
114
  try {
package/dist/launch.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * defaults the provider to `yagni`.
7
7
  */
8
8
  import type { Credentials } from "./credentials.js";
9
+ import { type OtelLaunchConfig } from "./otel.js";
9
10
  export interface LaunchPlan {
10
11
  env: NodeJS.ProcessEnv;
11
12
  argv: string[];
@@ -92,6 +93,13 @@ export interface BuildLaunchOptions {
92
93
  * override the token, base URL, or hermetic agent dir.
93
94
  */
94
95
  extraEnv?: Record<string, string>;
96
+ /**
97
+ * Resolved OTel export config (see `otel.ts`). When present, the session
98
+ * loads pi-otel alongside our extension and the env pins metadata-only
99
+ * capture; absent means no OTLP endpoint is configured and the launch is
100
+ * byte-for-byte what it was before OTel support existed.
101
+ */
102
+ otel?: OtelLaunchConfig;
95
103
  }
96
104
  export declare function buildLaunch(creds: Credentials | null, passthroughArgs: string[], opts: BuildLaunchOptions): LaunchPlan;
97
105
  //# sourceMappingURL=launch.d.ts.map
package/dist/launch.js CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { agentDirEnvVar } from "./branding.js";
10
+ import { otelChildEnv } from "./otel.js";
10
11
  import { PAD_X_ENV, resolvePadX } from "./padding.js";
11
12
  import { ENGINEERING_PRACTICE_SECTION, promptEnrichmentDisabled } from "./promptEnrichment.js";
12
13
  /**
@@ -105,6 +106,10 @@ export function buildLaunch(creds, passthroughArgs, opts) {
105
106
  // the editor (which the launcher pads by seeding editorPaddingX). The
106
107
  // footer can't read pi's settings, so the value crosses over env.
107
108
  [PAD_X_ENV]: String(resolvePadX()),
109
+ // OTel export (gated in otel.ts): pin metadata-only capture and forward
110
+ // pi-otel's path so /go stage children and subagents load it too. Placed
111
+ // after baseEnv on purpose — the capture pin must beat a user env override.
112
+ ...(opts.otel ? otelChildEnv(opts.otel, opts.baseEnv ?? {}) : {}),
108
113
  };
109
114
  // Always load our extension. Default the provider to `yagni` unless the user
110
115
  // explicitly chose one (so power users can still point pi elsewhere).
@@ -125,6 +130,7 @@ export function buildLaunch(creds, passthroughArgs, opts) {
125
130
  const argv = [
126
131
  "-e",
127
132
  opts.extensionPath,
133
+ ...(opts.otel ? ["-e", opts.otel.extensionPath] : []),
128
134
  ...(userChoseProvider ? [] : ["--provider", "yagni"]),
129
135
  ...(userChoseModel ? [] : ["--model", "advanced"]),
130
136
  ...(enrichmentOff ? [] : ["--append-system-prompt", ENGINEERING_PRACTICE_SECTION]),
package/dist/otel.d.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * OTel export wiring (Updater pilot ask): bake the `pi-otel` extension into
3
+ * every launch so a workspace can stream session traces — token usage, cost,
4
+ * model tier, tool calls — to its own OTLP collector (e.g. Datadog) without
5
+ * anyone hand-installing an extension.
6
+ *
7
+ * Three deliberate policies, all enforced here rather than left to defaults:
8
+ *
9
+ * 1. GATED, not always-on. pi-otel ships `enabled: true` pointed at
10
+ * `localhost:4317`, so loading it unconditionally would have every session
11
+ * probing a collector nobody runs. We only pass the extension to pi when an
12
+ * OTLP endpoint is actually configured: `OTEL_EXPORTER_OTLP_ENDPOINT` in the
13
+ * environment, or `otel.endpoint` in the repo's committed `.pi/settings.json`
14
+ * (the file pi-otel itself reads, so teams can configure once per repo).
15
+ *
16
+ * 2. METADATA-ONLY, enforced. Cost, tokens, model, finish reasons and tool-call
17
+ * ids export; prompt and response text never do. `PI_OTEL_CAPTURE_CONTENT`
18
+ * is pinned in the child env, and env beats settings in pi-otel's own
19
+ * precedence — a repo settings file asking for "full" is overridden, not
20
+ * honored. Session content leaving the machine is a contract change, not a
21
+ * config knob.
22
+ *
23
+ * 3. Cost is the customer's SELL rate. pi-otel exports pi's `usage.cost.total`
24
+ * verbatim (as `pi.cost.usd`), and pi computes that from the catalog's
25
+ * tier rates (YAG-381) — so the collector sees contracted prices, and the
26
+ * exported model name is the opaque tier id, never the backing model.
27
+ *
28
+ * Everything here is fail-soft: telemetry must never block a launch, so a
29
+ * missing package or unreadable settings file resolves to "disabled".
30
+ */
31
+ import { readFileSync } from "node:fs";
32
+ /**
33
+ * Env var carrying the resolved pi-otel entry path to /go stage children and
34
+ * subagents: they build their own pi argv inside pi-extension-yagni (which
35
+ * cannot import this package), so the path crosses over env. The extension
36
+ * reads the same literal in `pipeline/runner.ts`.
37
+ */
38
+ export declare const OTEL_EXTENSION_PATH_ENV = "YAGNI_OTEL_EXTENSION_PATH";
39
+ /** The service name a collector sees unless the user set their own. */
40
+ export declare const DEFAULT_OTEL_SERVICE_NAME = "yagni-code";
41
+ export interface OtelLaunchConfig {
42
+ /** Absolute path to pi-otel's extension entry. */
43
+ extensionPath: string;
44
+ /**
45
+ * Where the enablement signal came from. "workspace" additionally carries
46
+ * protocol/headers/serviceName, delivered as env to the session (the other
47
+ * sources leave those to whatever the user/repo already configured).
48
+ */
49
+ source: "env" | "workspace" | "project-settings";
50
+ /** The configured OTLP endpoint (for doctor display; env for workspace source). */
51
+ endpoint: string;
52
+ /** Workspace-configured OTLP protocol (workspace source only). */
53
+ protocol?: string;
54
+ /** Workspace-configured collector headers (workspace source only; carries secrets). */
55
+ headers?: Record<string, string>;
56
+ /** Workspace-configured service name (workspace source only). */
57
+ serviceName?: string;
58
+ }
59
+ /**
60
+ * The `otel` block a workspace admin configures in the web app, as served on
61
+ * GET /api/yagni-code/models (decrypted headers included — this is the same
62
+ * trust boundary as the YAGNI_TOKEN that fetched it).
63
+ */
64
+ export interface WorkspaceOtelConfig {
65
+ enabled: boolean;
66
+ endpoint: string;
67
+ protocol?: string;
68
+ serviceName?: string | null;
69
+ headers?: Record<string, string>;
70
+ }
71
+ /**
72
+ * Decide whether this launch exports OTel traces, and with which pi-otel entry.
73
+ * Returns undefined when export stays off: no endpoint configured anywhere,
74
+ * `PI_OTEL_DISABLED` set, or the pi-otel package unresolvable (never fatal).
75
+ */
76
+ export declare function resolveOtelLaunch(opts: {
77
+ env: NodeJS.ProcessEnv;
78
+ cwd: string;
79
+ /** Injectable seams for tests. */
80
+ resolveExtension?: () => string;
81
+ readFile?: typeof readFileSync;
82
+ }): OtelLaunchConfig | undefined;
83
+ /**
84
+ * Absolute path to pi-otel's extension entry (its package main IS the pi
85
+ * extension entry, `dist/index.js`). Resolved ESM-native like `paths.ts` does
86
+ * for pi itself. Throws when the package is missing — callers treat that as
87
+ * "export off", not an error.
88
+ */
89
+ export declare function resolveOtelExtensionPath(): string;
90
+ /**
91
+ * The env keys an OTel-exporting child must carry. `PI_OTEL_CAPTURE_CONTENT`
92
+ * is pinned unconditionally (policy 2 above); the service name only fills in
93
+ * when the user has not chosen their own.
94
+ *
95
+ * A "workspace"-sourced config additionally delivers the admin-set endpoint,
96
+ * protocol, and collector headers over the standard OTel env vars — but any
97
+ * of those the USER already set in their own environment wins (local env >
98
+ * workspace config), so an engineer can point one session at a scratch
99
+ * collector without an admin change.
100
+ */
101
+ export declare function otelChildEnv(config: OtelLaunchConfig, baseEnv: NodeJS.ProcessEnv): Record<string, string>;
102
+ export interface WorkspaceOtelFetchDeps {
103
+ fetchImpl?: typeof fetch;
104
+ cacheDir?: string;
105
+ timeoutMs?: number;
106
+ }
107
+ /**
108
+ * Resolve the workspace's admin-set OTel config: one fail-soft GET of the
109
+ * catalog endpoint (which carries the additive `otel` block), cached on disk
110
+ * per profile so a slow or offline backend degrades to last-known config
111
+ * instead of a launch stall.
112
+ *
113
+ * Cache semantics matter for revocation: a SUCCESSFUL response without an
114
+ * otel block means the admin disabled or removed the config, so the cache is
115
+ * DELETED — only a network/HTTP failure falls back to it. Otherwise turning
116
+ * export off in the web app would leave every laptop exporting (with a stale
117
+ * collector key) until the cache happened to be overwritten.
118
+ *
119
+ * The cache lives under the credentials dir at mode 0600 — the same posture
120
+ * as the profile token files, which is the right comparison: the cached
121
+ * headers carry the collector key, the profile carries the YAGNI token.
122
+ */
123
+ export declare function fetchWorkspaceOtel(creds: {
124
+ baseUrl: string;
125
+ token: string;
126
+ }, profileName: string, deps?: WorkspaceOtelFetchDeps): Promise<WorkspaceOtelConfig | null>;
127
+ /**
128
+ * Full launch-time resolution, all three sources in precedence order:
129
+ *
130
+ * 1. `PI_OTEL_DISABLED` — personal kill switch, beats everything.
131
+ * 2. env `OTEL_EXPORTER_OTLP_ENDPOINT` — the user's own setup, untouched.
132
+ * 3. workspace config — admin-set in the web app, fetched/cached.
133
+ * 4. repo `.pi/settings.json` — committed per-repo config.
134
+ *
135
+ * `creds` absent (not logged in — doctor on a fresh machine) skips source 3.
136
+ */
137
+ export declare function resolveOtelLaunchWithWorkspace(opts: {
138
+ env: NodeJS.ProcessEnv;
139
+ cwd: string;
140
+ creds?: {
141
+ baseUrl: string;
142
+ token: string;
143
+ } | null;
144
+ profileName?: string;
145
+ resolveExtension?: () => string;
146
+ readFile?: typeof readFileSync;
147
+ fetchDeps?: WorkspaceOtelFetchDeps;
148
+ fetchWorkspace?: typeof fetchWorkspaceOtel;
149
+ }): Promise<OtelLaunchConfig | undefined>;
150
+ //# sourceMappingURL=otel.d.ts.map
package/dist/otel.js ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * OTel export wiring (Updater pilot ask): bake the `pi-otel` extension into
3
+ * every launch so a workspace can stream session traces — token usage, cost,
4
+ * model tier, tool calls — to its own OTLP collector (e.g. Datadog) without
5
+ * anyone hand-installing an extension.
6
+ *
7
+ * Three deliberate policies, all enforced here rather than left to defaults:
8
+ *
9
+ * 1. GATED, not always-on. pi-otel ships `enabled: true` pointed at
10
+ * `localhost:4317`, so loading it unconditionally would have every session
11
+ * probing a collector nobody runs. We only pass the extension to pi when an
12
+ * OTLP endpoint is actually configured: `OTEL_EXPORTER_OTLP_ENDPOINT` in the
13
+ * environment, or `otel.endpoint` in the repo's committed `.pi/settings.json`
14
+ * (the file pi-otel itself reads, so teams can configure once per repo).
15
+ *
16
+ * 2. METADATA-ONLY, enforced. Cost, tokens, model, finish reasons and tool-call
17
+ * ids export; prompt and response text never do. `PI_OTEL_CAPTURE_CONTENT`
18
+ * is pinned in the child env, and env beats settings in pi-otel's own
19
+ * precedence — a repo settings file asking for "full" is overridden, not
20
+ * honored. Session content leaving the machine is a contract change, not a
21
+ * config knob.
22
+ *
23
+ * 3. Cost is the customer's SELL rate. pi-otel exports pi's `usage.cost.total`
24
+ * verbatim (as `pi.cost.usd`), and pi computes that from the catalog's
25
+ * tier rates (YAG-381) — so the collector sees contracted prices, and the
26
+ * exported model name is the opaque tier id, never the backing model.
27
+ *
28
+ * Everything here is fail-soft: telemetry must never block a launch, so a
29
+ * missing package or unreadable settings file resolves to "disabled".
30
+ */
31
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
32
+ import { join } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+ import { credentialsDir } from "./credentials.js";
35
+ /**
36
+ * Env var carrying the resolved pi-otel entry path to /go stage children and
37
+ * subagents: they build their own pi argv inside pi-extension-yagni (which
38
+ * cannot import this package), so the path crosses over env. The extension
39
+ * reads the same literal in `pipeline/runner.ts`.
40
+ */
41
+ export const OTEL_EXTENSION_PATH_ENV = "YAGNI_OTEL_EXTENSION_PATH";
42
+ /** The service name a collector sees unless the user set their own. */
43
+ export const DEFAULT_OTEL_SERVICE_NAME = "yagni-code";
44
+ /**
45
+ * Read `otel.endpoint` from the repo's `.pi/settings.json`, the project-level
46
+ * file pi-otel itself resolves config from. Returns undefined on any problem —
47
+ * a malformed settings file must not block a launch (pi-otel will surface its
48
+ * own complaint in-session).
49
+ */
50
+ function projectOtelEndpoint(cwd, readFile) {
51
+ try {
52
+ const raw = readFile(join(cwd, ".pi", "settings.json"), "utf8");
53
+ const parsed = JSON.parse(String(raw));
54
+ if (typeof parsed !== "object" || parsed === null)
55
+ return undefined;
56
+ const otel = parsed.otel;
57
+ if (typeof otel !== "object" || otel === null)
58
+ return undefined;
59
+ const endpoint = otel.endpoint;
60
+ return typeof endpoint === "string" && endpoint.trim() !== "" ? endpoint.trim() : undefined;
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ /** Truthy per pi-otel's own convention for PI_OTEL_DISABLED. */
67
+ function envDisabled(env) {
68
+ return env.PI_OTEL_DISABLED === "1" || env.PI_OTEL_DISABLED === "true";
69
+ }
70
+ /**
71
+ * Decide whether this launch exports OTel traces, and with which pi-otel entry.
72
+ * Returns undefined when export stays off: no endpoint configured anywhere,
73
+ * `PI_OTEL_DISABLED` set, or the pi-otel package unresolvable (never fatal).
74
+ */
75
+ export function resolveOtelLaunch(opts) {
76
+ const { env, cwd } = opts;
77
+ if (envDisabled(env))
78
+ return undefined;
79
+ const envEndpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim();
80
+ const settingsEndpoint = envEndpoint
81
+ ? undefined
82
+ : projectOtelEndpoint(cwd, opts.readFile ?? readFileSync);
83
+ if (!envEndpoint && !settingsEndpoint)
84
+ return undefined;
85
+ let extensionPath;
86
+ try {
87
+ extensionPath = (opts.resolveExtension ?? resolveOtelExtensionPath)();
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ return envEndpoint
93
+ ? { extensionPath, source: "env", endpoint: envEndpoint }
94
+ : { extensionPath, source: "project-settings", endpoint: settingsEndpoint };
95
+ }
96
+ /**
97
+ * Absolute path to pi-otel's extension entry (its package main IS the pi
98
+ * extension entry, `dist/index.js`). Resolved ESM-native like `paths.ts` does
99
+ * for pi itself. Throws when the package is missing — callers treat that as
100
+ * "export off", not an error.
101
+ */
102
+ export function resolveOtelExtensionPath() {
103
+ const path = fileURLToPath(import.meta.resolve("pi-otel"));
104
+ if (!existsSync(path)) {
105
+ throw new Error(`pi-otel entry not found at ${path}`);
106
+ }
107
+ return path;
108
+ }
109
+ /**
110
+ * The env keys an OTel-exporting child must carry. `PI_OTEL_CAPTURE_CONTENT`
111
+ * is pinned unconditionally (policy 2 above); the service name only fills in
112
+ * when the user has not chosen their own.
113
+ *
114
+ * A "workspace"-sourced config additionally delivers the admin-set endpoint,
115
+ * protocol, and collector headers over the standard OTel env vars — but any
116
+ * of those the USER already set in their own environment wins (local env >
117
+ * workspace config), so an engineer can point one session at a scratch
118
+ * collector without an admin change.
119
+ */
120
+ export function otelChildEnv(config, baseEnv) {
121
+ const workspace = {};
122
+ if (config.source === "workspace") {
123
+ if (!baseEnv.OTEL_EXPORTER_OTLP_ENDPOINT) {
124
+ workspace.OTEL_EXPORTER_OTLP_ENDPOINT = config.endpoint;
125
+ }
126
+ if (config.protocol && !baseEnv.OTEL_EXPORTER_OTLP_PROTOCOL) {
127
+ workspace.OTEL_EXPORTER_OTLP_PROTOCOL = config.protocol;
128
+ }
129
+ if (config.headers && Object.keys(config.headers).length > 0 && !baseEnv.OTEL_EXPORTER_OTLP_HEADERS) {
130
+ workspace.OTEL_EXPORTER_OTLP_HEADERS = Object.entries(config.headers)
131
+ .map(([k, v]) => `${k}=${v}`)
132
+ .join(",");
133
+ }
134
+ if (config.serviceName && !baseEnv.OTEL_SERVICE_NAME) {
135
+ workspace.OTEL_SERVICE_NAME = config.serviceName;
136
+ }
137
+ }
138
+ return {
139
+ ...workspace,
140
+ [OTEL_EXTENSION_PATH_ENV]: config.extensionPath,
141
+ PI_OTEL_CAPTURE_CONTENT: "metadata_only",
142
+ ...(baseEnv.OTEL_SERVICE_NAME || workspace.OTEL_SERVICE_NAME
143
+ ? {}
144
+ : { OTEL_SERVICE_NAME: DEFAULT_OTEL_SERVICE_NAME }),
145
+ };
146
+ }
147
+ // ── Workspace-configured export (admin sets it once in the web app) ─────────
148
+ /** How long the launcher waits on the config fetch before falling back to the
149
+ * on-disk cache. Launch latency is user-facing; telemetry config is not worth
150
+ * more than this. */
151
+ const WORKSPACE_FETCH_TIMEOUT_MS = 1_500;
152
+ function workspaceCachePath(profileName, dir) {
153
+ // Profile names are already path-safe (they name profile JSON files).
154
+ return join(dir, "otel", `${profileName}.json`);
155
+ }
156
+ function parseWorkspaceOtel(raw) {
157
+ if (typeof raw !== "object" || raw === null)
158
+ return null;
159
+ const o = raw;
160
+ if (o.enabled !== true || typeof o.endpoint !== "string" || o.endpoint.trim() === "") {
161
+ return null;
162
+ }
163
+ const headers = {};
164
+ if (typeof o.headers === "object" && o.headers !== null) {
165
+ for (const [k, v] of Object.entries(o.headers)) {
166
+ if (typeof v === "string")
167
+ headers[k] = v;
168
+ }
169
+ }
170
+ return {
171
+ enabled: true,
172
+ endpoint: o.endpoint.trim(),
173
+ ...(typeof o.protocol === "string" ? { protocol: o.protocol } : {}),
174
+ ...(typeof o.serviceName === "string" && o.serviceName ? { serviceName: o.serviceName } : {}),
175
+ headers,
176
+ };
177
+ }
178
+ /**
179
+ * Resolve the workspace's admin-set OTel config: one fail-soft GET of the
180
+ * catalog endpoint (which carries the additive `otel` block), cached on disk
181
+ * per profile so a slow or offline backend degrades to last-known config
182
+ * instead of a launch stall.
183
+ *
184
+ * Cache semantics matter for revocation: a SUCCESSFUL response without an
185
+ * otel block means the admin disabled or removed the config, so the cache is
186
+ * DELETED — only a network/HTTP failure falls back to it. Otherwise turning
187
+ * export off in the web app would leave every laptop exporting (with a stale
188
+ * collector key) until the cache happened to be overwritten.
189
+ *
190
+ * The cache lives under the credentials dir at mode 0600 — the same posture
191
+ * as the profile token files, which is the right comparison: the cached
192
+ * headers carry the collector key, the profile carries the YAGNI token.
193
+ */
194
+ export async function fetchWorkspaceOtel(creds, profileName, deps = {}) {
195
+ const cacheDir = deps.cacheDir ?? credentialsDir();
196
+ const cachePath = workspaceCachePath(profileName, cacheDir);
197
+ const doFetch = deps.fetchImpl ?? fetch;
198
+ let body;
199
+ try {
200
+ const res = await doFetch(`${creds.baseUrl}/api/yagni-code/models`, {
201
+ method: "GET",
202
+ headers: { authorization: `Bearer ${creds.token}` },
203
+ signal: AbortSignal.timeout(deps.timeoutMs ?? WORKSPACE_FETCH_TIMEOUT_MS),
204
+ });
205
+ if (!res.ok)
206
+ throw new Error(`HTTP ${res.status}`);
207
+ body = await res.json();
208
+ }
209
+ catch {
210
+ // Network/HTTP failure → last-known config (or nothing). Fail-soft by
211
+ // design: the fallback is visible via `yagni doctor`, not a launch error.
212
+ return readWorkspaceOtelCache(cachePath);
213
+ }
214
+ const config = parseWorkspaceOtel(body?.otel);
215
+ try {
216
+ if (config) {
217
+ mkdirSync(join(cacheDir, "otel"), { recursive: true, mode: 0o700 });
218
+ writeFileSync(cachePath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
219
+ }
220
+ else {
221
+ rmSync(cachePath, { force: true });
222
+ }
223
+ }
224
+ catch {
225
+ // Cache maintenance is best-effort; the fresh result still applies.
226
+ }
227
+ return config;
228
+ }
229
+ function readWorkspaceOtelCache(cachePath) {
230
+ try {
231
+ return parseWorkspaceOtel(JSON.parse(readFileSync(cachePath, "utf8")));
232
+ }
233
+ catch {
234
+ return null;
235
+ }
236
+ }
237
+ /**
238
+ * Full launch-time resolution, all three sources in precedence order:
239
+ *
240
+ * 1. `PI_OTEL_DISABLED` — personal kill switch, beats everything.
241
+ * 2. env `OTEL_EXPORTER_OTLP_ENDPOINT` — the user's own setup, untouched.
242
+ * 3. workspace config — admin-set in the web app, fetched/cached.
243
+ * 4. repo `.pi/settings.json` — committed per-repo config.
244
+ *
245
+ * `creds` absent (not logged in — doctor on a fresh machine) skips source 3.
246
+ */
247
+ export async function resolveOtelLaunchWithWorkspace(opts) {
248
+ const { env, cwd } = opts;
249
+ if (envDisabled(env))
250
+ return undefined;
251
+ // Source 2: the user's own env config short-circuits — no fetch needed.
252
+ if (env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim()) {
253
+ return resolveOtelLaunch({
254
+ env,
255
+ cwd,
256
+ ...(opts.resolveExtension ? { resolveExtension: opts.resolveExtension } : {}),
257
+ ...(opts.readFile ? { readFile: opts.readFile } : {}),
258
+ });
259
+ }
260
+ // Source 3: workspace config.
261
+ if (opts.creds?.token && opts.profileName) {
262
+ const workspace = await (opts.fetchWorkspace ?? fetchWorkspaceOtel)({ baseUrl: opts.creds.baseUrl, token: opts.creds.token }, opts.profileName, opts.fetchDeps ?? {});
263
+ if (workspace) {
264
+ let extensionPath;
265
+ try {
266
+ extensionPath = (opts.resolveExtension ?? resolveOtelExtensionPath)();
267
+ }
268
+ catch {
269
+ return undefined;
270
+ }
271
+ return {
272
+ extensionPath,
273
+ source: "workspace",
274
+ endpoint: workspace.endpoint,
275
+ ...(workspace.protocol ? { protocol: workspace.protocol } : {}),
276
+ ...(workspace.serviceName ? { serviceName: workspace.serviceName } : {}),
277
+ ...(workspace.headers && Object.keys(workspace.headers).length > 0
278
+ ? { headers: workspace.headers }
279
+ : {}),
280
+ };
281
+ }
282
+ }
283
+ // Source 4: repo-committed settings.
284
+ return resolveOtelLaunch({
285
+ env,
286
+ cwd,
287
+ ...(opts.resolveExtension ? { resolveExtension: opts.resolveExtension } : {}),
288
+ ...(opts.readFile ? { readFile: opts.readFile } : {}),
289
+ });
290
+ }
291
+ //# sourceMappingURL=otel.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.0.0-staging.1180.1",
3
+ "version": "1.0.0-staging.1184.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -36,9 +36,10 @@
36
36
  "dependencies": {
37
37
  "@earendil-works/pi-coding-agent": "0.84.1",
38
38
  "@earendil-works/pi-tui": "0.84.1",
39
+ "pi-otel": "0.1.0",
39
40
  "smol-toml": "^1.8.0",
40
41
  "turndown": "^7.2.4",
41
42
  "typebox": "^1.3.15"
42
43
  },
43
- "yagniSourceSha": "1db89538911e63de0e383aad36bd707e947140d9"
44
+ "yagniSourceSha": "4e0c38ced9c2bc27fd28c01af4e22a1539fd1637"
44
45
  }