@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 +25 -0
- package/dist/doctor.js +89 -0
- package/dist/extension/bgJobsPanel.js +3 -13
- package/dist/extension/footer.d.ts +23 -2
- package/dist/extension/footer.js +23 -2
- package/dist/extension/index.js +31 -10
- package/dist/extension/mcp/auth.d.ts +53 -4
- package/dist/extension/mcp/auth.js +268 -91
- package/dist/extension/mcp/authStore.d.ts +9 -0
- package/dist/extension/mcp/claudeImport.d.ts +147 -0
- package/dist/extension/mcp/claudeImport.js +295 -0
- package/dist/extension/mcp/cliConfig.d.ts +2 -1
- package/dist/extension/mcp/cliConfig.js +2 -1
- package/dist/extension/mcp/config.d.ts +12 -1
- package/dist/extension/mcp/config.js +8 -3
- package/dist/extension/mcp/startup.d.ts +6 -0
- package/dist/extension/mcp/startup.js +28 -1
- package/dist/extension/modelPicker.d.ts +132 -0
- package/dist/extension/modelPicker.js +244 -0
- package/dist/extension/panelBorder.d.ts +13 -0
- package/dist/extension/panelBorder.js +17 -0
- package/dist/extension/sandbox/panel.js +3 -14
- package/dist/extension/tierCommand.d.ts +52 -17
- package/dist/extension/tierCommand.js +103 -67
- package/dist/launch.js +4 -0
- package/dist/mcpCommand.d.ts +70 -0
- package/dist/mcpCommand.js +141 -74
- package/package.json +2 -2
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
|
|
131
|
+
this.addChild(new PanelBorder((s) => theme.fg("borderMuted", s)));
|
|
142
132
|
this.addChild(this.content);
|
|
143
|
-
this.addChild(new
|
|
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%
|
|
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
|
-
/**
|
|
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;
|
package/dist/extension/footer.js
CHANGED
|
@@ -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%
|
|
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),
|
package/dist/extension/index.js
CHANGED
|
@@ -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`
|
|
1190
|
-
//
|
|
1191
|
-
// sibling that reports and switches inline.
|
|
1192
|
-
// "movable" from becoming "parked on peak" —
|
|
1193
|
-
// child or eval lane names its tier on the
|
|
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
|
|
1681
|
-
// context % on line 2
|
|
1682
|
-
// line 3. The factory captures ctx so
|
|
1683
|
-
//
|
|
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
|
|
61
|
-
*
|
|
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
|
-
|
|
97
|
-
export
|
|
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 {
|