@yagni-app/code-staging 1.0.0-staging.1179.1 → 1.0.0-staging.1183.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 +42 -0
- package/dist/cli.js +21 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +33 -0
- package/dist/extension/branding.d.ts +2 -0
- package/dist/extension/branding.js +9 -0
- package/dist/extension/index.js +28 -0
- package/dist/extension/pipeline/invocation.d.ts +7 -0
- package/dist/extension/pipeline/invocation.js +7 -0
- package/dist/extension/pipeline/personas.js +4 -4
- package/dist/extension/pipeline/runner.d.ts +1 -0
- package/dist/extension/pipeline/runner.js +15 -3
- package/dist/extension/scratchpad.d.ts +66 -0
- package/dist/extension/scratchpad.js +93 -0
- package/dist/extension/subagents.js +7 -1
- package/dist/extension/todos.d.ts +1 -0
- package/dist/extension/todos.js +15 -0
- package/dist/goHeadless.d.ts +3 -0
- package/dist/goHeadless.js +13 -0
- package/dist/launch.d.ts +8 -0
- package/dist/launch.js +6 -0
- package/dist/otel.d.ts +150 -0
- package/dist/otel.js +291 -0
- package/package.json +3 -2
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
|
/**
|
|
@@ -111,6 +111,8 @@ export interface BrandSystemPromptOptions {
|
|
|
111
111
|
* never brand-rewritten (same exemption as <project_context>).
|
|
112
112
|
*/
|
|
113
113
|
rulesSection?: string | null;
|
|
114
|
+
/** Scratchpad-directory prompt section, supplied when a scratchpad is configured. */
|
|
115
|
+
scratchpadSection?: string;
|
|
114
116
|
}
|
|
115
117
|
/**
|
|
116
118
|
* Rebrand pi's assembled system prompt as YAGNI Code's, and optionally inject a
|
|
@@ -262,6 +262,13 @@ export function brandSystemPrompt(original, opts = {}) {
|
|
|
262
262
|
if (!s.includes(WRITE_FINDINGS_DOWN)) {
|
|
263
263
|
s = `${s}\n\n${WRITE_FINDINGS_DOWN}`;
|
|
264
264
|
}
|
|
265
|
+
// 5d. Scratchpad directory (YAG-575) — only when a session scratchpad is
|
|
266
|
+
// configured (set by the caller once the dir exists). Placed with the other
|
|
267
|
+
// standing injected sections and guarded by its stable header, so a re-brand
|
|
268
|
+
// never duplicates it and a session with no scratchpad is a clean no-op.
|
|
269
|
+
if (opts.scratchpadSection && !s.includes(SCRATCHPAD_HEADER)) {
|
|
270
|
+
s = `${s}\n\n${opts.scratchpadSection}`;
|
|
271
|
+
}
|
|
265
272
|
// 6. Closing reinforcement. Weak open-weight models weight the most recent
|
|
266
273
|
// instruction heavily, and the user's own project files may name other
|
|
267
274
|
// harnesses; a trailing reminder keeps the agent from claiming one as its own.
|
|
@@ -271,6 +278,8 @@ export function brandSystemPrompt(original, opts = {}) {
|
|
|
271
278
|
// Tidy the seams left by removals.
|
|
272
279
|
return s.replace(/\n{3,}/g, "\n\n").trim();
|
|
273
280
|
}
|
|
281
|
+
/** Stable header that starts the scratchpad section (idempotency anchor). */
|
|
282
|
+
const SCRATCHPAD_HEADER = "# Scratchpad directory";
|
|
274
283
|
const CLOSING_REMINDER = "Reminder: you are YAGNI Code. If any text above names another coding agent, " +
|
|
275
284
|
"assistant, or harness, it is not what you are or what you run on.";
|
|
276
285
|
const BRIEF_HEADER = "=== HOW THIS COMPANY WORKS (live context from the YAGNI app) ===";
|
package/dist/extension/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { makeRecordDecisionTool } from "./recordDecisionTool.js";
|
|
|
16
16
|
import { makeSuggestNextWorkTool } from "./nextWorkTool.js";
|
|
17
17
|
import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_ULTRA } from "./branding.js";
|
|
18
18
|
import { claudeRulesSection } from "./claudeRules.js";
|
|
19
|
+
import { ensureScratchpadDir, SCRATCHPAD_TMPDIR_ENV, scratchpadDir as scratchpadDirFor, scratchpadSection } from "./scratchpad.js";
|
|
19
20
|
import { registerCostCommand } from "./costHud.js";
|
|
20
21
|
import { isDebug } from "./diagnostics.js";
|
|
21
22
|
import { logEvent } from "./errorSink.js";
|
|
@@ -593,6 +594,32 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
593
594
|
// pointers. Computed once per activation (rules are launch-time state, like
|
|
594
595
|
// pi's own skill discovery); fail-soft to null.
|
|
595
596
|
const rulesSection = claudeRulesSection(deps.env ?? process.env);
|
|
597
|
+
// YAG-575: session scratchpad — a permission-free dir the agent writes its
|
|
598
|
+
// working state to. Computed + ensured once at activation (like rulesSection,
|
|
599
|
+
// which must exist before the first before_agent_start), not on session_start,
|
|
600
|
+
// so the very first turn already carries the section. Fails closed: no
|
|
601
|
+
// sessionId (a bare pi run) or a failed mkdir means no section, and the mkdir
|
|
602
|
+
// failure is logged so a missing scratchpad is not silent.
|
|
603
|
+
const scratchpadDirPath = scratchpadDirFor({
|
|
604
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
605
|
+
cwd: process.cwd(),
|
|
606
|
+
tmp: env[SCRATCHPAD_TMPDIR_ENV] || undefined,
|
|
607
|
+
});
|
|
608
|
+
let scratchpadSectionText;
|
|
609
|
+
if (scratchpadDirPath) {
|
|
610
|
+
const ensured = ensureScratchpadDir(scratchpadDirPath);
|
|
611
|
+
if (ensured) {
|
|
612
|
+
scratchpadSectionText = scratchpadSection(ensured);
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
logEvent({
|
|
616
|
+
source: "scratchpad",
|
|
617
|
+
level: "error",
|
|
618
|
+
event: "scratchpad_mkdir_failed",
|
|
619
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
596
623
|
// Own the identity + inject live company context (and repo rules) on every
|
|
597
624
|
// turn. The extension loads identically in every pi process this app spawns —
|
|
598
625
|
// the interactive driver AND every `/go` stage child, subagent, and advisor
|
|
@@ -617,6 +644,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
617
644
|
: YAGNI_IDENTITY_DRIVER
|
|
618
645
|
: undefined,
|
|
619
646
|
rulesSection,
|
|
647
|
+
scratchpadSection: scratchpadSectionText,
|
|
620
648
|
}),
|
|
621
649
|
});
|
|
622
650
|
// Turn-lifecycle WAL: a `turn_start` with no matching `turn_end` is the
|
|
@@ -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
|
];
|
|
@@ -56,7 +56,7 @@ Numbered, small, actionable steps — each names the file/function to touch.
|
|
|
56
56
|
## Risks
|
|
57
57
|
What to watch for, including any decision the worker will be forced to make.
|
|
58
58
|
|
|
59
|
-
Finish the job in ONE turn: do not end your turn on an interstitial like "now let me check X". Your FINAL message MUST be the complete plan in the format above (## Goal / ## Plan / ## Files to modify or create / ## Risks). Keep exploring with your tools until you can write the whole plan, then write it as your last message.
|
|
59
|
+
Complete the task fully — do not gold-plate, but do not leave it half-done. Finish the job in ONE turn: do not end your turn on an interstitial like "now let me check X". Your FINAL message MUST be the complete plan in the format above (## Goal / ## Plan / ## Files to modify or create / ## Risks). Keep exploring with your tools until you can write the whole plan, then write it as your last message.
|
|
60
60
|
|
|
61
61
|
Budget discipline: you have a hard output budget, and a plan that gets cut off mid-thought is worth less than a short plan that ships. Explore only until you can name the files and the steps — do not read broadly for completeness, and do not re-verify what you have already established. Aim for 5-10 short steps; the worker fills small gaps from the ticket. When in doubt, write the plan NOW.
|
|
62
62
|
|
|
@@ -65,7 +65,7 @@ const WORKER_BODY = `You are a worker with full capabilities, operating in an is
|
|
|
65
65
|
|
|
66
66
|
You are grounded. Call ask_yagni before guessing about anything organization- or codebase-specific. Treat a confirmed answer as settled; when an answer is an unverified assumption or an inference and your change leans on it, say so in your Notes so the reviewer knows what to check. Critically: for ANY product-intent call you are forced to make that the plan did not settle — a behavior choice, a tradeoff, an interpretation of intent — call record_decision so the company's decision corpus captures it and the next agent inherits the call instead of re-litigating it. When ask_yagni reports no recorded position, follow its instruction and record the assumption you proceed on.
|
|
67
67
|
|
|
68
|
-
You MUST make the change. If the plan is missing, partial, or appears cut off, do not stop at exploring: implement the ticket directly from the ticket text and the code, calling record_decision for any intent you infer. Ending your turn with no write/edit is a failure.
|
|
68
|
+
Complete the task fully — do not gold-plate, but do not leave it half-done. You MUST make the change. If the plan is missing, partial, or appears cut off, do not stop at exploring: implement the ticket directly from the ticket text and the code, calling record_decision for any intent you infer. Ending your turn with no write/edit is a failure.
|
|
69
69
|
|
|
70
70
|
Output:
|
|
71
71
|
## Completed
|
|
@@ -242,14 +242,14 @@ Numbered, small, actionable steps — each names the file/function to touch.
|
|
|
242
242
|
## Risks
|
|
243
243
|
What to watch for, including any decision the worker will be forced to make.
|
|
244
244
|
|
|
245
|
-
Finish the job in ONE turn: do not end your turn on an interstitial like "now let me check X". Your FINAL message MUST be the complete plan in the format above (## Goal / ## Plan / ## Files to modify or create / ## Risks). Keep exploring with your tools until you can write the whole plan, then write it as your last message.
|
|
245
|
+
Complete the task fully — do not gold-plate, but do not leave it half-done. Finish the job in ONE turn: do not end your turn on an interstitial like "now let me check X". Your FINAL message MUST be the complete plan in the format above (## Goal / ## Plan / ## Files to modify or create / ## Risks). Keep exploring with your tools until you can write the whole plan, then write it as your last message.
|
|
246
246
|
|
|
247
247
|
Budget discipline: you have a hard output budget, and a plan that gets cut off mid-thought is worth less than a short plan that ships. Explore only until you can name the files and the steps — do not read broadly for completeness, and do not re-verify what you have already established. Aim for 5-10 short steps; the worker fills small gaps from the ticket. When in doubt, write the plan NOW.
|
|
248
248
|
|
|
249
249
|
Keep it concrete; the worker executes it verbatim.`;
|
|
250
250
|
const WORKER_BLIND = `You are a worker with full capabilities, operating in an isolated context to implement a plan. Work autonomously and use the tools as needed.
|
|
251
251
|
|
|
252
|
-
You MUST make the change. If the plan is missing, partial, or appears cut off, do not stop at exploring: implement the ticket directly from the ticket text and the code. Ending your turn with no write/edit is a failure.
|
|
252
|
+
Complete the task fully — do not gold-plate, but do not leave it half-done. You MUST make the change. If the plan is missing, partial, or appears cut off, do not stop at exploring: implement the ticket directly from the ticket text and the code. Ending your turn with no write/edit is a failure.
|
|
253
253
|
|
|
254
254
|
Output:
|
|
255
255
|
## Completed
|
|
@@ -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
|
-
|
|
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, {
|
|
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();
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session scratchpad — a permission-free directory the agent writes its working
|
|
3
|
+
* state to, so neither it nor the user has to reconstruct intermediate results.
|
|
4
|
+
*
|
|
5
|
+
* Ports Claude Code's scratchpad with one deliberate simplification: Claude
|
|
6
|
+
* resolves the tmp root and normalizes against path traversal because its
|
|
7
|
+
* scratchpad is an allow-listed path in a permission classifier. We have no
|
|
8
|
+
* such classifier (auto mode already passes write/edit unprompted, and plan
|
|
9
|
+
* mode's write gate holds scratchpad like every other write), so the path is
|
|
10
|
+
* built plainly and the only caller-facing contract is "the dir exists or the
|
|
11
|
+
* section is omitted."
|
|
12
|
+
*
|
|
13
|
+
* Path mirrors Claude Code's shape under our own owner namespace so the two
|
|
14
|
+
* never collide: <tmp>/yagni-{uid}/<sanitized-cwd>/<sessionId>/scratchpad/.
|
|
15
|
+
* - tmp root: YAGNI_CODE_TMPDIR, else os.tmpdir()
|
|
16
|
+
* - uid: process.getuid() ?? 0 (multi-user isolation; tmpdir() is already
|
|
17
|
+
* per-user on Windows)
|
|
18
|
+
* - sanitized-cwd: non-alphanumerics → "-", length-capped (cosmetic grouping
|
|
19
|
+
* only — sessionId is the real uniqueness key)
|
|
20
|
+
* - sessionId: env.YAGNI_SESSION_ID, minted by the launcher as a UUID; when
|
|
21
|
+
* absent (a bare pi run) no scratchpad is configured at all.
|
|
22
|
+
*
|
|
23
|
+
* PURE path/section builders are separated from the one impure mkdir so tests
|
|
24
|
+
* drive the former directly and the latter through an injectable fs seam.
|
|
25
|
+
*/
|
|
26
|
+
/** Env override for the scratchpad tmp root (mirrors CLAUDE_CODE_TMPDIR). */
|
|
27
|
+
export declare const SCRATCHPAD_TMPDIR_ENV = "YAGNI_CODE_TMPDIR";
|
|
28
|
+
/**
|
|
29
|
+
* PURE: sanitize an absolute cwd into a filename-safe segment. Mirrors Claude
|
|
30
|
+
* Code's sanitizePath but without the hash suffix — the cwd segment is cosmetic
|
|
31
|
+
* grouping, not a permission identity, so an identical prefix under two long
|
|
32
|
+
* cwds is disambiguated by the sessionId one level deeper.
|
|
33
|
+
*/
|
|
34
|
+
export declare function sanitizeCwdSegment(cwd: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* PURE: the per-user scratchpad owner dir name. uid isolates multi-user systems
|
|
37
|
+
* the way Claude Code's "claude-{uid}" does, under our own prefix.
|
|
38
|
+
*/
|
|
39
|
+
export declare function scratchpadOwnerDir(uid: number): string;
|
|
40
|
+
/**
|
|
41
|
+
* PURE: the session scratchpad directory path. Returns null when there is no
|
|
42
|
+
* sessionId — a scratchpad is meaningless without a per-session key, and the
|
|
43
|
+
* prompt section is gated on a non-null result.
|
|
44
|
+
*/
|
|
45
|
+
export declare function scratchpadDir(opts?: {
|
|
46
|
+
sessionId?: string;
|
|
47
|
+
cwd?: string;
|
|
48
|
+
uid?: number;
|
|
49
|
+
tmp?: string;
|
|
50
|
+
}): string | null;
|
|
51
|
+
/**
|
|
52
|
+
* IMPURE: ensure the scratchpad dir exists (owner-only), failing soft. Returns
|
|
53
|
+
* the path on success and null on failure — a null result means "no scratchpad
|
|
54
|
+
* this session", which the caller turns into an omitted prompt section.
|
|
55
|
+
*/
|
|
56
|
+
export declare function ensureScratchpadDir(path: string, mkdir?: (p: string, o: {
|
|
57
|
+
mode: number;
|
|
58
|
+
recursive: boolean;
|
|
59
|
+
}) => void): string | null;
|
|
60
|
+
/**
|
|
61
|
+
* PURE: the prompt section naming the scratchpad. Gated by the caller on the
|
|
62
|
+
* dir existing; when present, it tells the agent where to put intermediate
|
|
63
|
+
* files instead of /tmp or the user's project.
|
|
64
|
+
*/
|
|
65
|
+
export declare function scratchpadSection(dir: string): string;
|
|
66
|
+
//# sourceMappingURL=scratchpad.d.ts.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session scratchpad — a permission-free directory the agent writes its working
|
|
3
|
+
* state to, so neither it nor the user has to reconstruct intermediate results.
|
|
4
|
+
*
|
|
5
|
+
* Ports Claude Code's scratchpad with one deliberate simplification: Claude
|
|
6
|
+
* resolves the tmp root and normalizes against path traversal because its
|
|
7
|
+
* scratchpad is an allow-listed path in a permission classifier. We have no
|
|
8
|
+
* such classifier (auto mode already passes write/edit unprompted, and plan
|
|
9
|
+
* mode's write gate holds scratchpad like every other write), so the path is
|
|
10
|
+
* built plainly and the only caller-facing contract is "the dir exists or the
|
|
11
|
+
* section is omitted."
|
|
12
|
+
*
|
|
13
|
+
* Path mirrors Claude Code's shape under our own owner namespace so the two
|
|
14
|
+
* never collide: <tmp>/yagni-{uid}/<sanitized-cwd>/<sessionId>/scratchpad/.
|
|
15
|
+
* - tmp root: YAGNI_CODE_TMPDIR, else os.tmpdir()
|
|
16
|
+
* - uid: process.getuid() ?? 0 (multi-user isolation; tmpdir() is already
|
|
17
|
+
* per-user on Windows)
|
|
18
|
+
* - sanitized-cwd: non-alphanumerics → "-", length-capped (cosmetic grouping
|
|
19
|
+
* only — sessionId is the real uniqueness key)
|
|
20
|
+
* - sessionId: env.YAGNI_SESSION_ID, minted by the launcher as a UUID; when
|
|
21
|
+
* absent (a bare pi run) no scratchpad is configured at all.
|
|
22
|
+
*
|
|
23
|
+
* PURE path/section builders are separated from the one impure mkdir so tests
|
|
24
|
+
* drive the former directly and the latter through an injectable fs seam.
|
|
25
|
+
*/
|
|
26
|
+
import { mkdirSync } from "node:fs";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
/** Env override for the scratchpad tmp root (mirrors CLAUDE_CODE_TMPDIR). */
|
|
30
|
+
export const SCRATCHPAD_TMPDIR_ENV = "YAGNI_CODE_TMPDIR";
|
|
31
|
+
/** Longest sanitized-cwd segment we keep; the sessionId carries uniqueness. */
|
|
32
|
+
const MAX_SANITIZED_CWD = 64;
|
|
33
|
+
/**
|
|
34
|
+
* PURE: sanitize an absolute cwd into a filename-safe segment. Mirrors Claude
|
|
35
|
+
* Code's sanitizePath but without the hash suffix — the cwd segment is cosmetic
|
|
36
|
+
* grouping, not a permission identity, so an identical prefix under two long
|
|
37
|
+
* cwds is disambiguated by the sessionId one level deeper.
|
|
38
|
+
*/
|
|
39
|
+
export function sanitizeCwdSegment(cwd) {
|
|
40
|
+
const sanitized = cwd.replace(/[^a-zA-Z0-9]/g, "-").replace(/^-+|-+$/g, "");
|
|
41
|
+
return sanitized.slice(0, MAX_SANITIZED_CWD) || "root";
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* PURE: the per-user scratchpad owner dir name. uid isolates multi-user systems
|
|
45
|
+
* the way Claude Code's "claude-{uid}" does, under our own prefix.
|
|
46
|
+
*/
|
|
47
|
+
export function scratchpadOwnerDir(uid) {
|
|
48
|
+
return `yagni-${uid}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* PURE: the session scratchpad directory path. Returns null when there is no
|
|
52
|
+
* sessionId — a scratchpad is meaningless without a per-session key, and the
|
|
53
|
+
* prompt section is gated on a non-null result.
|
|
54
|
+
*/
|
|
55
|
+
export function scratchpadDir(opts = {}) {
|
|
56
|
+
const sessionId = opts.sessionId?.trim();
|
|
57
|
+
if (!sessionId)
|
|
58
|
+
return null;
|
|
59
|
+
const tmp = opts.tmp ?? tmpdir();
|
|
60
|
+
const uid = opts.uid ?? (typeof process.getuid === "function" ? process.getuid() ?? 0 : 0);
|
|
61
|
+
const cwd = sanitizeCwdSegment(opts.cwd ?? ".");
|
|
62
|
+
return join(tmp, scratchpadOwnerDir(uid), cwd, sessionId, "scratchpad");
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* IMPURE: ensure the scratchpad dir exists (owner-only), failing soft. Returns
|
|
66
|
+
* the path on success and null on failure — a null result means "no scratchpad
|
|
67
|
+
* this session", which the caller turns into an omitted prompt section.
|
|
68
|
+
*/
|
|
69
|
+
export function ensureScratchpadDir(path, mkdir = mkdirSync) {
|
|
70
|
+
try {
|
|
71
|
+
mkdir(path, { recursive: true, mode: 0o700 });
|
|
72
|
+
return path;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* PURE: the prompt section naming the scratchpad. Gated by the caller on the
|
|
80
|
+
* dir existing; when present, it tells the agent where to put intermediate
|
|
81
|
+
* files instead of /tmp or the user's project.
|
|
82
|
+
*/
|
|
83
|
+
export function scratchpadSection(dir) {
|
|
84
|
+
return ("# Scratchpad directory\n\n" +
|
|
85
|
+
`Use this session scratchpad directory for files that do not belong in the user's project:\n` +
|
|
86
|
+
`${dir}\n\n` +
|
|
87
|
+
"- Store intermediate results or data during multi-step tasks.\n" +
|
|
88
|
+
"- Write temporary scripts or configuration files.\n" +
|
|
89
|
+
"- Save outputs that don't belong in the user's project.\n" +
|
|
90
|
+
"- Anything that would otherwise go to /tmp.\n\n" +
|
|
91
|
+
"The directory is session-specific and isolated from the user's project.");
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=scratchpad.js.map
|
|
@@ -74,7 +74,7 @@ You are grounded in how THIS company works: call ask_yagni before inferring a co
|
|
|
74
74
|
|
|
75
75
|
Never fabricate file paths, contents, or findings. If you cannot find something, say so.
|
|
76
76
|
|
|
77
|
-
Your final message is your report back to the driving agent, which has NOT seen what you read or did
|
|
77
|
+
Complete the task fully — do not gold-plate, but do not leave it half-done. Your final message is your report back to the driving agent, which has NOT seen what you read or did: make it a concise report of what was done and the key findings, since the caller relays it to the user and it only needs the essentials. Cover what you did, what you found, exact file paths and key excerpts, and anything the driver must know before continuing.`;
|
|
78
78
|
const GENERAL_AGENT = {
|
|
79
79
|
name: GENERAL_AGENT_NAME,
|
|
80
80
|
description: "General-purpose agent for research, multi-file changes, and self-contained tasks.",
|
|
@@ -94,6 +94,8 @@ one you actually read with a tool. If you cannot find something, say "not
|
|
|
94
94
|
found" — a plausible-sounding invention is worse than no answer because the
|
|
95
95
|
driving agent trusts your report.
|
|
96
96
|
|
|
97
|
+
Complete the task fully — do not gold-plate, but do not leave it half-done.
|
|
98
|
+
|
|
97
99
|
Your final message is your report back to the driving agent, which has NOT
|
|
98
100
|
seen what you read. Make it compressed and complete: exact file paths, the
|
|
99
101
|
key excerpts, and a one-paragraph map of how the pieces relate. Say what you
|
|
@@ -122,6 +124,8 @@ convention, an ownership rule, or anything organization-specific.
|
|
|
122
124
|
Never fabricate file paths or results. Report what you actually did and what
|
|
123
125
|
you actually found.
|
|
124
126
|
|
|
127
|
+
Complete the task fully — do not gold-plate, but do not leave it half-done.
|
|
128
|
+
|
|
125
129
|
Your final message is your report back to the driving agent, which has NOT
|
|
126
130
|
seen what you did. List every file you touched, what changed in each, the
|
|
127
131
|
commands you ran with their outcomes, and anything you deliberately left
|
|
@@ -154,6 +158,8 @@ a convention, an ownership rule, or anything organization-specific.
|
|
|
154
158
|
Never fabricate file paths or findings. If you could not verify something,
|
|
155
159
|
say exactly what you tried and why you could not.
|
|
156
160
|
|
|
161
|
+
Complete the task fully — do not gold-plate, but do not leave it half-done.
|
|
162
|
+
|
|
157
163
|
Your final message is your verdict back to the driving agent, which has NOT
|
|
158
164
|
seen what you read. Format:
|
|
159
165
|
## Verdict
|
|
@@ -105,6 +105,7 @@ export declare function makeTodoTool(get: () => TodoItem[], set: (todos: TodoIte
|
|
|
105
105
|
label: string;
|
|
106
106
|
description: string;
|
|
107
107
|
promptSnippet: string;
|
|
108
|
+
promptGuidelines: string[];
|
|
108
109
|
parameters: Type.TObject<{
|
|
109
110
|
todos: Type.TArray<Type.TObject<{
|
|
110
111
|
text: Type.TString;
|
package/dist/extension/todos.js
CHANGED
|
@@ -204,6 +204,21 @@ export function makeTodoTool(get, set) {
|
|
|
204
204
|
"in_progress at a time, mark items completed the moment they are done, and add newly " +
|
|
205
205
|
"discovered steps as pending. Use it for any task with three or more steps, updating as you go.",
|
|
206
206
|
promptSnippet: "todo_write: keep a user-visible checklist for multi-step work (full-list replacement).",
|
|
207
|
+
promptGuidelines: [
|
|
208
|
+
"Use todo_write proactively when a task needs 3 or more distinct steps, requires careful " +
|
|
209
|
+
"planning, or the user gives you a list of things (numbered or comma-separated).",
|
|
210
|
+
"Capture new instructions as todos the moment you receive them, and mark a step in_progress " +
|
|
211
|
+
"BEFORE you start working on it.",
|
|
212
|
+
"When in doubt, use it — a visible checklist answers \"is it stuck?\" without the user having " +
|
|
213
|
+
"to interrupt.",
|
|
214
|
+
"Skip it when there is only one straightforward task, the work is trivial, or the request is " +
|
|
215
|
+
"purely conversational or informational — in those cases just do the task directly.",
|
|
216
|
+
"Pass the FULL list every call; it replaces the previous one. Keep exactly ONE item in_progress " +
|
|
217
|
+
"at a time.",
|
|
218
|
+
"Mark a step completed the moment it is done (do not batch completions), and add newly " +
|
|
219
|
+
"discovered steps as pending. Only mark a step completed when it is fully done — if tests " +
|
|
220
|
+
"fail or work is partial, leave it in_progress and add a new step for the blocker.",
|
|
221
|
+
],
|
|
207
222
|
parameters,
|
|
208
223
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
209
224
|
const normalized = normalizeTodos(params.todos);
|
package/dist/goHeadless.d.ts
CHANGED
|
@@ -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
|
package/dist/goHeadless.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.0.0-staging.1183.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": "
|
|
44
|
+
"yagniSourceSha": "723499f455c038162f0ddb3bc886e7e3c55b07f5"
|
|
44
45
|
}
|