@yagni-app/code 1.0.6 → 1.0.8

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 (92) hide show
  1. package/README.md +91 -46
  2. package/dist/claudeCompat.d.ts +59 -0
  3. package/dist/claudeCompat.js +109 -2
  4. package/dist/claudePlugins.d.ts +45 -5
  5. package/dist/claudePlugins.js +129 -21
  6. package/dist/cli.js +16 -4
  7. package/dist/doctor.d.ts +21 -8
  8. package/dist/doctor.js +53 -28
  9. package/dist/extension/askUserQuestionTool.js +7 -2
  10. package/dist/extension/condensedTools.d.ts +12 -1
  11. package/dist/extension/condensedTools.js +17 -9
  12. package/dist/extension/config.d.ts +6 -0
  13. package/dist/extension/hooks.d.ts +3 -3
  14. package/dist/extension/hooks.js +30 -5
  15. package/dist/extension/index.d.ts +19 -0
  16. package/dist/extension/index.js +248 -43
  17. package/dist/extension/mcp/cliConfig.d.ts +1 -1
  18. package/dist/extension/mcp/cliConfig.js +1 -1
  19. package/dist/extension/mcp/config.d.ts +24 -2
  20. package/dist/extension/mcp/config.js +75 -3
  21. package/dist/extension/mcp/manager.d.ts +3 -1
  22. package/dist/extension/mcp/manager.js +2 -2
  23. package/dist/extension/mcp/panel.d.ts +0 -1
  24. package/dist/extension/mcp/panel.js +13 -3
  25. package/dist/extension/mcp/startup.js +8 -6
  26. package/dist/extension/permission/gate.d.ts +101 -2
  27. package/dist/extension/permission/gate.js +485 -42
  28. package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
  29. package/dist/extension/permissionRules/bashFileArgs.js +236 -0
  30. package/dist/extension/permissionRules/engine.d.ts +50 -0
  31. package/dist/extension/permissionRules/engine.js +238 -0
  32. package/dist/extension/permissionRules/loadConfig.d.ts +64 -0
  33. package/dist/extension/permissionRules/loadConfig.js +105 -0
  34. package/dist/extension/permissionRules/parser.d.ts +38 -0
  35. package/dist/extension/permissionRules/parser.js +136 -0
  36. package/dist/extension/permissionRules/pathRules.d.ts +60 -0
  37. package/dist/extension/permissionRules/pathRules.js +122 -0
  38. package/dist/extension/permissionRules/shellRules.d.ts +52 -0
  39. package/dist/extension/permissionRules/shellRules.js +221 -0
  40. package/dist/extension/pipeline/invocation.d.ts +3 -6
  41. package/dist/extension/pipeline/invocation.js +3 -6
  42. package/dist/extension/pipeline/runner.d.ts +0 -1
  43. package/dist/extension/pipeline/runner.js +6 -14
  44. package/dist/extension/plugins/inventory.d.ts +88 -0
  45. package/dist/extension/plugins/inventory.js +144 -0
  46. package/dist/extension/plugins/panel.d.ts +45 -0
  47. package/dist/extension/plugins/panel.js +293 -0
  48. package/dist/extension/sandbox/bash.d.ts +99 -0
  49. package/dist/extension/sandbox/bash.js +190 -0
  50. package/dist/extension/sandbox/config.d.ts +115 -0
  51. package/dist/extension/sandbox/config.js +388 -0
  52. package/dist/extension/sandbox/manager.d.ts +108 -0
  53. package/dist/extension/sandbox/manager.js +243 -0
  54. package/dist/extension/sandbox/panel.d.ts +111 -0
  55. package/dist/extension/sandbox/panel.js +342 -0
  56. package/dist/extension/sandbox/session.d.ts +85 -0
  57. package/dist/extension/sandbox/session.js +829 -0
  58. package/dist/extension/settingsFiles.d.ts +50 -0
  59. package/dist/extension/settingsFiles.js +206 -0
  60. package/dist/extension/telemetry/attrs.d.ts +96 -0
  61. package/dist/extension/telemetry/attrs.js +149 -0
  62. package/dist/extension/telemetry/config.d.ts +103 -0
  63. package/dist/extension/telemetry/config.js +193 -0
  64. package/dist/extension/telemetry/index.d.ts +7 -0
  65. package/dist/extension/telemetry/index.js +7 -0
  66. package/dist/extension/telemetry/probe.d.ts +29 -0
  67. package/dist/extension/telemetry/probe.js +122 -0
  68. package/dist/extension/telemetry/register.d.ts +47 -0
  69. package/dist/extension/telemetry/register.js +207 -0
  70. package/dist/extension/telemetry/sdk.d.ts +63 -0
  71. package/dist/extension/telemetry/sdk.js +207 -0
  72. package/dist/extension/telemetry/tracker.d.ts +131 -0
  73. package/dist/extension/telemetry/tracker.js +551 -0
  74. package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
  75. package/dist/extension/vendor/ignore.d.ts +86 -0
  76. package/dist/extension/vendor/ignore.js +788 -0
  77. package/dist/goHeadless.d.ts +1 -1
  78. package/dist/goHeadless.js +2 -2
  79. package/dist/launch.d.ts +4 -3
  80. package/dist/launch.js +7 -4
  81. package/dist/mcpCommand.d.ts +10 -1
  82. package/dist/mcpCommand.js +42 -10
  83. package/dist/otel.d.ts +67 -90
  84. package/dist/otel.js +152 -195
  85. package/dist/paths.d.ts +13 -0
  86. package/dist/paths.js +18 -0
  87. package/dist/pluginCommand.d.ts +43 -0
  88. package/dist/pluginCommand.js +499 -0
  89. package/dist/pluginStore.d.ts +170 -0
  90. package/dist/pluginStore.js +554 -0
  91. package/dist/upgrade.js +10 -1
  92. package/package.json +19 -3
@@ -2,6 +2,9 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { type SpendResponse } from "./costHud.js";
3
3
  import { runInitPass as defaultRunInitPass } from "./initPass.js";
4
4
  import { startMcp as defaultStartMcp } from "./mcp/startup.js";
5
+ import { type GuardianGateEvent } from "./permission/gate.js";
6
+ import { registerTelemetry as defaultRegisterTelemetry } from "./telemetry/register.js";
7
+ import { type PluginCliRunner } from "./plugins/panel.js";
5
8
  import { type FlushOutcome, type SpoolClientOpts } from "./spool.js";
6
9
  import { type TokenProvider } from "./tokenProvider.js";
7
10
  import { type CatalogResult, type ContextBrief } from "./config.js";
@@ -58,11 +61,18 @@ export interface RegisterYagniDeps {
58
61
  * homes or spawning servers. Default runs the real startup.
59
62
  */
60
63
  startMcp?: typeof defaultStartMcp;
64
+ /** Test seam for the /plugin panel: runs `yagni plugin …` (defaults to spawning YAGNI_CLI_PATH). */
65
+ runPluginCli?: PluginCliRunner;
61
66
  /**
62
67
  * The CLI init pass (Onramp Door B). Injectable so tests assert it fires on a
63
68
  * fresh-workspace first-run and is skipped otherwise, without a network or disk.
64
69
  */
65
70
  runInitPass?: typeof defaultRunInitPass;
71
+ /**
72
+ * OTel export registration. Injectable so tests assert the boot handoff of
73
+ * the /context `userEmail` into the telemetry identity without an SDK.
74
+ */
75
+ registerTelemetry?: typeof defaultRegisterTelemetry;
66
76
  /** The workspace id the token is bound to (keys the one-time init marker). */
67
77
  getWorkspaceId?: () => string | undefined;
68
78
  /** Has the init pass already run once for this workspace? Injectable (no disk in tests). */
@@ -115,6 +125,15 @@ export declare function buildRunSpendUrl(baseUrl: string, runId: string): string
115
125
  * Returns null on anything that doesn't look like the real shape.
116
126
  */
117
127
  export declare function parseSpendResponse(data: unknown): SpendResponse | null;
128
+ /**
129
+ * Local trail for EVERY terminal gate outcome (sandbox auto-allow included),
130
+ * always on so e2e and support can see what the gate did even when the
131
+ * storage tier is off. Decision K: the always-on line carries outcome
132
+ * metadata ONLY — no command content. The command prefix and the exec
133
+ * justification are command/model content and ride the debug tier (never
134
+ * uploaded by /feedback) plus the opt-in storage stream.
135
+ */
136
+ export declare function logGateOutcomeTrail(ev: GuardianGateEvent, env: NodeJS.ProcessEnv): void;
118
137
  export declare function registerYagni(pi: ExtensionAPI, deps?: RegisterYagniDeps): Promise<void>;
119
138
  export default function (pi: ExtensionAPI): Promise<void>;
120
139
  export { makeAskYagniTool } from "./askYagniTool.js";
@@ -24,6 +24,7 @@ import { logEvent } from "./errorSink.js";
24
24
  import { registerFeedbackCommands } from "./feedbackCommand.js";
25
25
  import { droppedSessionRuns, sessionRunIds } from "./sessionRuns.js";
26
26
  import { codeStateHome } from "./stateHome.js";
27
+ import { mutateLocalConfig } from "./settingsFiles.js";
27
28
  import { logTurnLifecycle } from "./turnLog.js";
28
29
  import { createYagniFooterFactory, cyclePermissionMode, formatCwd, GIT_MUTATING_PATTERN, isShiftTab } from "./footer.js";
29
30
  import { RerouteNotifier } from "./rerouteNotice.js";
@@ -34,7 +35,12 @@ import { startMcp as defaultStartMcp, wireShutdown, deriveStartupConnectivityNot
34
35
  import { registerGoCommand } from "./pipeline/goCommand.js";
35
36
  import { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
36
37
  import { DEFAULT_PERMISSION_POLICY, createModeHolder, registerPermissionGate } from "./permission/gate.js";
38
+ import { registerSandbox } from "./sandbox/session.js";
39
+ import { sandboxAutoAllowDecision } from "./sandbox/bash.js";
40
+ import { registerTelemetry as defaultRegisterTelemetry } from "./telemetry/register.js";
37
41
  import { loadHooksConfig, makeHookRunner, registerHooks } from "./hooks.js";
42
+ import { loadPermissionRules } from "./permissionRules/loadConfig.js";
43
+ import { registerPluginPanel } from "./plugins/panel.js";
38
44
  import { registerSubagents } from "./subagents.js";
39
45
  import { createUltraHolder, registerUltraCommand } from "./ultra.js";
40
46
  import { registerTodos } from "./todos.js";
@@ -99,6 +105,39 @@ export function parseSpendResponse(data) {
99
105
  return null;
100
106
  return d;
101
107
  }
108
+ /**
109
+ * Local trail for EVERY terminal gate outcome (sandbox auto-allow included),
110
+ * always on so e2e and support can see what the gate did even when the
111
+ * storage tier is off. Decision K: the always-on line carries outcome
112
+ * metadata ONLY — no command content. The command prefix and the exec
113
+ * justification are command/model content and ride the debug tier (never
114
+ * uploaded by /feedback) plus the opt-in storage stream.
115
+ */
116
+ export function logGateOutcomeTrail(ev, env) {
117
+ logEvent({
118
+ source: "guardian",
119
+ level: "info",
120
+ event: "gate_outcome",
121
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
122
+ fields: {
123
+ outcome: ev.outcome,
124
+ mode: ev.mode,
125
+ consulted: ev.consulted,
126
+ },
127
+ });
128
+ if (isDebug(env)) {
129
+ logEvent({
130
+ source: "guardian",
131
+ level: "debug",
132
+ event: "gate_outcome",
133
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
134
+ fields: {
135
+ commandPrefix: storagePrefix(ev.command),
136
+ ...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
137
+ },
138
+ });
139
+ }
140
+ }
102
141
  export async function registerYagni(pi, deps = {}) {
103
142
  const baseUrl = deps.baseUrl ?? resolveBaseUrl();
104
143
  const now = deps.now ?? (() => Date.now());
@@ -239,6 +278,9 @@ export async function registerYagni(pi, deps = {}) {
239
278
  // before_agent_start handler below) and widens the tool's fan-out ceiling.
240
279
  const ultraHolder = createUltraHolder();
241
280
  registerSubagents(pi, { isUltra: () => ultraHolder.get(), workingLine, childUsage });
281
+ // /plugin + /reload-plugins: manage Claude Code marketplace plugins from the
282
+ // session, driving the launcher's `yagni plugin …` (YAGNI_CLI_PATH).
283
+ registerPluginPanel(pi, { env: deps.env, runCli: deps.runPluginCli });
242
284
  registerUltraCommand(pi, ultraHolder);
243
285
  // Shared fetch timeout for the small, interactive display-path reads below
244
286
  // (/cost's spend + headroom, and Task 8's per-run spend for /go's summary):
@@ -334,6 +376,8 @@ export async function registerYagni(pi, deps = {}) {
334
376
  // The static queue only carries non-connectivity notices (e.g. the
335
377
  // `.mcp.json` approval prompt), which stay one-shot.
336
378
  let connectivityNoticesFlushed = false;
379
+ // permission-rule notices are one-shot like connectivity notices.
380
+ let rulesNoticesFlushed = false;
337
381
  pi.on("after_provider_response", (_event, ctx) => {
338
382
  try {
339
383
  if (!ctx.hasUI)
@@ -347,6 +391,21 @@ export async function registerYagni(pi, deps = {}) {
347
391
  while (mcpApprovalNotices.length > 0) {
348
392
  ctx.ui.notify(mcpApprovalNotices.shift(), "info");
349
393
  }
394
+ // one-shot permission-rule config warnings (bad files,
395
+ // never-consulted tools). One consolidated line, never a wall.
396
+ if (!rulesNoticesFlushed) {
397
+ rulesNoticesFlushed = true;
398
+ const parts = [];
399
+ if (loadedRules.diagnostics.warnings.length > 0) {
400
+ parts.push(`${loadedRules.diagnostics.warnings.length} permission-rule config warning(s)`);
401
+ }
402
+ if (loadedRules.diagnostics.neverConsultedTools.length > 0) {
403
+ parts.push(`rules for unknown tools: ${loadedRules.diagnostics.neverConsultedTools.join(", ")} (ignored)`);
404
+ }
405
+ if (parts.length > 0) {
406
+ ctx.ui.notify(`Permission rules: ${parts.join(" · ")}`, "warning");
407
+ }
408
+ }
350
409
  }
351
410
  catch {
352
411
  // A notice must never break a turn.
@@ -359,6 +418,18 @@ export async function registerYagni(pi, deps = {}) {
359
418
  // Mutating MCP tools join write/edit/bash in the gate policy: plan mode
360
419
  // holds them, review mode confirms them.
361
420
  const modeHolder = createModeHolder();
421
+ // OTel export (traces, metrics, log events; Claude Code parity). A no-op
422
+ // unless the launcher's gate set YAGNI_OTEL_EXPORT=1 — see the CLI's
423
+ // otel.ts for the sources and src/telemetry for the exporter.
424
+ const telemetry = (deps.registerTelemetry ?? defaultRegisterTelemetry)(pi, { env });
425
+ {
426
+ let previousMode = modeHolder.get();
427
+ modeHolder.onSet((m) => {
428
+ if (m !== previousMode)
429
+ telemetry.permissionModeChanged(previousMode, m);
430
+ previousMode = m;
431
+ });
432
+ }
362
433
  const footerInvalidateHandle = { invalidateGit: () => { }, requestRender: () => { } };
363
434
  const guardianState = makeGuardianState();
364
435
  // Disabled by the local env override OR the workspace kill switch
@@ -408,6 +479,25 @@ export async function registerYagni(pi, deps = {}) {
408
479
  // the startup load is the trust boundary; live reload was reviewed and
409
480
  // rejected as a same-session self-authorization path, PR #1698).
410
481
  const sessionGrants = evalMode ? [] : loadGrants();
482
+ // settings-based permission rules, loaded once at startup (same
483
+ // trust-boundary posture as grants: no mid-session reload). User config
484
+ // (~/.yagni-code/config.json) + project config (.yagni-code/config.json);
485
+ // project allow rules are trust-gated at evaluation time, deny/ask always.
486
+ const loadedRules = evalMode
487
+ ? { rules: [], diagnostics: { warnings: [], neverConsultedTools: [] } }
488
+ : loadPermissionRules({ cwd: process.cwd() });
489
+ for (const w of loadedRules.diagnostics.warnings) {
490
+ logEvent({ source: "permission-rules", level: "warn", event: "config_warning", fields: { warning: w } });
491
+ }
492
+ if (loadedRules.diagnostics.neverConsultedTools.length > 0) {
493
+ logEvent({
494
+ source: "permission-rules",
495
+ level: "warn",
496
+ event: "never_consulted_tools",
497
+ fields: { tools: loadedRules.diagnostics.neverConsultedTools.join(", ") },
498
+ });
499
+ }
500
+ const rulesStateHome = codeStateHome(null, env);
411
501
  const GUARDIAN_EVENT_TIMEOUT_MS = 5_000;
412
502
  // YAG-506: load user-configurable lifecycle hooks config and create the
413
503
  // hook runner for the permission gate. Skipped in eval mode.
@@ -415,11 +505,56 @@ export async function registerYagni(pi, deps = {}) {
415
505
  const hookRunner = evalMode ? null : makeHookRunner({ config: hooksConfig });
416
506
  if (!evalMode)
417
507
  registerHooks(pi, { config: hooksConfig });
508
+ // OS-level bash sandboxing. Registers the sandbox-aware bash tool
509
+ // (dangerouslyDisableSandbox param + wrap + violation annotation) and the
510
+ // session lifecycle (init/reset, fail-soft unless failIfUnavailable).
511
+ // Null on unsupported platforms — stock bash stays. Shares the loaded
512
+ // permission rules so sandbox config and the gate read one rule source.
513
+ const sandboxHandle = evalMode
514
+ ? null
515
+ : registerSandbox(pi, {
516
+ cwd: process.cwd(),
517
+ env,
518
+ hasUI: (ctx) => ctx.hasUI,
519
+ // Anchors project-protected paths (.yagni-code + its config.json in
520
+ // denyWrite) and project-sourced permission rules to the repo the
521
+ // session runs in — same root the gate uses for its rule anchoring.
522
+ projectRoot: process.cwd(),
523
+ });
524
+ if (sandboxHandle)
525
+ sandboxHandle.setRules(loadedRules.rules);
418
526
  registerPermissionGate(pi, {
527
+ ...(sandboxHandle
528
+ ? {
529
+ sandboxAutoAllow: (toolName, params) => sandboxAutoAllowDecision(toolName, params, sandboxHandle.manager, sandboxHandle.settings()),
530
+ }
531
+ : {}),
419
532
  modeHolder,
420
533
  guardianState,
421
534
  guardianLimits,
422
535
  guardianTier,
536
+ onToolDecision: (ev) => telemetry.toolDecision(ev),
537
+ ...(loadedRules.rules.length > 0
538
+ ? {
539
+ permissionRules: loadedRules.rules,
540
+ rulesUserStateHome: rulesStateHome,
541
+ rulesProjectRoot: process.cwd(),
542
+ onRuleVerdict: (ev) => {
543
+ logEvent({
544
+ source: "permission-rules",
545
+ level: "debug",
546
+ event: `rule_${ev.verdict}`,
547
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
548
+ fields: {
549
+ tool: ev.toolName,
550
+ via: ev.matchedVia,
551
+ source: ev.rule.source,
552
+ rule: ev.rule.raw,
553
+ },
554
+ });
555
+ },
556
+ }
557
+ : {}),
423
558
  guardianDisabled,
424
559
  childUsage,
425
560
  guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
@@ -471,57 +606,91 @@ export async function registerYagni(pi, deps = {}) {
471
606
  if (!evalMode)
472
607
  appendGrant(grant);
473
608
  },
609
+ // persist a project-local allow rule (Guardian ask dialog's third
610
+ // option — Claude Code's own default save destination for personal
611
+ // rules is localSettings; ours matches). Atomic write, never overwrites
612
+ // other keys. THROWS on failure — the gate's catch owns the fail-soft
613
+ // behavior (notify the user, record ask_approved not remembered); a
614
+ // catch HERE would swallow the throw and make that path unreachable,
615
+ // leaving the user believing a rule saved that will not survive restart.
616
+ // Local writes fire-and-forget the global gitignore helper so the file
617
+ // is never committed by accident.
618
+ //
619
+ // The cwd is the GATE's per-call session cwd (a session can switch
620
+ // directories; process.cwd() would silently land the rule in another
621
+ // project's local settings while the dialog claims this one). Not
622
+ // registered in eval mode: the gate hides the save option when the dep
623
+ // is absent, so a no-op implementation that would still emit
624
+ // ask_approved_remembered never exists.
625
+ ...(evalMode ? {} : {
626
+ persistUserRule: (ruleString, cwd) => {
627
+ mutateLocalConfig(cwd, (config) => {
628
+ const perms = (config.permissions ?? {});
629
+ const allow = Array.isArray(perms.allow) ? [...perms.allow] : [];
630
+ if (!allow.includes(ruleString))
631
+ allow.push(ruleString);
632
+ perms.allow = allow;
633
+ config.permissions = perms;
634
+ });
635
+ logEvent({
636
+ source: "permission-rules",
637
+ level: "info",
638
+ event: "rule_saved",
639
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
640
+ fields: { rule: ruleString, destination: "local" },
641
+ });
642
+ },
643
+ }),
474
644
  // Opt-in storage stream (YAG-510). Tier decides what leaves the machine:
475
645
  // "off" → nothing (not even sent); "hash" → sha256 + family prefix +
476
646
  // metadata, no command content; "raw" → adds client-REDACTED command and
477
647
  // rationale. Fire-and-forget: one attempt, short timeout, failures logged
478
648
  // fail-soft to the unified sink (source:"guardian") — a storage outage
479
649
  // never touches the session.
480
- onGuardianEvent: guardianStorageTier === "off" || evalMode
481
- ? undefined
482
- : (ev) => {
483
- void (async () => {
484
- try {
485
- const body = {
486
- sessionId: env.YAGNI_SESSION_ID ?? null,
487
- commandHash: createHash("sha256").update(ev.command).digest("hex"),
488
- commandPrefix: storagePrefix(ev.command),
489
- outcome: ev.outcome,
490
- mode: ev.mode,
491
- ...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
492
- ...(ev.riskLevel ? { riskLevel: ev.riskLevel } : {}),
493
- ...(ev.tier ? { tier: ev.tier } : {}),
494
- ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
495
- };
496
- if (guardianStorageTier === "raw") {
497
- body.command = redactCommand(ev.command);
498
- if (ev.rationale)
499
- body.rationale = redactCommand(ev.rationale);
500
- }
501
- const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-events`, {
502
- method: "POST",
503
- headers: {
504
- "content-type": "application/json",
505
- authorization: `Bearer ${getTokenFn() ?? ""}`,
506
- ...attributionHeaders(deps.env),
507
- },
508
- body: JSON.stringify(body),
509
- }, {
510
- fetchImpl: authedFetch,
511
- policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
512
- });
513
- if (!res.ok) {
514
- guardianLogSink({ event: "guardian_event_post_failed", status: res.status });
515
- }
650
+ onGuardianEvent: (ev) => {
651
+ logGateOutcomeTrail(ev, env);
652
+ // Opt-in storage stream (YAG-510): tier decides what leaves the machine.
653
+ if (guardianStorageTier === "off" || evalMode)
654
+ return;
655
+ void (async () => {
656
+ try {
657
+ const body = {
658
+ sessionId: env.YAGNI_SESSION_ID ?? null,
659
+ commandHash: createHash("sha256").update(ev.command).digest("hex"),
660
+ commandPrefix: storagePrefix(ev.command),
661
+ outcome: ev.outcome,
662
+ mode: ev.mode,
663
+ ...(ev.execJustification ? { execJustification: ev.execJustification } : {}),
664
+ ...(ev.riskLevel ? { riskLevel: ev.riskLevel } : {}),
665
+ ...(ev.tier ? { tier: ev.tier } : {}),
666
+ ...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
667
+ };
668
+ if (guardianStorageTier === "raw") {
669
+ body.command = redactCommand(ev.command);
670
+ if (ev.rationale)
671
+ body.rationale = redactCommand(ev.rationale);
516
672
  }
517
- catch {
518
- guardianLogSink({
519
- event: "guardian_event_post_failed",
520
- kind: "network",
521
- });
673
+ const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-events`, {
674
+ method: "POST",
675
+ headers: {
676
+ "content-type": "application/json",
677
+ authorization: `Bearer ${getTokenFn() ?? ""}`,
678
+ ...attributionHeaders(deps.env),
679
+ },
680
+ body: JSON.stringify(body),
681
+ }, {
682
+ fetchImpl: authedFetch,
683
+ policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
684
+ });
685
+ if (!res.ok) {
686
+ guardianLogSink({ event: "guardian_event_post_failed", status: res.status });
522
687
  }
523
- })();
524
- },
688
+ }
689
+ catch {
690
+ guardianLogSink({ event: "guardian_event_post_failed", kind: "network" });
691
+ }
692
+ })();
693
+ },
525
694
  ...(mcpMutatingSet.size > 0 || mcpOutcome.manager
526
695
  ? {
527
696
  policy: {
@@ -671,6 +840,29 @@ export async function registerYagni(pi, deps = {}) {
671
840
  contextBrief = undefined;
672
841
  }
673
842
  }
843
+ // The developer's account email for the OTel export (`user.email`), so a
844
+ // collector can attribute sessions per person. Set before session_start
845
+ // fires, so even the session.count metric carries it. No-op when export is
846
+ // off or the launcher already forwarded YAGNI_USER_EMAIL.
847
+ const bootUserEmail = typeof briefResult?.userEmail === "string" ? briefResult.userEmail : undefined;
848
+ telemetry.setUserEmail(bootUserEmail);
849
+ if (telemetry.enabled && !bootUserEmail && !telemetry.config.identity.userEmail) {
850
+ // Attribution would otherwise degrade silently: leave a trail in the local
851
+ // error sink so "why is user.email missing in the collector" is answerable.
852
+ // Info, not warn: one line per session start, and an account with no
853
+ // email hits it by design. The fields name each way the email can be
854
+ // absent — the boot fetch failed or lacked the field, and whether the
855
+ // launcher's YAGNI_USER_EMAIL was unset or forwarded blank.
856
+ logEvent({
857
+ source: "telemetry",
858
+ level: "info",
859
+ event: "user_email_missing",
860
+ fields: {
861
+ contextFetch: briefResult === null ? "failed_or_skipped" : "no_email_field",
862
+ launcherEmail: env.YAGNI_USER_EMAIL === undefined ? "unset" : "blank",
863
+ },
864
+ });
865
+ }
674
866
  // M1 ambient judgment recall: after a `read`, append the recorded judgment for
675
867
  // that path to the tool result (result modification is supported on pi's
676
868
  // `tool_result` event — verified against 0.80.2). No-op when the corpus is thin
@@ -753,12 +945,25 @@ export async function registerYagni(pi, deps = {}) {
753
945
  try {
754
946
  registerCondensedTools(pi, {
755
947
  ...(scratchpadDirPath ? { scratchpadDir: scratchpadDirPath } : {}),
948
+ // condensed owns the FINAL bash registration (it re-registers
949
+ // built-ins last), so the sandbox composes INTO it here. Identity when
950
+ // the sandbox is off.
951
+ ...(sandboxHandle ? { wrapBash: sandboxHandle.composeBash } : {}),
756
952
  });
757
953
  }
758
954
  catch {
759
955
  // Rendering must never break activation; pi's built-ins remain.
760
956
  }
761
957
  }
958
+ else if (sandboxHandle && env.YAGNI_CLASSIC_TOOL_ROWS !== "1") {
959
+ // Condensed inactive (eval mode / desktop): the sandbox's own
960
+ // registration is the one that reaches the tool registry. NOT under the
961
+ // classic-rows escape hatch — classic bash must stay pi's stock
962
+ // registration (the hatch's contract); a competing one would replace
963
+ // classic rendering with the sandbox's wrap. Desktop/eval still need
964
+ // the sandbox to reach bash, so the hatch is the one carve-out.
965
+ sandboxHandle.registerOwnBash();
966
+ }
762
967
  // Own the identity + inject live company context (and repo rules) on every
763
968
  // turn. The extension loads identically in every pi process this app spawns —
764
969
  // the interactive driver AND every `/go` stage child, subagent, and advisor
@@ -4,7 +4,7 @@
4
4
  * from its bundled copy). Pure config I/O — no pi imports, no TUI, no network —
5
5
  * so importing it from the launcher is side-effect-free.
6
6
  */
7
- export { McpServerConfig, McpScope, PROJECT_CONFIG_FILENAME, ScopedMcpServerConfig, expandEnvVarsInString, expandServerEnv, loadMcpServers, mcpConfigPath, readProjectMcpConfig, readUserMcpConfig, resolveProjectRoot, validateServerConfig, writeUserMcpConfig, } from "./config.js";
7
+ export { McpServerConfig, McpScope, PLUGIN_MCP_ENV, PROJECT_CONFIG_FILENAME, ScopedMcpServerConfig, expandEnvVarsInString, expandServerEnv, loadMcpServers, mcpConfigPath, readPluginMcpServers, readProjectMcpConfig, readUserMcpConfig, resolveProjectRoot, validateServerConfig, writeUserMcpConfig, } from "./config.js";
8
8
  export { ProjectApprovalState, decisionFor, readProjectApproval, recordProjectDecision, resetProjectChoices, undecidedProjectServers, } from "./approval.js";
9
9
  export { McpAuthFile, StoredOAuthEntry, deleteStoredOAuthEntry, getServerKey, getStoredOAuthEntry, mcpAuthPath, readMcpAuth, updateStoredOAuthEntry, writeMcpAuth, } from "./authStore.js";
10
10
  export { revokeTokensOnRemove } from "./auth.js";
@@ -4,7 +4,7 @@
4
4
  * from its bundled copy). Pure config I/O — no pi imports, no TUI, no network —
5
5
  * so importing it from the launcher is side-effect-free.
6
6
  */
7
- export { PROJECT_CONFIG_FILENAME, expandEnvVarsInString, expandServerEnv, loadMcpServers, mcpConfigPath, readProjectMcpConfig, readUserMcpConfig, resolveProjectRoot, validateServerConfig, writeUserMcpConfig, } from "./config.js";
7
+ export { PLUGIN_MCP_ENV, PROJECT_CONFIG_FILENAME, expandEnvVarsInString, expandServerEnv, loadMcpServers, mcpConfigPath, readPluginMcpServers, readProjectMcpConfig, readUserMcpConfig, resolveProjectRoot, validateServerConfig, writeUserMcpConfig, } from "./config.js";
8
8
  export { decisionFor, readProjectApproval, recordProjectDecision, resetProjectChoices, undecidedProjectServers, } from "./approval.js";
9
9
  export { deleteStoredOAuthEntry, getServerKey, getStoredOAuthEntry, mcpAuthPath, readMcpAuth, updateStoredOAuthEntry, writeMcpAuth, } from "./authStore.js";
10
10
  export { revokeTokensOnRemove } from "./auth.js";
@@ -6,13 +6,18 @@
6
6
  * A repo configured for Claude Code works here with zero changes.
7
7
  * - user: top-level `mcpServers` in `~/.yagni-code/mcp.json`.
8
8
  * - local: `projects[<absPath>].mcpServers` in the same file, keyed by cwd.
9
+ * - plugin: servers bundled in Claude Code plugins (`.mcp.json` at the plugin
10
+ * root), handed over by the yagni launcher as JSON in
11
+ * `YAGNI_CLAUDE_PLUGIN_MCP` (see yagni-code-cli claudeCompat.ts). Entries
12
+ * marked `gated` came from repo config and ride the project approval gate.
9
13
  *
10
- * Merge precedence (Claude Code parity): user < project < local — later wins.
14
+ * Merge precedence (Claude Code parity): plugin < user < project < local —
15
+ * later wins, so an explicit user entry beats a plugin's.
11
16
  * Writes are atomic (temp file + rename) so two concurrent sessions can never
12
17
  * interleave into a corrupt config. Validation errors are collected and
13
18
  * surfaced, never fatal: one malformed server entry must not hide the rest.
14
19
  */
15
- export type McpScope = "user" | "project" | "local";
20
+ export type McpScope = "user" | "project" | "local" | "plugin";
16
21
  export interface McpStdioServerConfig {
17
22
  type?: "stdio";
18
23
  command: string;
@@ -39,7 +44,24 @@ export interface ScopedMcpServerConfig {
39
44
  scope: McpScope;
40
45
  /** Where this entry came from (file path) — surfaced in /mcp and `yagni mcp get`. */
41
46
  sourcePath: string;
47
+ /** `name@marketplace` of the plugin that bundles this server (plugin scope). */
48
+ plugin?: string;
49
+ /** Plugin server that came from repo config: needs the project approval gate. */
50
+ gated?: boolean;
42
51
  }
52
+ /** Launcher → extension: plugin-bundled MCP servers (JSON array). */
53
+ export declare const PLUGIN_MCP_ENV = "YAGNI_CLAUDE_PLUGIN_MCP";
54
+ export interface PluginMcpEnvEntry {
55
+ plugin: string;
56
+ sourcePath: string;
57
+ gated: boolean;
58
+ servers: Record<string, unknown>;
59
+ }
60
+ /** Parse the launcher's plugin MCP payload; malformed input degrades to errors, never a throw. */
61
+ export declare function readPluginMcpServers(env?: NodeJS.ProcessEnv): {
62
+ servers: ScopedMcpServerConfig[];
63
+ errors: McpConfigError[];
64
+ };
43
65
  export interface McpConfigError {
44
66
  /** File the problem was found in. */
45
67
  sourcePath: string;
@@ -6,8 +6,13 @@
6
6
  * A repo configured for Claude Code works here with zero changes.
7
7
  * - user: top-level `mcpServers` in `~/.yagni-code/mcp.json`.
8
8
  * - local: `projects[<absPath>].mcpServers` in the same file, keyed by cwd.
9
+ * - plugin: servers bundled in Claude Code plugins (`.mcp.json` at the plugin
10
+ * root), handed over by the yagni launcher as JSON in
11
+ * `YAGNI_CLAUDE_PLUGIN_MCP` (see yagni-code-cli claudeCompat.ts). Entries
12
+ * marked `gated` came from repo config and ride the project approval gate.
9
13
  *
10
- * Merge precedence (Claude Code parity): user < project < local — later wins.
14
+ * Merge precedence (Claude Code parity): plugin < user < project < local —
15
+ * later wins, so an explicit user entry beats a plugin's.
11
16
  * Writes are atomic (temp file + rename) so two concurrent sessions can never
12
17
  * interleave into a corrupt config. Validation errors are collected and
13
18
  * surfaced, never fatal: one malformed server entry must not hide the rest.
@@ -15,6 +20,70 @@
15
20
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
21
  import { dirname, join } from "node:path";
17
22
  import { codeStateHome } from "../stateHome.js";
23
+ /** Launcher → extension: plugin-bundled MCP servers (JSON array). */
24
+ export const PLUGIN_MCP_ENV = "YAGNI_CLAUDE_PLUGIN_MCP";
25
+ const CLAUDE_COMPAT_DISABLE_ENV = "YAGNI_DISABLE_CLAUDE_COMPAT";
26
+ /** Parse the launcher's plugin MCP payload; malformed input degrades to errors, never a throw. */
27
+ export function readPluginMcpServers(env = process.env) {
28
+ const disabled = env[CLAUDE_COMPAT_DISABLE_ENV];
29
+ if (disabled !== undefined && disabled !== "" && disabled !== "0")
30
+ return { servers: [], errors: [] };
31
+ const raw = env[PLUGIN_MCP_ENV];
32
+ if (!raw)
33
+ return { servers: [], errors: [] };
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(raw);
37
+ }
38
+ catch (err) {
39
+ return {
40
+ servers: [],
41
+ errors: [{ sourcePath: PLUGIN_MCP_ENV, message: `could not parse plugin MCP payload (${err instanceof Error ? err.message : String(err)})` }],
42
+ };
43
+ }
44
+ if (!Array.isArray(parsed))
45
+ return { servers: [], errors: [{ sourcePath: PLUGIN_MCP_ENV, message: "plugin MCP payload is not an array" }] };
46
+ const servers = [];
47
+ const errors = [];
48
+ const seen = new Map();
49
+ for (const item of parsed) {
50
+ if (!item || typeof item !== "object")
51
+ continue;
52
+ const entry = item;
53
+ if (typeof entry.plugin !== "string" || !entry.servers || typeof entry.servers !== "object")
54
+ continue;
55
+ const sourcePath = typeof entry.sourcePath === "string" ? entry.sourcePath : entry.plugin;
56
+ for (const [name, value] of Object.entries(entry.servers)) {
57
+ const validation = validateServerConfig(value);
58
+ if (!validation.ok) {
59
+ errors.push({ sourcePath, serverName: name, message: `${validation.message} (plugin ${entry.plugin})` });
60
+ continue;
61
+ }
62
+ const owner = seen.get(name);
63
+ if (owner) {
64
+ errors.push({
65
+ sourcePath,
66
+ serverName: name,
67
+ message: `also defined by plugin ${owner} — keeping that one, skipping ${entry.plugin}'s`,
68
+ });
69
+ continue;
70
+ }
71
+ const config = value;
72
+ const expanded = expandServerEnv(config, env);
73
+ if (expanded.missingVars.length > 0) {
74
+ errors.push({
75
+ sourcePath,
76
+ serverName: name,
77
+ message: `missing environment variable(s): ${expanded.missingVars.join(", ")} — server skipped`,
78
+ });
79
+ continue;
80
+ }
81
+ seen.set(name, entry.plugin);
82
+ servers.push({ name, config, scope: "plugin", sourcePath, plugin: entry.plugin, gated: entry.gated === true });
83
+ }
84
+ }
85
+ return { servers, errors };
86
+ }
18
87
  export const PROJECT_CONFIG_FILENAME = ".mcp.json";
19
88
  /**
20
89
  * Expand `${VAR}` / `${VAR:-default}` in a config string (Claude Code parity —
@@ -239,7 +308,10 @@ function defaultExists(p) {
239
308
  export function loadMcpServers(cwd, env = process.env) {
240
309
  const repoRoot = resolveProjectRoot(cwd);
241
310
  const errors = [];
242
- const byScope = { user: [], project: [], local: [] };
311
+ const byScope = { plugin: [], user: [], project: [], local: [] };
312
+ const pluginLoad = readPluginMcpServers(env);
313
+ errors.push(...pluginLoad.errors);
314
+ byScope.plugin.push(...pluginLoad.servers);
243
315
  const { file: userFile, errors: userErrors } = readUserMcpConfig();
244
316
  errors.push(...userErrors);
245
317
  const userServers = userFile.mcpServers ?? {};
@@ -300,7 +372,7 @@ export function loadMcpServers(cwd, env = process.env) {
300
372
  }
301
373
  }
302
374
  const merged = new Map();
303
- for (const scope of ["user", "project", "local"]) {
375
+ for (const scope of ["plugin", "user", "project", "local"]) {
304
376
  for (const server of byScope[scope])
305
377
  merged.set(server.name, server);
306
378
  }
@@ -20,6 +20,8 @@ export interface ManagedServer {
20
20
  scope: McpScope;
21
21
  config: McpStdioServerConfig | McpHttpServerConfig;
22
22
  status: McpServerStatus;
23
+ /** Plugin server sourced from repo config: rides the project approval gate. */
24
+ gated?: boolean;
23
25
  /** Failure detail for the failed/needs_auth states (panel + logs). */
24
26
  error?: string;
25
27
  client?: Client;
@@ -51,7 +53,7 @@ export declare class McpManager {
51
53
  list(): ManagedServer[];
52
54
  get(name: string): ManagedServer | undefined;
53
55
  /** Registers a server without connecting (panel listing, kill-switch states). */
54
- register(name: string, scope: McpScope, config: McpStdioServerConfig | McpHttpServerConfig, status?: McpServerStatus): ManagedServer;
56
+ register(name: string, scope: McpScope, config: McpStdioServerConfig | McpHttpServerConfig, status?: McpServerStatus, gated?: boolean): ManagedServer;
55
57
  connect(name: string): Promise<ManagedServer | undefined>;
56
58
  disconnect(name: string): Promise<void>;
57
59
  /** Reconnect with bounded attempts, panel-visible progress. */
@@ -47,11 +47,11 @@ export class McpManager {
47
47
  return this.servers.get(name);
48
48
  }
49
49
  /** Registers a server without connecting (panel listing, kill-switch states). */
50
- register(name, scope, config, status = "disabled") {
50
+ register(name, scope, config, status = "disabled", gated = false) {
51
51
  const existing = this.servers.get(name);
52
52
  if (existing)
53
53
  return existing;
54
- const server = { name, scope, config, status };
54
+ const server = { name, scope, config, status, ...(gated ? { gated } : {}) };
55
55
  this.servers.set(name, server);
56
56
  return server;
57
57
  }
@@ -21,7 +21,6 @@ export interface PanelServerInfo {
21
21
  }
22
22
  export declare function statusIcon(status: string): string;
23
23
  export declare function statusText(server: ManagedServer): string;
24
- /** Plain-text listing, mirroring CC's `claude mcp list` and panel states. */
25
24
  export declare function renderPanelListing(servers: ManagedServer[], errors: string[]): string;
26
25
  export interface PanelDeps {
27
26
  cwd: string;