@yagni-app/code 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +30 -6
  2. package/dist/claudePlugins.d.ts +3 -1
  3. package/dist/claudePlugins.js +3 -1
  4. package/dist/cli.js +12 -0
  5. package/dist/doctor.d.ts +28 -3
  6. package/dist/doctor.js +117 -7
  7. package/dist/extension/index.d.ts +5 -5
  8. package/dist/extension/index.js +94 -31
  9. package/dist/extension/mcp/approval.d.ts +45 -0
  10. package/dist/extension/mcp/approval.js +164 -0
  11. package/dist/extension/mcp/auth.d.ts +124 -0
  12. package/dist/extension/mcp/auth.js +560 -0
  13. package/dist/extension/mcp/authStore.d.ts +61 -0
  14. package/dist/extension/mcp/authStore.js +105 -0
  15. package/dist/extension/mcp/callbackPage.d.ts +31 -0
  16. package/dist/extension/mcp/callbackPage.js +222 -0
  17. package/dist/extension/mcp/cliConfig.d.ts +12 -0
  18. package/dist/extension/mcp/cliConfig.js +12 -0
  19. package/dist/extension/mcp/config.d.ts +131 -0
  20. package/dist/extension/mcp/config.js +309 -0
  21. package/dist/extension/mcp/log.d.ts +28 -0
  22. package/dist/extension/mcp/log.js +82 -0
  23. package/dist/extension/mcp/manager.d.ts +98 -0
  24. package/dist/extension/mcp/manager.js +273 -0
  25. package/dist/extension/mcp/names.d.ts +25 -0
  26. package/dist/extension/mcp/names.js +40 -0
  27. package/dist/extension/mcp/panel.d.ts +34 -0
  28. package/dist/extension/mcp/panel.js +258 -0
  29. package/dist/extension/mcp/prompts.d.ts +23 -0
  30. package/dist/extension/mcp/prompts.js +93 -0
  31. package/dist/extension/mcp/startup.d.ts +55 -0
  32. package/dist/extension/mcp/startup.js +150 -0
  33. package/dist/extension/mcp/tools.d.ts +31 -0
  34. package/dist/extension/mcp/tools.js +117 -0
  35. package/dist/extension/mcp/transports.d.ts +17 -0
  36. package/dist/extension/mcp/transports.js +44 -0
  37. package/dist/extension/permission/execPolicy.js +17 -2
  38. package/dist/extension/permission/gate.d.ts +7 -0
  39. package/dist/extension/permission/gate.js +12 -5
  40. package/dist/extension/permission/guardian.d.ts +24 -5
  41. package/dist/extension/permission/guardian.js +162 -24
  42. package/dist/extension/pipeline/personas.js +5 -0
  43. package/dist/mcpCommand.d.ts +113 -0
  44. package/dist/mcpCommand.js +755 -0
  45. package/dist/otel.d.ts +36 -7
  46. package/dist/otel.js +90 -12
  47. package/dist/upgrade.d.ts +11 -2
  48. package/dist/upgrade.js +48 -8
  49. package/package.json +3 -2
  50. package/dist/extension/mcpTools.d.ts +0 -57
  51. package/dist/extension/mcpTools.js +0 -132
package/README.md CHANGED
@@ -179,11 +179,16 @@ children and subagents — emits a per-prompt span tree: interaction → LLM
179
179
  request → tool calls, following the OTel GenAI semantic conventions. Nothing is
180
180
  exported unless you configure an endpoint.
181
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
182
+ Enable it one of four ways (first match wins):
183
+
184
+ - `OTEL_EXPORTER_OTLP_ENDPOINT=…` in the environment (a personal override —
185
+ handy for pointing one session at a scratch collector), or
186
+ - `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=…` in the environment — the per-signal
187
+ form Claude Code setups use (e.g.
188
+ `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://otlp.datadoghq.com/v1/traces`
189
+ with `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL=http/protobuf`); the launcher
190
+ bridges it to the generic var pi-otel reads, so a Claude Code OTel env
191
+ lights up YAGNI Code traces with no extra config, or
187
192
  - **workspace settings** (the zero-setup path): a workspace admin sets the
188
193
  endpoint, protocol, and any collector headers (e.g. a Datadog API key) once
189
194
  in the web app under Settings → YAGNI Code → Trace export. Every session in
@@ -197,7 +202,26 @@ Enable it one of three ways (first match wins):
197
202
  Standard OTel env vars are honored (`OTEL_EXPORTER_OTLP_PROTOCOL`,
198
203
  `OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_SERVICE_NAME` — defaults to `yagni-code`),
199
204
  and `PI_OTEL_DISABLED=1` is the kill switch. `yagni doctor` shows the current
200
- export state.
205
+ export state and probes the endpoint live (unreachable → a warning with a
206
+ hint, never an exit-code flip).
207
+
208
+ Two recipes for common collectors:
209
+
210
+ - **Datadog, direct SaaS (no agent):** endpoint
211
+ `https://otlp.datadoghq.com/v1/traces` (US1; other sites have their own
212
+ host — `otlp.us3.datadoghq.com`, `otlp.us5.datadoghq.com`,
213
+ `otlp.datadoghq.eu`, `otlp.ap1.datadoghq.com`), protocol `http/protobuf`
214
+ (Datadog's direct trace intake does not accept gRPC), header
215
+ `dd-api-key=<your Datadog API key>`. Use the full path, not `/v1/` — the
216
+ endpoint is passed through verbatim, no path is appended for you.
217
+ - **Datadog Agent:** endpoint `http://<agent-host>:4317`, protocol `grpc`, no
218
+ key (the agent forwards with its own). OTLP ingest ships in the agent but
219
+ is off by default — enable it with
220
+ `otlp_config.receiver.protocols.grpc.endpoint: 0.0.0.0:4317` in
221
+ `datadog.yaml`.
222
+ - **Grafana Cloud:** endpoint `https://otlp-gateway-prod-…-0.grafana.net:443/otlp`,
223
+ protocol `http/protobuf`, header `authorization=<the raw MTgx… value>` (the
224
+ `Basic ` scheme is added for you).
201
225
 
202
226
  Two things are enforced and not configurable:
203
227
 
@@ -18,7 +18,9 @@
18
18
  * disabled via `enabledPlugins`.
19
19
  *
20
20
  * Deliberately NOT here: network installation of any kind, plugin hooks, MCP
21
- * servers, LSP servers, themes, output styles. Discovery is read-only.
21
+ * servers (standard MCP support lives in `src/mcpCommand.ts` + the extension's
22
+ * `mcp/` module — see `yagni mcp --help`), LSP servers, themes, output styles.
23
+ * Discovery is read-only.
22
24
  *
23
25
  * Everything is fail-soft: malformed JSON, missing dirs, or hostile path
24
26
  * entries degrade to "that plugin absent" — never a failed launch. Path
@@ -18,7 +18,9 @@
18
18
  * disabled via `enabledPlugins`.
19
19
  *
20
20
  * Deliberately NOT here: network installation of any kind, plugin hooks, MCP
21
- * servers, LSP servers, themes, output styles. Discovery is read-only.
21
+ * servers (standard MCP support lives in `src/mcpCommand.ts` + the extension's
22
+ * `mcp/` module — see `yagni mcp --help`), LSP servers, themes, output styles.
23
+ * Discovery is read-only.
22
24
  *
23
25
  * Everything is fail-soft: malformed JSON, missing dirs, or hostile path
24
26
  * entries degrade to "that plugin absent" — never a failed launch. Path
package/dist/cli.js CHANGED
@@ -23,6 +23,7 @@ import { agentDir, credentialsDir, piPackageDir } from "./credentials.js";
23
23
  import { DISTRIBUTION } from "./distribution.js";
24
24
  import { connectCommand } from "./connectClaudeCode.js";
25
25
  import { goCommand } from "./goHeadless.js";
26
+ import { mcpCommand } from "./mcpCommand.js";
26
27
  import { login } from "./login.js";
27
28
  import { logout } from "./logout.js";
28
29
  import { tokenCommand } from "./token.js";
@@ -515,6 +516,10 @@ export const HELP_TEXT = [
515
516
  " yagni connect claude-code Route Claude Code through the YAGNI model proxy",
516
517
  " (--project scopes to this repo; --off disconnects).",
517
518
  " yagni connect codex Route Codex CLI through the YAGNI model proxy.",
519
+ " yagni mcp <subcommand> Manage MCP servers: add, remove, list, get,",
520
+ " add-from-claude, and more — run `yagni mcp` for",
521
+ " details. OAuth servers authenticate via the",
522
+ " /mcp panel in a session.",
518
523
  " yagni token Output the active environment's API token (for helpers).",
519
524
  " yagni use <name> Switch the active environment (sticky).",
520
525
  " Presets: prod, local. Others need --base-url <url>.",
@@ -536,6 +541,7 @@ export const HELP_TEXT = [
536
541
  " /mode plan|review|auto Hold writes for planning, or confirm each change.",
537
542
  " /todos Show the agent's live task list.",
538
543
  " /cost Session usage and credit headroom.",
544
+ " /mcp Manage MCP servers; authenticate OAuth servers.",
539
545
  "",
540
546
  "The active environment is sticky; `use` switches it (prod is the default).",
541
547
  "Set YAGNI_BASE_URL to override the base URL for a single run.",
@@ -653,6 +659,12 @@ export async function main(argv) {
653
659
  if (command === "connect") {
654
660
  return connectCommand(rest);
655
661
  }
662
+ // MCP server configuration: same three-scope model and file homes the
663
+ // in-session /mcp panel uses, shared code via the bundled extension's
664
+ // config entry. `list` runs real health-check connections.
665
+ if (command === "mcp") {
666
+ return mcpCommand(rest);
667
+ }
656
668
  // The headless pipeline entry. `go` is a real subcommand, not passthrough:
657
669
  // the interactive run stays `/go` inside a session, and a `yagni go` without
658
670
  // --headless is refused with usage rather than launched as an agent prompt.
package/dist/doctor.d.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * Advisory checks (loose perms, missing `gh`) never flip the exit code.
14
14
  */
15
15
  import { type TokenExpiryStatus } from "./launch.js";
16
- import { type OtelLaunchConfig } from "./otel.js";
16
+ import { probeOtelEndpoint, type OtelLaunchConfig } from "./otel.js";
17
17
  import { type Profile } from "./profiles.js";
18
18
  export type CheckStatus = "ok" | "warn" | "fail";
19
19
  export interface CheckResult {
@@ -63,10 +63,33 @@ export declare function checkCliUpdate(probe: {
63
63
  export declare function checkGh(onPath: boolean): CheckResult;
64
64
  /**
65
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
66
+ * OTLP collector, from which config source, and when a collector is
67
+ * configured — whether the endpoint answers TCP right now (scheme-default
68
+ * ports included: https with no port probes 443). Never flips the exit code —
67
69
  * most machines have no collector, and that is the healthy default.
68
70
  */
69
- export declare function checkOtelExport(config: OtelLaunchConfig | undefined): CheckResult;
71
+ export declare function checkOtelExport(config: OtelLaunchConfig | undefined, deps?: {
72
+ probeEndpoint?: typeof probeOtelEndpoint;
73
+ probeTimeoutMs?: number;
74
+ }): Promise<CheckResult>;
75
+ /** Config-only MCP snapshot (no connections — health checks live in `mcp list`/`get`). */
76
+ export interface McpProbe {
77
+ /** YAGNI_CODE_MCP_DISABLED kill-switch is active. */
78
+ disabled: boolean;
79
+ /** Count of configured servers (post-merge, before the approval gate). */
80
+ serverCount: number;
81
+ /** Project-scope servers that are neither enabled nor disabled. */
82
+ undecidedProjectServers: string[];
83
+ /** Config parse/validation errors (already human-readable). */
84
+ errors: string[];
85
+ }
86
+ /**
87
+ * Advisory (never required) MCP line: reports config health without connecting
88
+ * to anything — `yagni mcp list`/`get` already run the real health checks, and
89
+ * doctor must never spawn an unapproved server process. The kill-switch and
90
+ * pending-approval cases read as `warn`, config errors as `fail` (non-required).
91
+ */
92
+ export declare function checkMcpConfig(probe: McpProbe): CheckResult;
70
93
  /** What the Windows bash probe found (pi needs a bash — Git Bash — on win32). */
71
94
  export interface BashProbe {
72
95
  found: boolean;
@@ -95,6 +118,8 @@ export interface DoctorDeps {
95
118
  probeBash?: () => BashProbe;
96
119
  currentVersion?: string;
97
120
  probeLatestVersion?: () => Promise<string | null>;
121
+ /** MCP config snapshot (config-only, no connections). */
122
+ probeMcp?: () => Promise<McpProbe>;
98
123
  log?: (msg: string) => void;
99
124
  }
100
125
  /** Whether a `gh` executable is resolvable on PATH (no subprocess spawn). */
package/dist/doctor.js CHANGED
@@ -17,9 +17,10 @@ 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
+ import { probeOtelEndpoint, resolveOtelLaunchWithWorkspace, } from "./otel.js";
21
21
  import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir } from "./paths.js";
22
22
  import { readActiveProfile } from "./profiles.js";
23
+ import { resolveMcpConfigPath } from "./mcpCommand.js";
23
24
  // ── Pure check builders ─────────────────────────────────────────────────────
24
25
  export function checkPiEngine(probe) {
25
26
  if (!probe.binPath || !probe.binExists) {
@@ -201,10 +202,12 @@ export function checkGh(onPath) {
201
202
  }
202
203
  /**
203
204
  * 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
+ * OTLP collector, from which config source, and when a collector is
206
+ * configured — whether the endpoint answers TCP right now (scheme-default
207
+ * ports included: https with no port probes 443). Never flips the exit code —
205
208
  * most machines have no collector, and that is the healthy default.
206
209
  */
207
- export function checkOtelExport(config) {
210
+ export async function checkOtelExport(config, deps = {}) {
208
211
  if (!config) {
209
212
  return {
210
213
  name: "otel export (optional)",
@@ -214,14 +217,88 @@ export function checkOtelExport(config) {
214
217
  };
215
218
  }
216
219
  const source = config.source === "env"
217
- ? "OTEL_EXPORTER_OTLP_ENDPOINT"
220
+ ? (config.envVar ?? "OTEL_EXPORTER_OTLP_ENDPOINT")
218
221
  : config.source === "workspace"
219
222
  ? "workspace settings"
220
223
  : ".pi/settings.json";
224
+ const base = `on → ${config.endpoint} (${source}, metadata-only)`;
225
+ const doProbe = deps.probeEndpoint ?? probeOtelEndpoint;
226
+ try {
227
+ const reachable = await doProbe(config.endpoint, deps.probeTimeoutMs ?? 1_000);
228
+ return reachable
229
+ ? {
230
+ name: "otel export (optional)",
231
+ status: "ok",
232
+ detail: `${base}, TCP reachable`,
233
+ required: false,
234
+ }
235
+ : {
236
+ name: "otel export (optional)",
237
+ status: "warn",
238
+ detail: `${base}, endpoint not reachable`,
239
+ hint: "the collector is down, unreachable from this machine, or the URL is wrong — traces will not export until it answers",
240
+ required: false,
241
+ };
242
+ }
243
+ catch {
244
+ // The probe itself errored (not "endpoint down" — the check could not
245
+ // run). Distinct from a verified-healthy line so nobody reads an
246
+ // unverified config as checked-and-good.
247
+ return {
248
+ name: "otel export (optional)",
249
+ status: "warn",
250
+ detail: `${base}, reachability probe failed`,
251
+ hint: "the reachability check could not run (network error) — export is configured but unverified; traces may still export normally",
252
+ required: false,
253
+ };
254
+ }
255
+ }
256
+ /**
257
+ * Advisory (never required) MCP line: reports config health without connecting
258
+ * to anything — `yagni mcp list`/`get` already run the real health checks, and
259
+ * doctor must never spawn an unapproved server process. The kill-switch and
260
+ * pending-approval cases read as `warn`, config errors as `fail` (non-required).
261
+ */
262
+ export function checkMcpConfig(probe) {
263
+ if (probe.disabled) {
264
+ return {
265
+ name: "mcp servers",
266
+ status: "warn",
267
+ detail: "disabled (YAGNI_CODE_MCP_DISABLED=1)",
268
+ hint: "unset YAGNI_CODE_MCP_DISABLED to re-enable MCP servers",
269
+ required: false,
270
+ };
271
+ }
272
+ if (probe.errors.length > 0) {
273
+ return {
274
+ name: "mcp servers",
275
+ status: "fail",
276
+ detail: `${probe.serverCount} configured, ${probe.errors.length} config error${probe.errors.length === 1 ? "" : "s"}`,
277
+ hint: `run \`yagni mcp list\` (first error: ${probe.errors[0]})`,
278
+ required: false,
279
+ };
280
+ }
281
+ if (probe.serverCount === 0) {
282
+ return {
283
+ name: "mcp servers",
284
+ status: "ok",
285
+ detail: "none configured",
286
+ required: false,
287
+ };
288
+ }
289
+ if (probe.undecidedProjectServers.length > 0) {
290
+ return {
291
+ name: "mcp servers",
292
+ status: "warn",
293
+ detail: `${probe.serverCount} configured, ${probe.undecidedProjectServers.length} awaiting approval`,
294
+ hint: "run /mcp to approve the project servers",
295
+ required: false,
296
+ };
297
+ }
221
298
  return {
222
- name: "otel export (optional)",
299
+ name: "mcp servers",
223
300
  status: "ok",
224
- detail: `on → ${config.endpoint} (${source}, metadata-only)`,
301
+ detail: `${probe.serverCount} configured`,
225
302
  required: false,
226
303
  };
227
304
  }
@@ -312,6 +389,37 @@ function defaultProbeStateDir() {
312
389
  return { path, exists: false, mode: null };
313
390
  }
314
391
  }
392
+ async function defaultProbeMcp(env = process.env) {
393
+ const disabled = env.YAGNI_CODE_MCP_DISABLED === "1";
394
+ if (disabled)
395
+ return { disabled: true, serverCount: 0, undecidedProjectServers: [], errors: [] };
396
+ try {
397
+ const mod = (await import(resolveMcpConfigPath()));
398
+ if (typeof mod?.loadMcpServers !== "function") {
399
+ return { disabled: false, serverCount: 0, undecidedProjectServers: [], errors: ["extension MCP config entry missing"] };
400
+ }
401
+ const loaded = mod.loadMcpServers(process.cwd(), env);
402
+ const repoRoot = mod.resolveProjectRoot(process.cwd());
403
+ const { state } = mod.readProjectApproval(repoRoot);
404
+ const undecided = loaded.servers
405
+ .filter((s) => s.scope === "project" && mod.decisionFor(state, s.name) === "undecided")
406
+ .map((s) => s.name);
407
+ return {
408
+ disabled: false,
409
+ serverCount: loaded.servers.length,
410
+ undecidedProjectServers: undecided,
411
+ errors: loaded.errors.map((e) => (e.serverName ? `${e.serverName}: ${e.message}` : e.message)),
412
+ };
413
+ }
414
+ catch (err) {
415
+ return {
416
+ disabled: false,
417
+ serverCount: 0,
418
+ undecidedProjectServers: [],
419
+ errors: [err instanceof Error ? err.message : String(err)],
420
+ };
421
+ }
422
+ }
315
423
  async function defaultProbeBackend(baseUrl, token) {
316
424
  if (!token)
317
425
  return { kind: "skipped" };
@@ -378,6 +486,7 @@ export async function gatherChecks(deps = {}) {
378
486
  const readProfile = deps.readActiveProfile ?? readActiveProfile;
379
487
  const probeBackend = deps.probeBackend ?? defaultProbeBackend;
380
488
  const probeStateDir = deps.probeStateDir ?? defaultProbeStateDir;
489
+ const probeMcp = deps.probeMcp ?? (() => defaultProbeMcp());
381
490
  const ghOnPath = deps.ghOnPath ?? (() => ghOnPathDefault());
382
491
  const platform = deps.platform ?? process.platform;
383
492
  const probeBash = deps.probeBash ?? (() => bashOnWindowsDefault());
@@ -406,7 +515,8 @@ export async function gatherChecks(deps = {}) {
406
515
  checks.push(checkBackend(backend));
407
516
  checks.push(checkStateDir(probeStateDir()));
408
517
  checks.push(checkGh(ghOnPath()));
409
- checks.push(checkOtelExport(await resolveOtelLaunchWithWorkspace({
518
+ checks.push(checkMcpConfig(await probeMcp()));
519
+ checks.push(await checkOtelExport(await resolveOtelLaunchWithWorkspace({
410
520
  env: process.env,
411
521
  cwd: process.cwd(),
412
522
  creds: profile.token ? { baseUrl: profile.baseUrl, token: profile.token } : null,
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { type SpendResponse } from "./costHud.js";
3
3
  import { runInitPass as defaultRunInitPass } from "./initPass.js";
4
- import { fetchMcpServers as defaultFetchMcpServers } from "./mcpTools.js";
4
+ import { startMcp as defaultStartMcp } from "./mcp/startup.js";
5
5
  import { type FlushOutcome, type SpoolClientOpts } from "./spool.js";
6
6
  import { type TokenProvider } from "./tokenProvider.js";
7
7
  import { type CatalogResult, type ContextBrief } from "./config.js";
@@ -53,11 +53,11 @@ export interface RegisterYagniDeps {
53
53
  /** The beat's once-per-repo marker store. Injectable (no disk in tests). */
54
54
  mineBeatMarkers?: MineBeatMarkers;
55
55
  /**
56
- * Workspace MCP server list seam (YAG-446). The default hits
57
- * `/api/yagni-code/mcp/servers` and is fail-soft: older backends and
58
- * un-rescoped tokens yield `[]`, never a startup error.
56
+ * Local-MCP startup seam: injectable so tests assert the wiring (approval
57
+ * skip, kill-switch, mutating-tool gate) without touching real config
58
+ * homes or spawning servers. Default runs the real startup.
59
59
  */
60
- fetchMcpServers?: typeof defaultFetchMcpServers;
60
+ startMcp?: typeof defaultStartMcp;
61
61
  /**
62
62
  * The CLI init pass (Onramp Door B). Injectable so tests assert it fires on a
63
63
  * fresh-workspace first-run and is skipped otherwise, without a network or disk.
@@ -29,7 +29,8 @@ import { createYagniFooterFactory, cyclePermissionMode, formatCwd, GIT_MUTATING_
29
29
  import { RerouteNotifier } from "./rerouteNotice.js";
30
30
  import { isFreshWorkspace, registerTeamSetupCommand, runInitPass as defaultRunInitPass } from "./initPass.js";
31
31
  import { isInitDone as defaultIsInitDone, markInitDone as defaultMarkInitDone } from "./initDone.js";
32
- import { fetchMcpServers as defaultFetchMcpServers, registerMcpCommand, registerMcpTools, } from "./mcpTools.js";
32
+ import { looksMutating } from "./mcp/tools.js";
33
+ import { startMcp as defaultStartMcp, wireShutdown, deriveStartupConnectivityNotices } from "./mcp/startup.js";
33
34
  import { registerGoCommand } from "./pipeline/goCommand.js";
34
35
  import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
35
36
  import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission/gate.js";
@@ -293,16 +294,65 @@ export async function registerYagni(pi, deps = {}) {
293
294
  const decisionCapture = evalMode ? undefined : makeDecisionCapture(decisionClientOpts);
294
295
  if (!evalMode)
295
296
  registerDecisionCommands(pi, decisionClientOpts);
296
- // Workspace MCP servers (YAG-446): register one tool per enabled server
297
- // tool, executing through the backend proxy, plus the /mcp listing
298
- // command. Fail-soft fetch (older backend / un-rescoped token no MCP
299
- // tools); skipped in eval mode like the other external-side-effect tools.
300
- const fetchMcp = deps.fetchMcpServers ?? defaultFetchMcpServers;
301
- const mcpServers = evalMode
302
- ? []
303
- : await fetchMcp({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
304
- const { mutatingToolNames: mcpMutatingTools } = registerMcpTools(pi, mcpServers, { baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
305
- registerMcpCommand(pi, mcpServers, { baseUrl });
297
+ // Local MCP servers (standard client, replaces the backend-proxy /mcp of
298
+ // the YAG-446 era): load user/project/local config, approval-gate project
299
+ // servers, connect, register one tool per server tool plus /mcp panel.
300
+ // Fail-soft everywhere; skipped entirely in eval mode (external side
301
+ // effects) and under the YAGNI_CODE_MCP_DISABLED kill-switch.
302
+ const startMcpImpl = deps.startMcp ?? defaultStartMcp;
303
+ // One-shot notices queued by startMcp at session start (the `.mcp.json`
304
+ // approval prompt); flushed once on the first provider response. Connectivity
305
+ // notices are NOT here they are recomputed live at flush (see below).
306
+ const mcpApprovalNotices = [];
307
+ const mcpOutcome = evalMode
308
+ ? { manager: undefined, mutatingToolNames: [] }
309
+ : await startMcpImpl(pi, {
310
+ cwd: process.cwd(),
311
+ env,
312
+ hasUI: true,
313
+ notify: (msg) => mcpApprovalNotices.push(msg),
314
+ });
315
+ const mcpMutatingSet = new Set(mcpOutcome.mutatingToolNames);
316
+ // Wire names carry the original tool name after the second `__`: parse it
317
+ // out and re-run the heuristic for tools registered after startup (a
318
+ // reconnect can re-register tools the original classification never saw).
319
+ const mcpStartupIsMutating = (wireName) => {
320
+ const parts = wireName.split("__");
321
+ const original = parts.slice(2).join("__");
322
+ if (!original)
323
+ return false;
324
+ return looksMutating(original);
325
+ };
326
+ if (!evalMode && mcpOutcome.manager) {
327
+ wireShutdown(pi, mcpOutcome.manager);
328
+ // Startup notify (documented CC deviation): one quiet line per notice on
329
+ // the first provider response — never raw stdout, never blocking.
330
+ //
331
+ // Connectivity notices are recomputed against LIVE manager state at flush
332
+ // time (not the frozen startMcp snapshot): if the user healed a server via
333
+ // /mcp before the first turn, it must no longer read as broken/unauth.
334
+ // The static queue only carries non-connectivity notices (e.g. the
335
+ // `.mcp.json` approval prompt), which stay one-shot.
336
+ let connectivityNoticesFlushed = false;
337
+ pi.on("after_provider_response", (_event, ctx) => {
338
+ try {
339
+ if (!ctx.hasUI)
340
+ return;
341
+ const connectivity = connectivityNoticesFlushed
342
+ ? []
343
+ : deriveStartupConnectivityNotices(mcpOutcome.manager);
344
+ connectivityNoticesFlushed = true;
345
+ for (const notice of connectivity)
346
+ ctx.ui.notify(notice, "info");
347
+ while (mcpApprovalNotices.length > 0) {
348
+ ctx.ui.notify(mcpApprovalNotices.shift(), "info");
349
+ }
350
+ }
351
+ catch {
352
+ // A notice must never break a turn.
353
+ }
354
+ });
355
+ }
306
356
  // P3 + W4: interactive permission tiers + plan mode via /mode, now with a
307
357
  // session bless store (three-way review-mode select) and a capture hook that
308
358
  // drafts a decision on "don't ask again". Default auto, so still additive.
@@ -336,12 +386,18 @@ export async function registerYagni(pi, deps = {}) {
336
386
  const guardianLogSink = (payload) => {
337
387
  const event = typeof payload.event === "string" ? payload.event : "guardian_event";
338
388
  const { event: _ignored, ...fields } = payload;
389
+ // `malformed` (repair failed — still really failing) and a failed
390
+ // telemetry POST are errors. `repaired: true` (the ladder salvaged the
391
+ // verdict) is a warn — it worked, but we keep counting it. Everything
392
+ // else is routine info.
393
+ const level = event === "guardian_event_post_failed" || payload.outcome === "malformed"
394
+ ? "error"
395
+ : payload.repaired === true && payload.outcome !== undefined
396
+ ? "warn"
397
+ : "info";
339
398
  logEvent({
340
399
  source: "guardian",
341
- // `malformed` is a real failure (this is the "unclear verdict" bug the
342
- // whole capture exists to diagnose) — an error, not routine info. The
343
- // failed-telemetry event is already an error.
344
- level: event === "guardian_event_post_failed" || payload.outcome === "malformed" ? "error" : "info",
400
+ level,
345
401
  event,
346
402
  sessionId: env.YAGNI_SESSION_ID ?? undefined,
347
403
  fields,
@@ -369,15 +425,20 @@ export async function registerYagni(pi, deps = {}) {
369
425
  guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
370
426
  onGuardianReview: (ev) => {
371
427
  guardianLogSink(ev);
372
- // A malformed verdict is a real failure on our side that must reach
373
- // Sentry for EVERY workspace (independent of the opt-in storage tier).
374
- // Fire-and-forget to the dedicated telemetry endpoint; fail-soft, and
428
+ // A malformed verdict (repair FAILED still really failing) is a real
429
+ // failure on our side that must reach Sentry for EVERY workspace
430
+ // (independent of the opt-in storage tier). A REPAIRED verdict (repair
431
+ // salvaged it) posts too, with outcome "malformed_repaired" so the
432
+ // backend can count salvage hits on their own Sentry issue. Both
433
+ // fire-and-forget to the dedicated telemetry endpoint; fail-soft, and
375
434
  // suppressed under test/eval so a CI run never phones prod.
376
- if (ev.outcome === "malformed" && !evalMode && !runningUnderTest(env)) {
435
+ const isMalformed = ev.outcome === "malformed";
436
+ const isRepaired = ev.outcome !== "malformed" && ev.repaired === true;
437
+ if ((isMalformed || isRepaired) && !evalMode && !runningUnderTest(env)) {
377
438
  void (async () => {
378
439
  try {
379
440
  const body = {
380
- outcome: ev.outcome,
441
+ outcome: isRepaired ? "malformed_repaired" : ev.outcome,
381
442
  ...(ev.rawOutput ? { rawOutput: ev.rawOutput } : {}),
382
443
  ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
383
444
  ...(ev.tier ? { tier: ev.tier } : {}),
@@ -461,18 +522,17 @@ export async function registerYagni(pi, deps = {}) {
461
522
  }
462
523
  })();
463
524
  },
464
- ...(mcpMutatingTools.length > 0
525
+ ...(mcpMutatingSet.size > 0 || mcpOutcome.manager
465
526
  ? {
466
527
  policy: {
467
- planBlockTools: [
468
- ...DEFAULT_PERMISSION_POLICY.planBlockTools,
469
- ...mcpMutatingTools,
470
- ],
471
- reviewConfirmTools: [
472
- ...DEFAULT_PERMISSION_POLICY.reviewConfirmTools,
473
- ...mcpMutatingTools,
474
- ],
528
+ planBlockTools: [...DEFAULT_PERMISSION_POLICY.planBlockTools],
529
+ reviewConfirmTools: [...DEFAULT_PERMISSION_POLICY.reviewConfirmTools],
475
530
  alwaysConfirmTools: DEFAULT_PERMISSION_POLICY.alwaysConfirmTools,
531
+ // Predicate (not a static spread): matches any registered
532
+ // mcp__* tool that startup classified as mutating, including
533
+ // tools re-registered after a /mcp reconnect.
534
+ isMutating: (toolName) => mcpMutatingSet.has(toolName) ||
535
+ (toolName.startsWith("mcp__") && mcpStartupIsMutating(toolName)),
476
536
  },
477
537
  }
478
538
  : {}),
@@ -885,9 +945,12 @@ export async function registerYagni(pi, deps = {}) {
885
945
  // Surface Pi's native find/grep/ls on the driver. These are Pi's
886
946
  // equivalents of Claude Code's Glob/Grep/LS; the /go stages and subagents
887
947
  // already pass them explicitly, but the driver defaults to read/bash/edit/write
888
- // and otherwise reaches for `bash` find/grep/ls.
948
+ // and otherwise reaches for `bash` find/grep/ls. Dedupe against the active
949
+ // set — condensedTools re-registers these built-ins, so they can already be
950
+ // active here, and a duplicated name in the request's tool list is a hard
951
+ // 400 on some providers (Baseten: "Tool names must be unique.").
889
952
  try {
890
- pi.setActiveTools([...pi.getActiveTools(), "grep", "find", "ls"]);
953
+ pi.setActiveTools([...new Set([...pi.getActiveTools(), "grep", "find", "ls"])]);
891
954
  }
892
955
  catch {
893
956
  // Tool enrichment must never break session start.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Approval gate for project-scope `.mcp.json` servers, mirroring Claude Code's
3
+ * decision shape: a `.mcp.json` in the repo (shared, VCS-controlled) cannot
4
+ * spawn processes until the user has approved it. Choices live in the
5
+ * per-project entry of `~/.yagni-code/mcp.json` under Claude Code's exact key
6
+ * names (`enabledMcpjsonServers` / `disabledMcpjsonServers` /
7
+ * `enableAllProjectMcpServers`), so a decision made in one tool is respected
8
+ * by the other.
9
+ */
10
+ import { McpConfigError, UserMcpProjectEntry } from "./config.js";
11
+ export type ProjectApprovalDecision = "enabled" | "disabled" | "undecided";
12
+ export interface ProjectApprovalState {
13
+ enabledMcpjsonServers: string[];
14
+ disabledMcpjsonServers: string[];
15
+ enableAllProjectMcpServers: boolean;
16
+ }
17
+ /** Read the approval state for one project (abs path). Missing file/entry → fresh state. */
18
+ export declare function readProjectApproval(repoRoot: string): {
19
+ state: ProjectApprovalState;
20
+ errors: McpConfigError[];
21
+ };
22
+ /** Effective decision for one project-scope server. Undecided by default. */
23
+ export declare function decisionFor(state: ProjectApprovalState, serverName: string): ProjectApprovalDecision;
24
+ /** Which configured project servers await a decision (drives the session-start prompt). */
25
+ export declare function undecidedProjectServers(state: ProjectApprovalState, projectServerNames: string[]): string[];
26
+ /**
27
+ * Record a decision. `enableAll: true` mirrors Claude Code's "Yes, approve all
28
+ * project servers in this repo" (sets the blanket flag); a single enable/disable
29
+ * appends to the corresponding list. Idempotent.
30
+ */
31
+ export declare function recordProjectDecision(repoRoot: string, serverName: string | "all", decision: "enabled" | "disabled"): {
32
+ errors: McpConfigError[];
33
+ };
34
+ /** Clear this project's `.mcp.json` decisions (`yagni mcp reset-project-choices`). */
35
+ export declare function resetProjectChoices(repoRoot: string): {
36
+ errors: McpConfigError[];
37
+ };
38
+ /**
39
+ * Mutate `~/.yagni-code/mcp.json`'s per-project entry under a read-modify-write
40
+ * helper shared with CLI commands (add/remove at local scope).
41
+ */
42
+ export declare function updateProjectEntry(repoRoot: string, mutate: (entry: UserMcpProjectEntry) => void): {
43
+ errors: McpConfigError[];
44
+ };
45
+ //# sourceMappingURL=approval.d.ts.map