@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
@@ -17,10 +17,19 @@
17
17
  * relative-path source loads on the project side, unless explicitly
18
18
  * disabled via `enabledPlugins`.
19
19
  *
20
- * Deliberately NOT here: network installation of any kind, plugin hooks, MCP
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.
20
+ * Three sources, first wins on the same `name@marketplace`:
21
+ *
22
+ * 0. YAGNI's own ledger (`~/.yagni-code/plugins`, written by `yagni plugin`;
23
+ * see pluginStore.ts) handed in by the caller as `yagniLedger`.
24
+ * 1. Claude Code's ledger + `enabledPlugins` (mirrored read-only).
25
+ * 2. The repo's own `.claude-plugin/marketplace.json`.
26
+ *
27
+ * Plugin MCP servers (`.mcp.json` at the plugin root, or the manifest's
28
+ * `mcpServers`) are collected here and bridged by the launcher into the
29
+ * extension's MCP client (see claudeCompat.ts). Deliberately NOT here:
30
+ * network installation (that is pluginStore.ts), plugin hooks, LSP servers,
31
+ * themes, output styles — those are reported as `unsupported` so `yagni
32
+ * plugin list` can say so. Discovery is read-only.
24
33
  *
25
34
  * Everything is fail-soft: malformed JSON, missing dirs, or hostile path
26
35
  * entries degrade to "that plugin absent" — never a failed launch. Path
@@ -151,10 +160,20 @@ export function readKnownMarketplaces(homeDir) {
151
160
  }
152
161
  return out;
153
162
  }
163
+ // ── marketplace file ────────────────────────────────────────────────────────
164
+ /**
165
+ * Names and versions from marketplace/plugin JSON become path segments under
166
+ * the plugin home (`cache/<mp>/<plugin>/<version>`), so they must be plain
167
+ * single segments: no separators, no `.`/`..`, nothing starting with `-`.
168
+ */
169
+ export const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
170
+ export function isSafeSegment(value) {
171
+ return typeof value === "string" && SAFE_SEGMENT.test(value);
172
+ }
154
173
  /** Parse `<root>/.claude-plugin/marketplace.json`; null when absent/unusable. */
155
174
  export function readMarketplace(root) {
156
175
  const parsed = readJsonObject(join(root, ".claude-plugin", "marketplace.json"));
157
- if (!parsed || typeof parsed.name !== "string" || parsed.name.length === 0)
176
+ if (!parsed || !isSafeSegment(parsed.name))
158
177
  return null;
159
178
  if (!Array.isArray(parsed.plugins))
160
179
  return null;
@@ -166,10 +185,16 @@ export function readMarketplace(root) {
166
185
  for (const item of parsed.plugins) {
167
186
  if (!item || typeof item !== "object" || Array.isArray(item))
168
187
  continue;
169
- const name = item.name;
170
- if (typeof name !== "string" || name.length === 0)
188
+ const entry = item;
189
+ const name = entry.name;
190
+ if (!isSafeSegment(name))
171
191
  continue;
172
- plugins.push({ name, source: item.source });
192
+ plugins.push({
193
+ name,
194
+ source: entry.source,
195
+ ...(isSafeSegment(entry.version) ? { version: entry.version } : {}),
196
+ ...(typeof entry.description === "string" ? { description: entry.description } : {}),
197
+ });
173
198
  }
174
199
  return { name: parsed.name, root, pluginRoot, plugins };
175
200
  }
@@ -212,13 +237,17 @@ function asStringArray(value) {
212
237
  * discovery reads whole dirs), root `SKILL.md` fallback. Null when the plugin
213
238
  * has nothing we can bridge.
214
239
  */
215
- export function pluginAssets(root, name) {
240
+ export function pluginAssets(root, name, meta = {}) {
216
241
  if (!isDirectory(root))
217
242
  return null;
218
- const manifest = readJsonObject(join(root, ".claude-plugin", "plugin.json")) ?? {};
243
+ const manifestPath = join(root, ".claude-plugin", "plugin.json");
244
+ const manifest = readJsonObject(manifestPath) ?? {};
245
+ // Default component locations go through the same containment check as
246
+ // manifest-declared ones: a `skills` symlink pointing outside the plugin
247
+ // must not hand that directory to pi.
219
248
  const skillPaths = [];
220
- const defaultSkills = join(root, "skills");
221
- if (isDirectory(defaultSkills))
249
+ const defaultSkills = containedExistingPath(root, "skills");
250
+ if (defaultSkills && isDirectory(defaultSkills))
222
251
  skillPaths.push(defaultSkills);
223
252
  const manifestSkills = asStringArray(manifest.skills);
224
253
  for (const rel of manifestSkills) {
@@ -227,8 +256,8 @@ export function pluginAssets(root, name) {
227
256
  skillPaths.push(contained);
228
257
  }
229
258
  if (skillPaths.length === 0 && manifestSkills.length === 0) {
230
- const rootSkill = join(root, "SKILL.md");
231
- if (isFile(rootSkill))
259
+ const rootSkill = containedExistingPath(root, "SKILL.md");
260
+ if (rootSkill && isFile(rootSkill))
232
261
  skillPaths.push(rootSkill);
233
262
  }
234
263
  const componentPaths = (manifestValue, defaultDir) => {
@@ -242,14 +271,62 @@ export function pluginAssets(root, name) {
242
271
  }
243
272
  return out;
244
273
  }
245
- const def = join(root, defaultDir);
246
- return isDirectory(def) ? [def] : [];
274
+ const def = containedExistingPath(root, defaultDir);
275
+ return def && isDirectory(def) ? [def] : [];
247
276
  };
248
277
  const commandPaths = componentPaths(manifest.commands, "commands");
249
278
  const agentDirs = componentPaths(manifest.agents, "agents").filter(isDirectory);
250
- if (skillPaths.length === 0 && commandPaths.length === 0 && agentDirs.length === 0)
279
+ const mcp = pluginMcp(root, manifest, manifestPath);
280
+ const unsupported = [];
281
+ if (manifest.hooks !== undefined || isDirectory(join(root, "hooks")) || isFile(join(root, "hooks.json")))
282
+ unsupported.push("hooks");
283
+ if (manifest.lspServers !== undefined || isFile(join(root, ".lsp.json")))
284
+ unsupported.push("lsp");
285
+ if (manifest.outputStyles !== undefined)
286
+ unsupported.push("output styles");
287
+ if (skillPaths.length === 0 && commandPaths.length === 0 && agentDirs.length === 0 && !mcp)
251
288
  return null;
252
- return { name, root, skillPaths, commandPaths, agentDirs };
289
+ const version = meta.version ?? (typeof manifest.version === "string" ? manifest.version : null);
290
+ return {
291
+ name,
292
+ key: meta.key ?? name,
293
+ origin: meta.origin ?? "claude-code",
294
+ version,
295
+ root,
296
+ skillPaths,
297
+ commandPaths,
298
+ agentDirs,
299
+ mcp,
300
+ unsupported,
301
+ };
302
+ }
303
+ /**
304
+ * A plugin's MCP servers, per Claude Code's rules: the manifest `mcpServers`
305
+ * field (an inline map, or a contained path to a JSON file with one), else
306
+ * `.mcp.json` at the plugin root. Malformed → null (fail-soft).
307
+ */
308
+ function pluginMcp(root, manifest, manifestPath) {
309
+ const field = manifest.mcpServers;
310
+ if (field && typeof field === "object" && !Array.isArray(field)) {
311
+ return { sourcePath: manifestPath, servers: field };
312
+ }
313
+ const candidates = [];
314
+ if (typeof field === "string") {
315
+ const contained = containedExistingPath(root, field);
316
+ if (contained && isFile(contained))
317
+ candidates.push(contained);
318
+ }
319
+ const rootFile = containedExistingPath(root, ".mcp.json");
320
+ if (rootFile && isFile(rootFile))
321
+ candidates.push(rootFile);
322
+ for (const path of candidates) {
323
+ const parsed = readJsonObject(path);
324
+ const servers = parsed?.mcpServers;
325
+ if (servers && typeof servers === "object" && !Array.isArray(servers)) {
326
+ return { sourcePath: path, servers: servers };
327
+ }
328
+ }
329
+ return null;
253
330
  }
254
331
  function resolveInstalledRoot(key, installed, marketplaces, realCwd) {
255
332
  const entries = (installed.get(key) ?? []).filter((e) => isDirectory(e.installPath));
@@ -295,14 +372,40 @@ export function discoverClaudePlugins(deps) {
295
372
  const user = [];
296
373
  const project = [];
297
374
  const seen = new Set();
375
+ // YAGNI-installed plugins are the user's own explicit action (`yagni
376
+ // plugin install`), so both scopes load without ceremony; a project-scope
377
+ // install applies only inside its repo.
378
+ for (const [key, entries] of Object.entries(deps.yagniLedger?.plugins ?? {})) {
379
+ const applicable = entries.filter((e) => {
380
+ if (!e.enabled || !isDirectory(e.installPath))
381
+ return false;
382
+ if (e.scope === "user")
383
+ return true;
384
+ const real = typeof e.projectPath === "string" ? realOrNull(e.projectPath) : null;
385
+ return !!real && (realCwd === real || realCwd.startsWith(real + sep));
386
+ });
387
+ const chosen = applicable.find((e) => e.scope === "project") ?? applicable[0];
388
+ if (!chosen)
389
+ continue;
390
+ const at = key.lastIndexOf("@");
391
+ const assets = pluginAssets(chosen.installPath, at > 0 ? key.slice(0, at) : key, {
392
+ key,
393
+ origin: "yagni",
394
+ version: chosen.version,
395
+ });
396
+ if (!assets)
397
+ continue;
398
+ seen.add(key);
399
+ user.push(assets);
400
+ }
298
401
  for (const [key, state] of enabled) {
299
- if (!state.enabled)
402
+ if (!state.enabled || seen.has(key))
300
403
  continue;
301
404
  const root = resolveInstalledRoot(key, installed, marketplaces, realCwd);
302
405
  if (!root)
303
406
  continue;
304
407
  const at = key.lastIndexOf("@");
305
- const assets = pluginAssets(root, at > 0 ? key.slice(0, at) : key);
408
+ const assets = pluginAssets(root, at > 0 ? key.slice(0, at) : key, { key, origin: "claude-code" });
306
409
  if (!assets)
307
410
  continue;
308
411
  seen.add(key);
@@ -322,7 +425,12 @@ export function discoverClaudePlugins(deps) {
322
425
  resolveInstalledRoot(key, installed, marketplaces, realCwd);
323
426
  if (!root)
324
427
  continue;
325
- const assets = pluginAssets(root, entry.name);
428
+ const declared = entry.version;
429
+ const assets = pluginAssets(root, entry.name, {
430
+ key,
431
+ origin: "repo-marketplace",
432
+ version: typeof declared === "string" ? declared : null,
433
+ });
326
434
  if (!assets)
327
435
  continue;
328
436
  seen.add(key);
package/dist/cli.js CHANGED
@@ -24,6 +24,7 @@ import { DISTRIBUTION } from "./distribution.js";
24
24
  import { connectCommand } from "./connectClaudeCode.js";
25
25
  import { goCommand } from "./goHeadless.js";
26
26
  import { mcpCommand } from "./mcpCommand.js";
27
+ import { pluginCommand } from "./pluginCommand.js";
27
28
  import { login } from "./login.js";
28
29
  import { logout } from "./logout.js";
29
30
  import { tokenCommand } from "./token.js";
@@ -247,10 +248,10 @@ async function runDefault(passthroughArgs) {
247
248
  catch {
248
249
  compat = { argv: [], env: {} };
249
250
  }
250
- // OTel export: load pi-otel only when an OTLP endpoint is configured — the
251
- // user's env, the workspace's admin-set config (fetched fail-soft, cached),
252
- // or the repo's .pi/settings.json, in that order. undefined keeps the
253
- // launch untouched. See otel.ts for the policy.
251
+ // OTel export: on only when an OTLP endpoint is configured — the user's
252
+ // env, the workspace's admin-set config (fetched fail-soft, cached), or the
253
+ // repo's .pi/settings.json, in that order. undefined keeps the launch
254
+ // untouched. See otel.ts for the policy.
254
255
  const otel = await resolveOtelLaunchWithWorkspace({
255
256
  env: process.env,
256
257
  cwd: process.cwd(),
@@ -520,6 +521,10 @@ export const HELP_TEXT = [
520
521
  " add-from-claude, and more — run `yagni mcp` for",
521
522
  " details. OAuth servers authenticate via the",
522
523
  " /mcp panel in a session.",
524
+ " yagni plugin <subcommand> Claude Code plugins from a marketplace: marketplace",
525
+ " add, install, uninstall, update, list — run",
526
+ " `yagni plugin` for details. Skills, commands,",
527
+ " agents, and bundled MCP servers load on launch.",
523
528
  " yagni token Output the active environment's API token (for helpers).",
524
529
  " yagni use <name> Switch the active environment (sticky).",
525
530
  " Presets: prod, local. Others need --base-url <url>.",
@@ -542,6 +547,8 @@ export const HELP_TEXT = [
542
547
  " /todos Show the agent's live task list.",
543
548
  " /cost Session usage and credit headroom.",
544
549
  " /mcp Manage MCP servers; authenticate OAuth servers.",
550
+ " /plugin Browse, install, and remove Claude Code plugins.",
551
+ " /reload-plugins Apply plugin changes without restarting.",
545
552
  "",
546
553
  "The active environment is sticky; `use` switches it (prod is the default).",
547
554
  "Set YAGNI_BASE_URL to override the base URL for a single run.",
@@ -665,6 +672,11 @@ export async function main(argv) {
665
672
  if (command === "mcp") {
666
673
  return mcpCommand(rest);
667
674
  }
675
+ // Claude Code plugin marketplaces: install/remove/update from YAGNI itself
676
+ // (state under ~/.yagni-code/plugins; Claude Code's own installs still mirror).
677
+ if (command === "plugin") {
678
+ return pluginCommand(rest);
679
+ }
668
680
  // The headless pipeline entry. `go` is a real subcommand, not passthrough:
669
681
  // the interactive run stays `/go` inside a session, and a `yagni go` without
670
682
  // --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 { probeOtelEndpoint, type OtelLaunchConfig } from "./otel.js";
16
+ import { type OtelLaunchConfig } from "./otel.js";
17
17
  import { type Profile } from "./profiles.js";
18
18
  export type CheckStatus = "ok" | "warn" | "fail";
19
19
  export interface CheckResult {
@@ -61,16 +61,25 @@ export declare function checkCliUpdate(probe: {
61
61
  latest: string | null;
62
62
  }): CheckResult;
63
63
  export declare function checkGh(onPath: boolean): CheckResult;
64
+ /** One signal's verdict from the extension's probe (see telemetry/probe.ts). */
65
+ export interface OtelProbeSignal {
66
+ signal: "traces" | "metrics" | "logs";
67
+ url: string;
68
+ protocol: string;
69
+ ok: boolean;
70
+ error?: string;
71
+ }
64
72
  /**
65
- * Advisory OTel-export line: says whether sessions will stream traces to an
66
- * OTLP collector, from which config source, and — when a collector is
67
- * configured — whether the endpoint answers TCP right now (scheme-default
68
- * ports included: https with no port probes 443). Never flips the exit code —
69
- * most machines have no collector, and that is the healthy default.
73
+ * Advisory OTel-export line: says whether sessions will stream telemetry to
74
+ * an OTLP collector, from which config source, and — when a collector is
75
+ * configured — whether it ACCEPTED a real record on each signal (a wrong API
76
+ * key, a wrong path, or a protocol mismatch shows up as the collector's own
77
+ * error). Never flips the exit code — most machines have no collector, and
78
+ * that is the healthy default.
70
79
  */
71
80
  export declare function checkOtelExport(config: OtelLaunchConfig | undefined, deps?: {
72
- probeEndpoint?: typeof probeOtelEndpoint;
73
- probeTimeoutMs?: number;
81
+ probe?: (env: NodeJS.ProcessEnv) => Promise<OtelProbeSignal[]>;
82
+ baseEnv?: NodeJS.ProcessEnv;
74
83
  }): Promise<CheckResult>;
75
84
  /** Config-only MCP snapshot (no connections — health checks live in `mcp list`/`get`). */
76
85
  export interface McpProbe {
@@ -120,6 +129,10 @@ export interface DoctorDeps {
120
129
  probeLatestVersion?: () => Promise<string | null>;
121
130
  /** MCP config snapshot (config-only, no connections). */
122
131
  probeMcp?: () => Promise<McpProbe>;
132
+ /** OTel gate resolution (env / workspace / repo settings); tests stub it. */
133
+ resolveOtel?: (profile: Profile) => Promise<OtelLaunchConfig | undefined>;
134
+ /** OTel export probe: one real record per signal against the session env. */
135
+ probeOtel?: (env: NodeJS.ProcessEnv) => Promise<OtelProbeSignal[]>;
123
136
  log?: (msg: string) => void;
124
137
  }
125
138
  /** Whether a `gh` executable is resolvable on PATH (no subprocess spawn). */
package/dist/doctor.js CHANGED
@@ -17,8 +17,8 @@ import { delimiter, join } from "node:path";
17
17
  import { credentialsDir } from "./credentials.js";
18
18
  import { currentCliVersion, fetchLatestVersion, isNewerVersion } from "./upgrade.js";
19
19
  import { classifyTokenExpiry } from "./launch.js";
20
- import { probeOtelEndpoint, resolveOtelLaunchWithWorkspace, } from "./otel.js";
21
- import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir } from "./paths.js";
20
+ import { otelChildEnv, resolveOtelLaunchWithWorkspace } from "./otel.js";
21
+ import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir, resolveTelemetryProbePath } from "./paths.js";
22
22
  import { readActiveProfile } from "./profiles.js";
23
23
  import { resolveMcpConfigPath } from "./mcpCommand.js";
24
24
  // ── Pure check builders ─────────────────────────────────────────────────────
@@ -201,11 +201,22 @@ export function checkGh(onPath) {
201
201
  };
202
202
  }
203
203
  /**
204
- * Advisory OTel-export line: says whether sessions will stream traces to an
205
- * OTLP collector, from which config source, and when a collector is
206
- * configured whether the endpoint answers TCP right now (scheme-default
207
- * ports included: https with no port probes 443). Never flips the exit code
208
- * most machines have no collector, and that is the healthy default.
204
+ * Run the extension's telemetry probe against the exact env a session would
205
+ * get: one real span, one metric point, one log record, each reported with
206
+ * the collector's actual verdict. Dynamic import so `doctor` on a machine
207
+ * with no collector never loads the OTel SDK graph.
208
+ */
209
+ async function defaultProbeOtel(env) {
210
+ const mod = (await import(resolveTelemetryProbePath()));
211
+ return (await mod.probeTelemetryExport(env)).results;
212
+ }
213
+ /**
214
+ * Advisory OTel-export line: says whether sessions will stream telemetry to
215
+ * an OTLP collector, from which config source, and — when a collector is
216
+ * configured — whether it ACCEPTED a real record on each signal (a wrong API
217
+ * key, a wrong path, or a protocol mismatch shows up as the collector's own
218
+ * error). Never flips the exit code — most machines have no collector, and
219
+ * that is the healthy default.
209
220
  */
210
221
  export async function checkOtelExport(config, deps = {}) {
211
222
  if (!config) {
@@ -222,23 +233,32 @@ export async function checkOtelExport(config, deps = {}) {
222
233
  ? "workspace settings"
223
234
  : ".pi/settings.json";
224
235
  const base = `on → ${config.endpoint} (${source}, metadata-only)`;
225
- const doProbe = deps.probeEndpoint ?? probeOtelEndpoint;
236
+ const baseEnv = deps.baseEnv ?? process.env;
237
+ const env = { ...baseEnv, ...otelChildEnv(config, baseEnv) };
238
+ const doProbe = deps.probe ?? defaultProbeOtel;
226
239
  try {
227
- const reachable = await doProbe(config.endpoint, deps.probeTimeoutMs ?? 1_000);
228
- return reachable
229
- ? {
240
+ const results = await doProbe(env);
241
+ const accepted = results.filter((r) => r.ok).map((r) => r.signal);
242
+ const failed = results.filter((r) => !r.ok);
243
+ const summary = results.map((r) => `${r.signal} ${r.ok ? "accepted" : "FAILED"}`).join(", ");
244
+ if (failed.length === 0) {
245
+ return {
230
246
  name: "otel export (optional)",
231
247
  status: "ok",
232
- detail: `${base}, TCP reachable`,
233
- required: false,
234
- }
235
- : {
236
- name: "otel export (optional)",
237
- status: "warn",
238
- detail: `${base}, endpoint not reachable`,
239
- hint: "the collector is down, unreachable from this machine, or the URL is wrong — traces will not export until it answers",
248
+ detail: `${base}; ${summary}`,
240
249
  required: false,
241
250
  };
251
+ }
252
+ const reasons = failed.map((r) => `${r.signal} → ${r.url} (${r.protocol}): ${r.error ?? "rejected"}`).join("; ");
253
+ return {
254
+ name: "otel export (optional)",
255
+ status: "warn",
256
+ detail: `${base}; ${summary}`,
257
+ hint: `${reasons}. A 401/403 means the collector key is wrong, a 404 the path or site is wrong, ` +
258
+ `a connection error the collector is unreachable from this machine` +
259
+ (accepted.length > 0 ? `; ${accepted.join(" and ")} still export` : ""),
260
+ required: false,
261
+ };
242
262
  }
243
263
  catch {
244
264
  // The probe itself errored (not "endpoint down" — the check could not
@@ -247,8 +267,8 @@ export async function checkOtelExport(config, deps = {}) {
247
267
  return {
248
268
  name: "otel export (optional)",
249
269
  status: "warn",
250
- detail: `${base}, reachability probe failed`,
251
- hint: "the reachability check could not run (network error) — export is configured but unverified; traces may still export normally",
270
+ detail: `${base}, export probe failed`,
271
+ hint: "the export probe could not run (the bundled extension is missing or the OTel SDK failed to load) — export is configured but unverified; sessions may still export normally",
252
272
  required: false,
253
273
  };
254
274
  }
@@ -402,7 +422,7 @@ async function defaultProbeMcp(env = process.env) {
402
422
  const repoRoot = mod.resolveProjectRoot(process.cwd());
403
423
  const { state } = mod.readProjectApproval(repoRoot);
404
424
  const undecided = loaded.servers
405
- .filter((s) => s.scope === "project" && mod.decisionFor(state, s.name) === "undecided")
425
+ .filter((s) => (s.scope === "project" || s.gated === true) && mod.decisionFor(state, s.name) === "undecided")
406
426
  .map((s) => s.name);
407
427
  return {
408
428
  disabled: false,
@@ -516,12 +536,17 @@ export async function gatherChecks(deps = {}) {
516
536
  checks.push(checkStateDir(probeStateDir()));
517
537
  checks.push(checkGh(ghOnPath()));
518
538
  checks.push(checkMcpConfig(await probeMcp()));
519
- checks.push(await checkOtelExport(await resolveOtelLaunchWithWorkspace({
520
- env: process.env,
521
- cwd: process.cwd(),
522
- creds: profile.token ? { baseUrl: profile.baseUrl, token: profile.token } : null,
523
- profileName: profile.name,
524
- })));
539
+ const resolveOtel = deps.resolveOtel ??
540
+ ((p) => resolveOtelLaunchWithWorkspace({
541
+ env: process.env,
542
+ cwd: process.cwd(),
543
+ creds: p.token ? { baseUrl: p.baseUrl, token: p.token } : null,
544
+ profileName: p.name,
545
+ }));
546
+ checks.push(await checkOtelExport(await resolveOtel(profile), {
547
+ ...(deps.probeOtel ? { probe: deps.probeOtel } : {}),
548
+ baseEnv: process.env,
549
+ }));
525
550
  return checks;
526
551
  }
527
552
  /**
@@ -24,7 +24,11 @@ import { logAskQuestion } from "./diagnostics.js";
24
24
  const TOOL_NAME = "ask_user_question";
25
25
  /** Claude mirrors this as ASK_USER_QUESTION_TOOL_CHIP_WIDTH. */
26
26
  const CHIP_WIDTH = 12;
27
- /** Same fail-closed ceiling as the permission-gate ask. */
27
+ /** Cancel an unanswered question after 2 minutes. This is the structured
28
+ * QUESTION tool's own UX cap — NOT the permission-ask contract (gate asks
29
+ * wait indefinitely per Claude Code parity; see agent-safety.md). A stale
30
+ * question auto-cancels so the agent's turn cannot hang on a chip nobody
31
+ * is looking at; the user can always re-ask. */
28
32
  const ASK_TIMEOUT_MS = 120_000;
29
33
  /** Sentinel value representing the "Other" row in the multi-select toggle set. */
30
34
  const OTHER_KEY = "__other__";
@@ -523,7 +527,8 @@ async function askOne(ctx, q, qIndex) {
523
527
  return;
524
528
  }
525
529
  ctx.signal?.addEventListener("abort", onAbort, { once: true });
526
- // Best-effort timeout, mirroring the permission-gate ask fail-closed.
530
+ // Best-effort timeout: this surface's own 2-minute question cap (the
531
+ // permission-gate ask deliberately has none — see ASK_TIMEOUT_MS above).
527
532
  const timer = setTimeout(() => settle({ status: "cancelled" }), ASK_TIMEOUT_MS);
528
533
  void ctx.ui
529
534
  .custom((_tui, theme, keybindings, done) => {
@@ -24,7 +24,7 @@
24
24
  * pattern) so tests run against plain text. Renderer exceptions are swallowed
25
25
  * by pi (degrading to the built-in fallback), so everything stays total.
26
26
  */
27
- import { SettingsManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
+ import { SettingsManager, type ExtensionAPI, type ToolDefinition } from "@earendil-works/pi-coding-agent";
28
28
  import type { RenderTheme } from "./subagentRender.js";
29
29
  import { ToolRunTracker } from "./toolRuns.js";
30
30
  /** Lines of write content shown collapsed (mirrors Claude Code's preview). */
@@ -87,6 +87,17 @@ export interface RegisterCondensedToolsDeps {
87
87
  * image autoResize. Injectable so tests never touch the config dir.
88
88
  */
89
89
  loadSettings?: (cwd: string) => SettingsManager;
90
+ /**
91
+ * sandbox composition: when present, the bash definition is passed
92
+ * through this wrap before registration — the sandbox adds its schema field
93
+ * (dangerouslyDisableSandbox), wrap-on-execute, and violation annotation
94
+ * while condensed keeps its rendering. The compose fn is built by
95
+ * sandbox/session.ts (makeSandboxBashComposition) and is a no-op when the
96
+ * sandbox is disabled. Condensed registers LAST among the bash-defining
97
+ * sites, so composing HERE is what makes the sandbox actually reach the
98
+ * model's tool calls.
99
+ */
100
+ wrapBash?: (def: ToolDefinition) => ToolDefinition;
90
101
  }
91
102
  /**
92
103
  * Re-register the seven built-ins with condensed renderers. Returns the
@@ -291,15 +291,23 @@ export function registerCondensedTools(pi, deps = {}) {
291
291
  catch {
292
292
  toolOptions = { bash: {}, read: {} };
293
293
  }
294
- const buildDefinitions = (cwd) => ({
295
- read: createReadToolDefinition(cwd, toolOptions.read),
296
- bash: createBashToolDefinition(cwd, toolOptions.bash),
297
- edit: createEditToolDefinition(cwd),
298
- write: createWriteToolDefinition(cwd),
299
- grep: createGrepToolDefinition(cwd),
300
- find: createFindToolDefinition(cwd),
301
- ls: createLsToolDefinition(cwd),
302
- });
294
+ const buildDefinitions = (cwd) => {
295
+ const defs = {
296
+ read: createReadToolDefinition(cwd, toolOptions.read),
297
+ bash: createBashToolDefinition(cwd, toolOptions.bash),
298
+ edit: createEditToolDefinition(cwd),
299
+ write: createWriteToolDefinition(cwd),
300
+ grep: createGrepToolDefinition(cwd),
301
+ find: createFindToolDefinition(cwd),
302
+ ls: createLsToolDefinition(cwd),
303
+ };
304
+ // the sandbox wraps the bash DEFINITION (not the registration
305
+ // wrapper) so the cwd-delegating execute below still lands on the
306
+ // sandbox-aware tool for every cwd the session visits.
307
+ if (defs.bash && deps.wrapBash)
308
+ defs.bash = deps.wrapBash(defs.bash);
309
+ return defs;
310
+ };
303
311
  const definitionsByCwd = new Map();
304
312
  const definitionsFor = (cwd) => {
305
313
  let defs = definitionsByCwd.get(cwd);
@@ -127,6 +127,12 @@ export interface ContextBrief {
127
127
  * The opt-in mining beat fires ONLY on an explicit 0.
128
128
  */
129
129
  repoDecisionCount?: number;
130
+ /**
131
+ * The signed-in developer's account email (additive; absent on older
132
+ * backends). Feeds the OTel export's `user.email` attribute when the
133
+ * launcher did not already forward one.
134
+ */
135
+ userEmail?: string;
130
136
  }
131
137
  /**
132
138
  * Attribution headers for the model proxy (YAG-471). On the SERVER side these
@@ -26,8 +26,8 @@ export interface HookEntry {
26
26
  export interface HookGroup {
27
27
  matcher?: string;
28
28
  hooks: HookEntry[];
29
- /** Where this group was loaded from. Project-level groups are gated on workspace trust. */
30
- _source?: "user" | "project";
29
+ /** Where this group was loaded from. Project AND local groups are gated on workspace trust. */
30
+ _source?: "user" | "project" | "local";
31
31
  }
32
32
  /** The hooks section of config.json. */
33
33
  export type HooksConfig = Record<string, HookGroup[]>;
@@ -55,7 +55,7 @@ export interface HookRunner {
55
55
  preToolUse(toolName: string, input: Record<string, unknown>, cwd: string, trusted?: boolean): Promise<PreToolUseHookResult>;
56
56
  permissionRequest(toolName: string, input: Record<string, unknown>, cwd: string, trusted?: boolean): Promise<PermissionRequestHookResult>;
57
57
  }
58
- /** Read and merge hooks config from user and project files. Pure I/O, fail-soft. */
58
+ /** Read and merge hooks config from user, project, and local files. Pure I/O, fail-soft. */
59
59
  export declare function loadHooksConfig(userHome?: string, cwd?: string, env?: NodeJS.ProcessEnv): HooksConfig;
60
60
  /**
61
61
  * Check if a matcher matches a tool name. Matches Claude Code / Codex:
@@ -36,7 +36,7 @@ const SUPPORTED_EVENTS = [
36
36
  // ---------------------------------------------------------------------------
37
37
  // Config loading
38
38
  // ---------------------------------------------------------------------------
39
- /** Read and merge hooks config from user and project files. Pure I/O, fail-soft. */
39
+ /** Read and merge hooks config from user, project, and local files. Pure I/O, fail-soft. */
40
40
  export function loadHooksConfig(userHome = homedir(), cwd = process.cwd(), env = process.env) {
41
41
  if (env.YAGNI_CODE_EVAL_MODE === "1")
42
42
  return {};
@@ -47,6 +47,13 @@ export function loadHooksConfig(userHome = homedir(), cwd = process.cwd(), env =
47
47
  // Project-level: .yagni-code/config.json — tagged for trust gating at execution time
48
48
  const projectPath = join(cwd, ".yagni-code", "config.json");
49
49
  mergeHooksFromFile(merged, projectPath, "project");
50
+ // Local-level: .yagni-code/config.local.json — the personal per-project
51
+ // tier. Trust-gated like project (NOT always-trusted): a hostile repo can
52
+ // COMMIT a local file — the gitignore convention only covers untracked
53
+ // files. Deliberately stricter than Claude Code, which does not
54
+ // trust-gate its localSettings hooks.
55
+ const localPath = join(cwd, ".yagni-code", "config.local.json");
56
+ mergeHooksFromFile(merged, localPath, "local");
50
57
  return merged;
51
58
  }
52
59
  function mergeHooksFromFile(merged, path, source) {
@@ -68,8 +75,20 @@ function mergeHooksFromFile(merged, path, source) {
68
75
  }
69
76
  }
70
77
  }
71
- catch {
72
- // Fail-soft: missing or malformed config is logged and skipped.
78
+ catch (err) {
79
+ // Fail-soft: missing or malformed config is skipped and LOGGED: a
80
+ // corrupt local/project config silently dropping its hooks (including
81
+ // deny-enforcing ones) with zero signal is undiagnosable. Path + error
82
+ // class only, matching loadConfig.ts's warning behavior.
83
+ logEvent({
84
+ source: "hooks",
85
+ level: "warn",
86
+ event: "config_warning",
87
+ fields: {
88
+ path,
89
+ error: err instanceof Error ? err.constructor.name : typeof err,
90
+ },
91
+ });
73
92
  }
74
93
  }
75
94
  function isValidHookGroup(value) {
@@ -297,11 +316,17 @@ function logHookEvent(env, payload) {
297
316
  fields,
298
317
  });
299
318
  }
300
- /** Filter hook groups by workspace trust: project-level groups are skipped when untrusted. */
319
+ /**
320
+ * Filter hook groups by workspace trust: project AND local groups are
321
+ * skipped when untrusted. Local is gated exactly like project (not like the
322
+ * always-trusted user tier) because a hostile repo can COMMIT a
323
+ * config.local.json — the global-gitignore convention only covers
324
+ * untracked files. Deliberately stricter than Claude Code's localSettings.
325
+ */
301
326
  function filterByTrust(groups, isTrusted) {
302
327
  if (isTrusted)
303
328
  return groups;
304
- return groups.filter((g) => g._source !== "project");
329
+ return groups.filter((g) => g._source !== "project" && g._source !== "local");
305
330
  }
306
331
  /**
307
332
  * Create a HookRunner for injection into the permission gate. The `isTrusted`