@yagni-app/code-staging 1.1.4-staging.1425.1 → 1.1.4-staging.1429.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/doctor.d.ts CHANGED
@@ -147,6 +147,29 @@ export interface McpProbe {
147
147
  * pending-approval cases read as `warn`, config errors as `fail` (non-required).
148
148
  */
149
149
  export declare function checkMcpConfig(probe: McpProbe): CheckResult;
150
+ /** Config-only snapshot of the Claude Code MCP bridge (claudeImport.ts). */
151
+ export interface ClaudeImportProbe {
152
+ /** `~/.claude.json` exists (and the bridge is not disabled). */
153
+ exists: boolean;
154
+ /** Set when the file exists but could not be parsed. */
155
+ parseError?: string;
156
+ /** The same one-line notice the session-start banner shows, when the gap is non-empty. */
157
+ notice?: string;
158
+ /** Claude Code server names not configured here. */
159
+ importable: string[];
160
+ /** claude.ai connectors with a public MCP equivalent not configured here. */
161
+ connectorSuggestions: string[];
162
+ /** Claude Code has `.mcp.json` approvals for this checkout that ours lack. */
163
+ approvals: boolean;
164
+ /** The probe itself failed (bundled extension broken, config unreadable) while ~/.claude.json exists. */
165
+ probeError?: string;
166
+ }
167
+ /**
168
+ * Advisory (never required) line for the Claude Code bridge. Absent entirely
169
+ * when there is no `~/.claude.json` — a machine without Claude Code has
170
+ * nothing to say here. Reads config only; never connects.
171
+ */
172
+ export declare function checkClaudeImport(probe: ClaudeImportProbe): CheckResult | null;
150
173
  /** What the Windows bash probe found (pi needs a bash — Git Bash — on win32). */
151
174
  export interface BashProbe {
152
175
  found: boolean;
@@ -179,6 +202,8 @@ export interface DoctorDeps {
179
202
  probeLatestVersion?: () => Promise<string | null>;
180
203
  /** MCP config snapshot (config-only, no connections). */
181
204
  probeMcp?: () => Promise<McpProbe>;
205
+ /** Claude Code bridge snapshot (config-only). */
206
+ probeClaudeImport?: () => Promise<ClaudeImportProbe>;
182
207
  /** Certificate-authority configuration of the running process. */
183
208
  probeCaTrust?: () => CaTrustProbe;
184
209
  /** OTel gate resolution (env / workspace / repo settings); tests stub it. */
package/dist/doctor.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * Advisory checks (loose perms, missing `gh`) never flip the exit code.
14
14
  */
15
15
  import { existsSync, readFileSync, statSync } from "node:fs";
16
+ import { homedir } from "node:os";
16
17
  import { delimiter, join } from "node:path";
17
18
  import { credentialsDir } from "./credentials.js";
18
19
  import { currentCliVersion, fetchLatestVersion, isNewerVersion } from "./upgrade.js";
@@ -20,6 +21,7 @@ import { classifyTokenExpiry } from "./launch.js";
20
21
  import { otelChildEnv, resolveOtelLaunchWithWorkspace } from "./otel.js";
21
22
  import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir, resolveTelemetryProbePath } from "./paths.js";
22
23
  import { readActiveProfile } from "./profiles.js";
24
+ import { DISTRIBUTION } from "./distribution.js";
23
25
  import { resolveMcpConfigPath } from "./mcpCommand.js";
24
26
  import { MIN_NODE_VERSION, nodeVersionSatisfies } from "./nodeVersion.js";
25
27
  import { EXTRA_CA_ENV, SYSTEM_CA_ENV, SYSTEM_CA_FLAG, childCaEnv, sessionExecArgv, findTlsCertError, nodeOptionsHaveSystemCa, systemCaEnabled, systemCaEnvSupported, systemCaFlagSupported, } from "./tlsTrust.js";
@@ -414,6 +416,52 @@ export function checkMcpConfig(probe) {
414
416
  required: false,
415
417
  };
416
418
  }
419
+ /**
420
+ * Advisory (never required) line for the Claude Code bridge. Absent entirely
421
+ * when there is no `~/.claude.json` — a machine without Claude Code has
422
+ * nothing to say here. Reads config only; never connects.
423
+ */
424
+ export function checkClaudeImport(probe) {
425
+ if (!probe.exists)
426
+ return null;
427
+ if (probe.probeError) {
428
+ return {
429
+ name: "claude code mcp",
430
+ status: "warn",
431
+ detail: `could not compare with ~/.claude.json (${probe.probeError})`,
432
+ hint: "run `yagni mcp add-from-claude` for the full error, or `yagni upgrade` if the extension is missing",
433
+ required: false,
434
+ };
435
+ }
436
+ if (probe.parseError) {
437
+ return {
438
+ name: "claude code mcp",
439
+ status: "warn",
440
+ detail: `~/.claude.json unreadable (${probe.parseError})`,
441
+ hint: "fix the file so Claude Code's MCP servers can be imported",
442
+ required: false,
443
+ };
444
+ }
445
+ const gaps = [];
446
+ if (probe.importable.length > 0) {
447
+ gaps.push(`${probe.importable.length} server${probe.importable.length === 1 ? "" : "s"} not imported (${probe.importable.join(", ")})`);
448
+ }
449
+ if (probe.approvals)
450
+ gaps.push("repo approvals not imported");
451
+ if (probe.connectorSuggestions.length > 0) {
452
+ gaps.push(`${probe.connectorSuggestions.length} claude.ai connector${probe.connectorSuggestions.length === 1 ? "" : "s"} with a public MCP (${probe.connectorSuggestions.join(", ")})`);
453
+ }
454
+ if (gaps.length === 0) {
455
+ return { name: "claude code mcp", status: "ok", detail: "in sync with ~/.claude.json", required: false };
456
+ }
457
+ return {
458
+ name: "claude code mcp",
459
+ status: "warn",
460
+ detail: gaps.join("; "),
461
+ hint: probe.notice ?? "run `yagni mcp add-from-claude`",
462
+ required: false,
463
+ };
464
+ }
417
465
  export function checkBash(probe) {
418
466
  if (!probe.found) {
419
467
  return {
@@ -501,6 +549,43 @@ function defaultProbeStateDir() {
501
549
  return { path, exists: false, mode: null };
502
550
  }
503
551
  }
552
+ async function defaultProbeClaudeImport(env = process.env) {
553
+ const none = { exists: false, importable: [], connectorSuggestions: [], approvals: false };
554
+ if (env.YAGNI_CODE_MCP_DISABLED === "1")
555
+ return none;
556
+ try {
557
+ const mod = (await import(resolveMcpConfigPath()));
558
+ if (typeof mod?.readClaudeMcpInventory !== "function" ||
559
+ typeof mod.claudeImportGap !== "function" ||
560
+ typeof mod.claudeImportNotice !== "function") {
561
+ return none;
562
+ }
563
+ const cwd = process.cwd();
564
+ const repoRoot = mod.resolveProjectRoot(cwd);
565
+ const inventory = mod.readClaudeMcpInventory({ cwd, repoRoot, env });
566
+ if (!inventory.exists)
567
+ return none;
568
+ if (inventory.parseError)
569
+ return { ...none, exists: true, parseError: inventory.parseError };
570
+ const loaded = mod.loadMcpServers(cwd, env);
571
+ const { state } = mod.readProjectApproval(repoRoot);
572
+ const gap = mod.claudeImportGap(inventory, loaded.servers, state, DISTRIBUTION.commandName);
573
+ return {
574
+ exists: true,
575
+ notice: mod.claudeImportNotice(gap, DISTRIBUTION.commandName),
576
+ importable: gap.importable.map((c) => c.name),
577
+ connectorSuggestions: gap.connectorSuggestions.map((c) => c.name),
578
+ approvals: gap.approvals !== undefined,
579
+ };
580
+ }
581
+ catch (err) {
582
+ // Advisory only: a probe that cannot run must never fail doctor — but a
583
+ // machine that HAS ~/.claude.json must not read as "no Claude Code".
584
+ if (!existsSync(join(homedir(), ".claude.json")))
585
+ return none;
586
+ return { ...none, exists: true, probeError: err instanceof Error ? err.message : String(err) };
587
+ }
588
+ }
504
589
  async function defaultProbeMcp(env = process.env) {
505
590
  const disabled = env.YAGNI_CODE_MCP_DISABLED === "1";
506
591
  if (disabled)
@@ -633,6 +718,7 @@ export async function gatherChecks(deps = {}) {
633
718
  const probeBackend = deps.probeBackend ?? defaultProbeBackend;
634
719
  const probeStateDir = deps.probeStateDir ?? defaultProbeStateDir;
635
720
  const probeMcp = deps.probeMcp ?? (() => defaultProbeMcp());
721
+ const probeClaudeImport = deps.probeClaudeImport ?? (() => defaultProbeClaudeImport());
636
722
  const probeCaTrust = deps.probeCaTrust ?? (() => defaultProbeCaTrust());
637
723
  const ghOnPath = deps.ghOnPath ?? (() => ghOnPathDefault());
638
724
  const platform = deps.platform ?? process.platform;
@@ -665,6 +751,9 @@ export async function gatherChecks(deps = {}) {
665
751
  checks.push(checkStateDir(probeStateDir()));
666
752
  checks.push(checkGh(ghOnPath()));
667
753
  checks.push(checkMcpConfig(await probeMcp()));
754
+ const claudeImport = checkClaudeImport(await probeClaudeImport());
755
+ if (claudeImport)
756
+ checks.push(claudeImport);
668
757
  const resolveOtel = deps.resolveOtel ??
669
758
  ((p) => resolveOtelLaunchWithWorkspace({
670
759
  env: process.env,
@@ -16,6 +16,7 @@
16
16
  import { Box, Container, Key, Text, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
17
17
  import { logEvent } from "./errorSink.js";
18
18
  import { listBgJobs } from "./bgJobs.js";
19
+ import { PanelBorder } from "./panelBorder.js";
19
20
  /** The content frame's horizontal padding (SandboxPanel's CONTENT_PADDING_X). */
20
21
  export const CONTENT_PADDING_X = 2;
21
22
  /** Max rendered output lines in the detail view. */
@@ -100,17 +101,6 @@ export function formatPanelSubtitle(jobs) {
100
101
  }
101
102
  // ---------------------------------------------------------------------------
102
103
  // The panel: SandboxPanel's structure (Container + Border + Box + Border).
103
- /** Full-width border line (the house pattern, color fn passed explicitly). */
104
- class Border {
105
- color;
106
- constructor(color) {
107
- this.color = color;
108
- }
109
- render(width) {
110
- return [this.color("─".repeat(Math.max(1, width)))];
111
- }
112
- invalidate() { }
113
- }
114
104
  export class BgJobsPanel extends Container {
115
105
  deps;
116
106
  theme;
@@ -138,9 +128,9 @@ export class BgJobsPanel extends Container {
138
128
  this.content.addChild(new Text("", 0, 0));
139
129
  this.content.addChild(new Text("", 0, 0));
140
130
  this.content.addChild(new Text("", 0, 0));
141
- this.addChild(new Border((s) => theme.fg("borderMuted", s)));
131
+ this.addChild(new PanelBorder((s) => theme.fg("borderMuted", s)));
142
132
  this.addChild(this.content);
143
- this.addChild(new Border((s) => theme.fg("borderMuted", s)));
133
+ this.addChild(new PanelBorder((s) => theme.fg("borderMuted", s)));
144
134
  this.rebuild();
145
135
  }
146
136
  dispose() {
@@ -3,9 +3,14 @@
3
3
  *
4
4
  * Renders three lines:
5
5
  * 1. folder · [worktree] · branch (git context; ~-path fallback off-repo)
6
- * 2. ↑in ↓out $cost · ctx% (session stats; context % is an integer)
6
+ * 2. ◆ tier · mode · ↑in ↓out $cost · ctx% (the rung in play, permission mode,
7
+ * session stats; context % is an integer)
7
8
  * 3. extension statuses (branding, todo counter, mode) joined by " · "
8
9
  *
10
+ * The tier segment is the footer's answer to "what am I driving on" now that
11
+ * the rung is a dial (/model, Ctrl+L, /tier): it reads `ctx.model` live on
12
+ * every render, so a switch shows the moment the footer repaints.
13
+ *
9
14
  * --- How to customize the status bar (for future tickets) ---
10
15
  *
11
16
  * LIFECYCLE: `ctx.ui.setFooter()` is NOT available at extension factory time.
@@ -42,6 +47,17 @@ import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earen
42
47
  import type { ChildUsageHandle } from "./childUsage.js";
43
48
  import type { ModeHolder, PermissionMode } from "./permission/gate.js";
44
49
  export declare const BRANCH_MAX_WIDTH = 60;
50
+ /** The glyph in front of the tier segment (the desktop start chips' rung mark). */
51
+ export declare const TIER_GLYPH = "\u25C6";
52
+ /**
53
+ * The footer's tier label for the model a session is on: the rate-card name
54
+ * for a selectable rung, the catalog name (or id) for anything else, null
55
+ * when there is no model yet. PURE.
56
+ */
57
+ export declare function footerTierLabel(model: {
58
+ id: string;
59
+ name?: string;
60
+ } | undefined): string | null;
45
61
  export declare function cyclePermissionMode(current: PermissionMode): PermissionMode;
46
62
  export declare function isShiftTab(data: string): boolean;
47
63
  export declare const GIT_MUTATING_PATTERN: RegExp;
@@ -94,7 +110,12 @@ export declare function detectGitInfo(cwd: string, home: string | undefined): Gi
94
110
  /** Pure line-builder, exported for tests. All data injected; colors via theme. */
95
111
  export declare function renderFooterLines(input: {
96
112
  git: GitInfo;
97
- /** Current permission mode; shown on line 2 to the left of the stats as "<mode> mode". */
113
+ /**
114
+ * The rung in play, already resolved to its label ("Advanced"); leads
115
+ * line 2 as "◆ Advanced". Null hides the segment (no model yet).
116
+ */
117
+ tier?: string | null;
118
+ /** Current permission mode; shown on line 2 after the tier as "<mode> mode". */
98
119
  mode?: PermissionMode | null;
99
120
  /** Formatted `+N -M` / `±`, or null when clean — appended to the branch as `[ … ]`. */
100
121
  diff?: string | null;
@@ -3,9 +3,14 @@
3
3
  *
4
4
  * Renders three lines:
5
5
  * 1. folder · [worktree] · branch (git context; ~-path fallback off-repo)
6
- * 2. ↑in ↓out $cost · ctx% (session stats; context % is an integer)
6
+ * 2. ◆ tier · mode · ↑in ↓out $cost · ctx% (the rung in play, permission mode,
7
+ * session stats; context % is an integer)
7
8
  * 3. extension statuses (branding, todo counter, mode) joined by " · "
8
9
  *
10
+ * The tier segment is the footer's answer to "what am I driving on" now that
11
+ * the rung is a dial (/model, Ctrl+L, /tier): it reads `ctx.model` live on
12
+ * every render, so a switch shows the moment the footer repaints.
13
+ *
9
14
  * --- How to customize the status bar (for future tickets) ---
10
15
  *
11
16
  * LIFECYCLE: `ctx.ui.setFooter()` is NOT available at extension factory time.
@@ -43,10 +48,23 @@ import { statSync } from "node:fs";
43
48
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
44
49
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
45
50
  import { createDiffStatCache, formatDiffStat } from "./diffStat.js";
51
+ import { isSelectableTier, tierLabel } from "./tierCommand.js";
46
52
  export const BRANCH_MAX_WIDTH = 60;
47
53
  const WORKTREE_MAX_WIDTH = 30;
48
54
  /** Section separator: single space + middle dot + single space. */
49
55
  const SEP = " · ";
56
+ /** The glyph in front of the tier segment (the desktop start chips' rung mark). */
57
+ export const TIER_GLYPH = "◆";
58
+ /**
59
+ * The footer's tier label for the model a session is on: the rate-card name
60
+ * for a selectable rung, the catalog name (or id) for anything else, null
61
+ * when there is no model yet. PURE.
62
+ */
63
+ export function footerTierLabel(model) {
64
+ if (!model)
65
+ return null;
66
+ return isSelectableTier(model.id) ? tierLabel(model.id) : model.name || model.id;
67
+ }
50
68
  const MODE_CYCLE = ["auto", "review", "plan"];
51
69
  export function cyclePermissionMode(current) {
52
70
  const idx = MODE_CYCLE.indexOf(current);
@@ -244,7 +262,7 @@ export function renderFooterLines(input, theme, width, padX = 0) {
244
262
  }
245
263
  }
246
264
  const line1 = pad + truncateToWidth(line1Parts.join(sep), contentWidth, dim("…"));
247
- // Line 2: [mode ·] ↑in ↓out $cost · ctx%
265
+ // Line 2: [◆ tier ·] [mode ·] ↑in ↓out $cost · ctx%
248
266
  // Child-process spend (subagents, advisor, /go) joins the driver totals so
249
267
  // the footer matches the per-child receipts and /cost's session scope.
250
268
  const child = input.childUsage;
@@ -261,6 +279,8 @@ export function renderFooterLines(input, theme, width, padX = 0) {
261
279
  const stats = statParts.join(" ");
262
280
  const percentText = input.contextPercent === null ? "?" : `${Math.round(input.contextPercent)}%`;
263
281
  const line2Parts = [];
282
+ if (input.tier)
283
+ line2Parts.push(theme.fg("accent", `${TIER_GLYPH} ${input.tier}`));
264
284
  if (input.mode) {
265
285
  const modeLabel = modeDisplay(input.mode);
266
286
  line2Parts.push(theme.fg(modeLabel.color, modeLabel.text) + dim(" (shift+tab to change)"));
@@ -306,6 +326,7 @@ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle, chil
306
326
  .map(([, text]) => text);
307
327
  return renderFooterLines({
308
328
  git: gitInfo(),
329
+ tier: footerTierLabel(ctx.model),
309
330
  mode: modeHolder?.get() ?? null,
310
331
  diff: formatDiffStat(diffStatCache?.get() ?? null),
311
332
  usage: collectUsage(ctx.sessionManager),
@@ -61,7 +61,8 @@ import { flushSpool as defaultFlushSpool } from "./spool.js";
61
61
  import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
62
62
  import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
63
63
  import { buildYagniProvider } from "./provider.js";
64
- import { orderDriverCatalog, registerStartTierGuard, registerTierCommand } from "./tierCommand.js";
64
+ import { orderDriverCatalog, registerStartTierGuard, registerTierCommand, trailReason } from "./tierCommand.js";
65
+ import { makeModelPickerListener } from "./modelPicker.js";
65
66
  import { registerChipEditor } from "./chipEditor.js";
66
67
  import { registerSlashCommandFilter } from "./slashCommandFilter.js";
67
68
  import { defaultMineBeatGit, fileMineBeatMarkers, maybeOfferMiningBeat as defaultMaybeOfferMiningBeat, } from "./mineBeat.js";
@@ -1186,11 +1187,12 @@ export async function registerYagni(pi, deps = {}) {
1186
1187
  // count as costHud's "Excludes N earlier /go runs." note.
1187
1188
  droppedSessionRuns,
1188
1189
  });
1189
- // The manual tier dial. `/model` (pi's own picker) now lists the four rungs
1190
- // because the driver catalog carries them; `/tier` is the tier-language
1191
- // sibling that reports and switches inline. The start guard is what keeps
1192
- // "movable" from becoming "parked on peak" — driver sessions only, since a
1193
- // child or eval lane names its tier on the command line.
1190
+ // The manual tier dial. `/model` and Ctrl+L open YAGNI's own picker (the
1191
+ // terminal-input listener wired on session_start below, modelPicker.ts);
1192
+ // `/tier` is the tier-language sibling that reports and switches inline.
1193
+ // The start guard is what keeps "movable" from becoming "parked on peak" —
1194
+ // driver sessions only, since a child or eval lane names its tier on the
1195
+ // command line.
1194
1196
  if (!evalMode && driver) {
1195
1197
  registerTierCommand(pi);
1196
1198
  registerStartTierGuard(pi);
@@ -1677,10 +1679,11 @@ export async function registerYagni(pi, deps = {}) {
1677
1679
  const mastheadCwd = formatCwd(process.cwd(), process.env.HOME);
1678
1680
  ctx.ui?.setHeader?.((_tui, theme) => new Text(buildMastheadString(theme, { version: mastheadVersion, cwd: mastheadCwd })));
1679
1681
  // Replace the built-in footer with the YAGNI status bar: folder +
1680
- // [worktree] + branch on line 1, model + token/cost stats + integer
1681
- // context % on line 2, and extension statuses (brand, todos, mode) on
1682
- // line 3. The factory captures ctx so the footer can read session data
1683
- // (token stats, context usage) that isn't on the footerData provider.
1682
+ // [worktree] + branch on line 1; the tier in play, the permission mode,
1683
+ // token/cost stats and integer context % on line 2; and extension
1684
+ // statuses (brand, todos, mode) on line 3. The factory captures ctx so
1685
+ // the footer can read session data (the model, token stats, context
1686
+ // usage) that isn't on the footerData provider.
1684
1687
  ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder, footerInvalidateHandle, childUsage)(tui, theme, footerData));
1685
1688
  ctx.ui?.onTerminalInput?.((data) => {
1686
1689
  if (isShiftTab(data)) {
@@ -1690,6 +1693,24 @@ export async function registerYagni(pi, deps = {}) {
1690
1693
  }
1691
1694
  return undefined;
1692
1695
  });
1696
+ // YAGNI's own model picker in place of pi's: Ctrl+L and Enter on a
1697
+ // `/model` line are consumed here, before the editor sees them, and
1698
+ // mount modelPicker.ts's panel. Driver sessions only, like /tier.
1699
+ if (!evalMode && driver && ctx.hasUI) {
1700
+ try {
1701
+ ctx.ui?.onTerminalInput?.(makeModelPickerListener(pi, ctx, { footer: footerInvalidateHandle }));
1702
+ }
1703
+ catch (err) {
1704
+ // The picker is a convenience over /tier; it must never break
1705
+ // session start. But a "Ctrl+L does nothing" report needs a trail.
1706
+ logEvent({
1707
+ source: "tier",
1708
+ level: "warn",
1709
+ event: "model_picker_register_failed",
1710
+ fields: { reason: trailReason(err) },
1711
+ });
1712
+ }
1713
+ }
1693
1714
  // Label the collapsed chain-of-thought line so users know it is reasoning
1694
1715
  // and how to reveal the full trace. Harmless when reasoning is expanded
1695
1716
  // (the label only shows on hidden thinking blocks). Fails closed to pi's
@@ -18,9 +18,11 @@
18
18
  * high port (or a fixed `oauth.callbackPort`), redirect_uri path `/callback`,
19
19
  * and validate the returned `state` to prevent CSRF.
20
20
  */
21
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
21
22
  import { type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
22
23
  import type { AuthorizationServerMetadata, OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
23
24
  import type { McpHttpServerConfig } from "./config.js";
25
+ import { type StoredOAuthEntry } from "./authStore.js";
24
26
  export interface AuthDeps {
25
27
  /** Open a URL in the browser; injected for tests, default is a best-effort opener. */
26
28
  openUrl?: (url: string) => Promise<void>;
@@ -57,8 +59,12 @@ export declare const DEFAULT_REDIRECT_PORT = 42000;
57
59
  /**
58
60
  * Build a provider for a server at connect time. The redirect URL is only used
59
61
  * when the SDK needs to (re)authorize; refresh- and access-token paths ignore
60
- * it, so a stable default port is fine here. `authenticate()` binds its own
61
- * fresh port and builds a dedicated provider for the interactive flow.
62
+ * it. A 401 at connect time with no stored client makes the SDK run dynamic
63
+ * client registration through THIS provider, so the port it advertises is the
64
+ * one the server pins — `authenticate()` reads it back from the auth store
65
+ * (`redirectUri`) and reuses it. Precedence: explicit `redirectUrl` >
66
+ * `oauth.callbackPort` > the redirect a previous registration pinned > the
67
+ * stable default port.
62
68
  */
63
69
  export declare function authProviderForServer(serverName: string, config: McpHttpServerConfig, redirectUrl?: string): YagniAuthProvider;
64
70
  /**
@@ -93,8 +99,51 @@ export declare function authenticate(serverName: string, config: McpHttpServerCo
93
99
  timeoutMs?: number;
94
100
  serverUrl?: string;
95
101
  }): Promise<OAuthResult>;
96
- /** Find a free loopback port (OS-assigned). */
97
- export declare function findFreePort(): Promise<number>;
102
+ type CallbackHandler = (req: IncomingMessage, res: ServerResponse) => void;
103
+ export interface RedirectListener {
104
+ server: ReturnType<typeof createServer>;
105
+ port: number;
106
+ /** Install the callback handler (waitForCode); until then non-marker requests get 404. */
107
+ arm(handler: CallbackHandler): void;
108
+ /** Set when the pinned port could not be bound and a fresh one was bound instead: the bind error. */
109
+ pinnedBindError?: string;
110
+ }
111
+ /**
112
+ * Every listener answers this path with a fixed marker, so a second flow that
113
+ * finds the pinned port taken can tell "one of our own callback listeners"
114
+ * (refuse: that flow's client must not be dropped from under it) from "some
115
+ * other program" (fresh port, re-register). Loopback only, by design: the
116
+ * answer is a constant and names nothing — not the server, not the flow.
117
+ */
118
+ export declare const LISTENER_MARKER_PATH = "/.yagni-oauth-listener";
119
+ export declare const LISTENER_MARKER_HEADER = "x-yagni-oauth-listener";
120
+ /**
121
+ * Bind the loopback listener for an interactive flow and return the socket
122
+ * we hold, so the redirect_uri we advertise is the port we are listening on
123
+ * — decided by an actual bind, not a probe. A fixed `oauth.callbackPort`
124
+ * always wins (the operator registered it; a bind failure there is the
125
+ * error, not a fallback). Otherwise reuse the port a previous dynamic
126
+ * registration pinned when it can still be bound — the authorization server
127
+ * will reject any other redirect_uri for that client. When it cannot: one of
128
+ * our own listeners holding it means a concurrent flow for this server (an
129
+ * error; its client stays); anything else means a fresh OS-assigned port,
130
+ * and the caller re-registers the client. The probe's verdict is logged as
131
+ * `oauth_pinned_port_probe` so the later `oauth_client_reregister` reason
132
+ * is auditable.
133
+ */
134
+ export declare function bindRedirectListener(serverName: string, config: McpHttpServerConfig, stored: Pick<StoredOAuthEntry, "clientId" | "redirectUri"> | undefined): Promise<RedirectListener>;
135
+ export interface ListenerProbe {
136
+ /** `ours`: one of our callback listeners answered; `foreign`: something else answered; `no_answer`: refused, hung, or garbled. */
137
+ outcome: "ours" | "foreign" | "no_answer";
138
+ /** What the probe saw, for the log. */
139
+ detail: string;
140
+ }
141
+ /** Ask whoever holds `port` whether it is one of our callback listeners. Bounded (500ms), never throws. */
142
+ export declare function probeListener(port: number): Promise<ListenerProbe>;
143
+ /** Bind `port` (0 = OS-assigned) on 127.0.0.1; rejects when it cannot be bound. */
144
+ export declare function listenOnLoopback(port: number): Promise<RedirectListener>;
145
+ /** The port of a stored loopback redirect_uri, or undefined when it is not ours to parse. */
146
+ export declare function portOfRedirectUri(redirectUri: string | undefined): number | undefined;
98
147
  export declare function buildRedirectUri(port: number): string;
99
148
  type CodeOutcomeEvent = "oauth_callback_code" | "oauth_callback_error" | "oauth_callback_state_mismatch" | "oauth_timeout" | "oauth_cancelled";
100
149
  export interface WaitForCodeOpts {