gsd-pi 2.71.0-dev.7a61d89 → 2.71.0-dev.d4d916a
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/resources/extensions/gsd/auto/phases.js +1 -1
- package/dist/resources/extensions/gsd/auto/session.js +3 -0
- package/dist/resources/extensions/gsd/auto-model-selection.js +10 -2
- package/dist/resources/extensions/gsd/auto-start.js +30 -6
- package/dist/resources/extensions/gsd/auto-worktree.js +1 -1
- package/dist/resources/extensions/gsd/bootstrap/register-shortcuts.js +2 -5
- package/dist/resources/extensions/gsd/commands/handlers/core.js +11 -0
- package/dist/resources/extensions/gsd/notification-overlay.js +26 -12
- package/dist/resources/extensions/gsd/notification-store.js +5 -4
- package/dist/resources/extensions/gsd/session-model-override.js +25 -0
- package/dist/resources/extensions/gsd/shortcut-defs.js +7 -1
- package/dist/resources/extensions/ollama/index.js +13 -5
- package/dist/startup-model-validation.d.ts +0 -1
- package/dist/startup-model-validation.js +6 -2
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
- package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +1 -1
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/package.json +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver-initial-model-auth.test.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/model-resolver-initial-model-auth.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/model-resolver-initial-model-auth.test.js +64 -0
- package/packages/pi-coding-agent/dist/core/model-resolver-initial-model-auth.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.js +22 -18
- package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/model-resolver.test.d.ts +8 -0
- package/packages/pi-coding-agent/dist/core/model-resolver.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/model-resolver.test.js +75 -0
- package/packages/pi-coding-agent/dist/core/model-resolver.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/login-dialog.test.d.ts +2 -0
- package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/login-dialog.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/login-dialog.test.js +13 -0
- package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/login-dialog.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.d.ts +4 -0
- package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.js +24 -2
- package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js +9 -2
- package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/model-controller.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/model-controller.js +6 -1
- package/packages/pi-coding-agent/dist/modes/interactive/controllers/model-controller.js.map +1 -1
- package/packages/pi-coding-agent/src/core/model-resolver-initial-model-auth.test.ts +78 -0
- package/packages/pi-coding-agent/src/core/model-resolver.test.ts +85 -0
- package/packages/pi-coding-agent/src/core/model-resolver.ts +22 -18
- package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/login-dialog.test.ts +24 -0
- package/packages/pi-coding-agent/src/modes/interactive/components/login-dialog.ts +30 -2
- package/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts +15 -6
- package/packages/pi-coding-agent/src/modes/interactive/controllers/model-controller.ts +6 -1
- package/src/resources/extensions/gsd/auto/loop-deps.ts +2 -0
- package/src/resources/extensions/gsd/auto/phases.ts +2 -0
- package/src/resources/extensions/gsd/auto/session.ts +3 -0
- package/src/resources/extensions/gsd/auto-model-selection.ts +9 -1
- package/src/resources/extensions/gsd/auto-start.ts +37 -6
- package/src/resources/extensions/gsd/auto-worktree.ts +1 -1
- package/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts +2 -5
- package/src/resources/extensions/gsd/commands/handlers/core.ts +12 -0
- package/src/resources/extensions/gsd/notification-overlay.ts +27 -11
- package/src/resources/extensions/gsd/notification-store.ts +5 -4
- package/src/resources/extensions/gsd/session-model-override.ts +36 -0
- package/src/resources/extensions/gsd/shortcut-defs.ts +8 -1
- package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +25 -9
- package/src/resources/extensions/gsd/tests/format-shortcut.test.ts +16 -0
- package/src/resources/extensions/gsd/tests/integration/auto-worktree-milestone-merge.test.ts +66 -1
- package/src/resources/extensions/gsd/tests/model-isolation.test.ts +36 -51
- package/src/resources/extensions/gsd/tests/register-shortcuts.test.ts +3 -2
- package/src/resources/extensions/gsd/tests/session-model-override.test.ts +35 -0
- package/src/resources/extensions/ollama/index.ts +13 -3
- package/src/resources/extensions/ollama/ollama-status-indicator.test.ts +28 -0
- /package/dist/web/standalone/.next/static/{ug91LJa0m7OdzrTVaz_48 → IRnpNeY-_eO7SxKBIkTbL}/_buildManifest.js +0 -0
- /package/dist/web/standalone/.next/static/{ug91LJa0m7OdzrTVaz_48 → IRnpNeY-_eO7SxKBIkTbL}/_ssgManifest.js +0 -0
|
@@ -504,27 +504,31 @@ export async function findInitialModel(options: {
|
|
|
504
504
|
|
|
505
505
|
// 3. Try saved default from settings
|
|
506
506
|
if (defaultProvider && defaultModelId) {
|
|
507
|
-
|
|
508
|
-
if (
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
507
|
+
// Guard against stale settings defaults: only use the saved provider/model
|
|
508
|
+
// if the provider is actually request-ready (auth/OAuth/CLI ready).
|
|
509
|
+
if (modelRegistry.isProviderRequestReady(defaultProvider)) {
|
|
510
|
+
const found = modelRegistry.find(defaultProvider, defaultModelId);
|
|
511
|
+
if (found) {
|
|
512
|
+
// Check if the provider's recommended default is a higher-capability variant
|
|
513
|
+
// of the saved model (e.g. saved "claude-opus-4-6" vs recommended "claude-opus-4-6-extended").
|
|
514
|
+
// If so, prefer the recommended variant to avoid using a smaller context window (#1125).
|
|
515
|
+
const recommendedId = defaultModelPerProvider[defaultProvider as KnownProvider];
|
|
516
|
+
if (recommendedId && recommendedId !== defaultModelId && recommendedId.startsWith(defaultModelId)) {
|
|
517
|
+
const recommended = modelRegistry.find(defaultProvider, recommendedId);
|
|
518
|
+
if (recommended) {
|
|
519
|
+
model = recommended;
|
|
520
|
+
if (defaultThinkingLevel) {
|
|
521
|
+
thinkingLevel = defaultThinkingLevel;
|
|
522
|
+
}
|
|
523
|
+
return { model, thinkingLevel, fallbackMessage: undefined };
|
|
519
524
|
}
|
|
520
|
-
return { model, thinkingLevel, fallbackMessage: undefined };
|
|
521
525
|
}
|
|
526
|
+
model = found;
|
|
527
|
+
if (defaultThinkingLevel) {
|
|
528
|
+
thinkingLevel = defaultThinkingLevel;
|
|
529
|
+
}
|
|
530
|
+
return { model, thinkingLevel, fallbackMessage: undefined };
|
|
522
531
|
}
|
|
523
|
-
model = found;
|
|
524
|
-
if (defaultThinkingLevel) {
|
|
525
|
-
thinkingLevel = defaultThinkingLevel;
|
|
526
|
-
}
|
|
527
|
-
return { model, thinkingLevel, fallbackMessage: undefined };
|
|
528
532
|
}
|
|
529
533
|
}
|
|
530
534
|
|
package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/login-dialog.test.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildAuthUrlPresentation } from "../login-dialog.js";
|
|
4
|
+
|
|
5
|
+
describe("LoginDialogComponent", () => {
|
|
6
|
+
test("shows the full OAuth URL when the hyperlink label is truncated", () => {
|
|
7
|
+
const presentation = buildAuthUrlPresentation(
|
|
8
|
+
"https://auth.example.com/device?code=ABCD-1234&callback=oauth&state=needs-full-visibility",
|
|
9
|
+
52,
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
assert.notEqual(
|
|
13
|
+
presentation.displayUrl,
|
|
14
|
+
"https://auth.example.com/device?code=ABCD-1234&callback=oauth&state=needs-full-visibility",
|
|
15
|
+
"narrow terminals should still truncate the hyperlink label",
|
|
16
|
+
);
|
|
17
|
+
assert.ok(presentation.fullUrlLines.length > 1, "truncated URLs should expose wrapped full-url lines");
|
|
18
|
+
assert.match(presentation.fullUrlLines[0] ?? "", /https:\/\/auth\.example\.com\/device\?code=ABCD-1234&/);
|
|
19
|
+
assert.match(
|
|
20
|
+
presentation.fullUrlLines[presentation.fullUrlLines.length - 1] ?? "",
|
|
21
|
+
/state=needs-full-visibility/,
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -7,6 +7,27 @@ import { theme } from "../theme/theme.js";
|
|
|
7
7
|
import { DynamicBorder } from "./dynamic-border.js";
|
|
8
8
|
import { keyHint } from "./keybinding-hints.js";
|
|
9
9
|
|
|
10
|
+
function wrapPlainText(text: string, width: number): string[] {
|
|
11
|
+
const lines: string[] = [];
|
|
12
|
+
const safeWidth = Math.max(1, width);
|
|
13
|
+
for (let idx = 0; idx < text.length; idx += safeWidth) {
|
|
14
|
+
lines.push(text.slice(idx, idx + safeWidth));
|
|
15
|
+
}
|
|
16
|
+
return lines.length > 0 ? lines : [""];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildAuthUrlPresentation(url: string, terminalColumns: number): {
|
|
20
|
+
displayUrl: string;
|
|
21
|
+
fullUrlLines: string[];
|
|
22
|
+
} {
|
|
23
|
+
const maxUrlWidth = Math.max(20, terminalColumns - 4);
|
|
24
|
+
const displayUrl = truncateToWidth(url, maxUrlWidth);
|
|
25
|
+
return {
|
|
26
|
+
displayUrl,
|
|
27
|
+
fullUrlLines: displayUrl === url ? [] : wrapPlainText(url, maxUrlWidth),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
10
31
|
/**
|
|
11
32
|
* Login dialog component - replaces editor during OAuth login flow.
|
|
12
33
|
*
|
|
@@ -124,14 +145,21 @@ export class LoginDialogComponent extends Container implements Focusable {
|
|
|
124
145
|
|
|
125
146
|
// Truncate the visible URL text so it never wraps (which would break
|
|
126
147
|
// the OSC 8 hyperlink). The full URL is still the link target.
|
|
127
|
-
const
|
|
128
|
-
const displayUrl = truncateToWidth(url, maxUrlWidth);
|
|
148
|
+
const { displayUrl, fullUrlLines } = buildAuthUrlPresentation(url, this.tui.terminal.columns);
|
|
129
149
|
const urlLink = `\x1b]8;;${url}\x07${theme.fg("accent", displayUrl)}\x1b]8;;\x07`;
|
|
130
150
|
this.contentContainer.addChild(new Text(urlLink, 1, 0));
|
|
131
151
|
|
|
132
152
|
const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open";
|
|
133
153
|
this.contentContainer.addChild(new Text(theme.fg("dim", clickHint), 1, 0));
|
|
134
154
|
|
|
155
|
+
if (fullUrlLines.length > 0) {
|
|
156
|
+
this.contentContainer.addChild(new Spacer(1));
|
|
157
|
+
this.contentContainer.addChild(new Text(theme.fg("dim", "Full URL:"), 1, 0));
|
|
158
|
+
for (const line of fullUrlLines) {
|
|
159
|
+
this.contentContainer.addChild(new Text(theme.fg("dim", line), 1, 0));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
135
163
|
if (instructions) {
|
|
136
164
|
this.contentContainer.addChild(new Spacer(1));
|
|
137
165
|
this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0));
|
|
@@ -120,7 +120,12 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|
|
120
120
|
this.settingsManager = settingsManager;
|
|
121
121
|
this.modelRegistry = modelRegistry;
|
|
122
122
|
this.scopedModels = scopedModels;
|
|
123
|
-
|
|
123
|
+
// Only land in "scoped" view when at least one scoped model has working
|
|
124
|
+
// auth — otherwise the user would see an empty picker (#unconfigured-models).
|
|
125
|
+
const hasReadyScopedModel = scopedModels.some((scoped) =>
|
|
126
|
+
modelRegistry.isProviderRequestReady(scoped.model.provider),
|
|
127
|
+
);
|
|
128
|
+
this.scope = hasReadyScopedModel ? "scoped" : "all";
|
|
124
129
|
this.onSelectCallback = onSelect;
|
|
125
130
|
this.onCancelCallback = onCancel;
|
|
126
131
|
|
|
@@ -215,12 +220,16 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
|
|
215
220
|
}
|
|
216
221
|
|
|
217
222
|
this.allModels = this.sortModelsWithinProvider(models);
|
|
223
|
+
// Scoped models must also be filtered by provider readiness so users
|
|
224
|
+
// can't pick a scoped model whose provider has no API key / OAuth.
|
|
218
225
|
this.scopedModelItems = this.sortModelsWithinProvider(
|
|
219
|
-
this.scopedModels
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
226
|
+
this.scopedModels
|
|
227
|
+
.filter((scoped) => this.modelRegistry.isProviderRequestReady(scoped.model.provider))
|
|
228
|
+
.map((scoped) => ({
|
|
229
|
+
provider: scoped.model.provider,
|
|
230
|
+
id: scoped.model.id,
|
|
231
|
+
model: scoped.model,
|
|
232
|
+
})),
|
|
224
233
|
);
|
|
225
234
|
this.activeModels = this.scope === "scoped" ? this.scopedModelItems : this.allModels;
|
|
226
235
|
this.filteredModels = this.activeModels;
|
|
@@ -52,7 +52,12 @@ export async function findExactModelMatch(host: any, searchTerm: string): Promis
|
|
|
52
52
|
|
|
53
53
|
export async function getModelCandidates(host: any): Promise<Model<any>[]> {
|
|
54
54
|
if (host.session.scopedModels.length > 0) {
|
|
55
|
-
|
|
55
|
+
// Filter scoped models by provider auth readiness so callers like
|
|
56
|
+
// findExactModelMatch can't resolve a scoped-but-unconfigured model.
|
|
57
|
+
const registry = host.session.modelRegistry;
|
|
58
|
+
return host.session.scopedModels
|
|
59
|
+
.filter((scoped: any) => registry.isProviderRequestReady(scoped.model.provider))
|
|
60
|
+
.map((scoped: any) => scoped.model);
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
host.session.modelRegistry.refresh();
|
|
@@ -211,6 +211,8 @@ export interface LoopDeps {
|
|
|
211
211
|
verbose: boolean,
|
|
212
212
|
startModel: { provider: string; id: string } | null,
|
|
213
213
|
retryContext?: { isRetry: boolean; previousTier?: string },
|
|
214
|
+
isAutoMode?: boolean,
|
|
215
|
+
sessionModelOverride?: { provider: string; id: string } | null,
|
|
214
216
|
) => Promise<{
|
|
215
217
|
routing: { tier: string; modelDowngraded: boolean } | null;
|
|
216
218
|
appliedModel: { provider: string; id: string } | null;
|
|
@@ -1183,6 +1183,8 @@ export async function runUnitPhase(
|
|
|
1183
1183
|
s.verbose,
|
|
1184
1184
|
s.autoModeStartModel,
|
|
1185
1185
|
sidecarItem ? undefined : { isRetry, previousTier },
|
|
1186
|
+
undefined,
|
|
1187
|
+
s.manualSessionModelOverride,
|
|
1186
1188
|
);
|
|
1187
1189
|
s.currentUnitRouting =
|
|
1188
1190
|
modelResult.routing as AutoSession["currentUnitRouting"];
|
|
@@ -111,6 +111,8 @@ export class AutoSession {
|
|
|
111
111
|
|
|
112
112
|
// ── Model state ──────────────────────────────────────────────────────────
|
|
113
113
|
autoModeStartModel: StartModel | null = null;
|
|
114
|
+
/** Explicit /gsd model pin captured at bootstrap (session-scoped policy override). */
|
|
115
|
+
manualSessionModelOverride: StartModel | null = null;
|
|
114
116
|
currentUnitModel: Model<Api> | null = null;
|
|
115
117
|
/** Fully-qualified model ID (provider/id) set after selectAndApplyModel + hook overrides (#2899). */
|
|
116
118
|
currentDispatchedModelId: string | null = null;
|
|
@@ -222,6 +224,7 @@ export class AutoSession {
|
|
|
222
224
|
|
|
223
225
|
// Model
|
|
224
226
|
this.autoModeStartModel = null;
|
|
227
|
+
this.manualSessionModelOverride = null;
|
|
225
228
|
this.currentUnitModel = null;
|
|
226
229
|
this.currentDispatchedModelId = null;
|
|
227
230
|
this.originalModelId = null;
|
|
@@ -14,6 +14,7 @@ import { classifyUnitComplexity, tierLabel } from "./complexity-classifier.js";
|
|
|
14
14
|
import { resolveModelForComplexity, escalateTier, getEligibleModels, loadCapabilityOverrides, adjustToolSet, filterToolsForProvider } from "./model-router.js";
|
|
15
15
|
import { getLedger, getProjectTotals } from "./metrics.js";
|
|
16
16
|
import { unitPhaseLabel } from "./auto-dashboard.js";
|
|
17
|
+
import { getSessionModelOverride } from "./session-model-override.js";
|
|
17
18
|
|
|
18
19
|
export interface ModelSelectionResult {
|
|
19
20
|
/** Routing metadata for metrics recording */
|
|
@@ -72,8 +73,15 @@ export async function selectAndApplyModel(
|
|
|
72
73
|
/** When false (interactive/guided-flow), skip dynamic routing and use the session model.
|
|
73
74
|
* Dynamic routing only applies in auto-mode where cost optimization is expected. (#3962) */
|
|
74
75
|
isAutoMode = true,
|
|
76
|
+
/** Explicit /gsd model pin captured at bootstrap for long-running auto loops. */
|
|
77
|
+
sessionModelOverride?: { provider: string; id: string } | null,
|
|
75
78
|
): Promise<ModelSelectionResult> {
|
|
76
|
-
const
|
|
79
|
+
const effectiveSessionModelOverride = sessionModelOverride === undefined
|
|
80
|
+
? getSessionModelOverride(ctx.sessionManager.getSessionId())
|
|
81
|
+
: (sessionModelOverride ?? undefined);
|
|
82
|
+
const modelConfig = effectiveSessionModelOverride
|
|
83
|
+
? undefined
|
|
84
|
+
: resolvePreferredModelConfig(unitType, autoModeStartModel, isAutoMode);
|
|
77
85
|
let routing: { tier: string; modelDowngraded: boolean } | null = null;
|
|
78
86
|
let appliedModel: Model<Api> | null = null;
|
|
79
87
|
|
|
@@ -85,6 +85,7 @@ import { sep as pathSep } from "node:path";
|
|
|
85
85
|
import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js";
|
|
86
86
|
import { resolveDefaultSessionModel, resolveDynamicRoutingConfig } from "./preferences-models.js";
|
|
87
87
|
import type { WorktreeResolver } from "./worktree-resolver.js";
|
|
88
|
+
import { getSessionModelOverride } from "./session-model-override.js";
|
|
88
89
|
|
|
89
90
|
export interface BootstrapDeps {
|
|
90
91
|
shouldUseWorktreeIsolation: () => boolean;
|
|
@@ -266,13 +267,42 @@ export async function bootstrapAutoSession(
|
|
|
266
267
|
// Capture the user's session model before guided-flow dispatch can apply a
|
|
267
268
|
// phase-specific planning model for a discuss turn (#2829).
|
|
268
269
|
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
270
|
+
// Precedence:
|
|
271
|
+
// 1) Explicit session override via /gsd model (this session)
|
|
272
|
+
// 2) GSD model preferences from PREFERENCES.md (validated against live auth)
|
|
273
|
+
// 3) Current session model from settings/session restore (if provider ready)
|
|
274
|
+
//
|
|
275
|
+
// This preserves #3517 defaults while honoring explicit runtime model
|
|
276
|
+
// selection for subsequent /gsd runs in the same session.
|
|
277
|
+
const manualSessionOverride = getSessionModelOverride(ctx.sessionManager.getSessionId());
|
|
273
278
|
const preferredModel = resolveDefaultSessionModel(ctx.model?.provider);
|
|
274
|
-
|
|
275
|
-
|
|
279
|
+
// Validate the preferred model against the live registry + provider auth so
|
|
280
|
+
// an unconfigured PREFERENCES.md entry (no API key / OAuth) can't become the
|
|
281
|
+
// start-model snapshot. Without this, every subsequent unit would try to
|
|
282
|
+
// fall back to an unusable model.
|
|
283
|
+
let validatedPreferredModel: { provider: string; id: string } | undefined;
|
|
284
|
+
if (preferredModel) {
|
|
285
|
+
const { resolveModelId } = await import("./auto-model-selection.js");
|
|
286
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
287
|
+
const match = resolveModelId(
|
|
288
|
+
`${preferredModel.provider}/${preferredModel.id}`,
|
|
289
|
+
available,
|
|
290
|
+
ctx.model?.provider,
|
|
291
|
+
);
|
|
292
|
+
if (match) {
|
|
293
|
+
validatedPreferredModel = { provider: match.provider, id: match.id };
|
|
294
|
+
} else {
|
|
295
|
+
ctx.ui.notify(
|
|
296
|
+
`Preferred model ${preferredModel.provider}/${preferredModel.id} from PREFERENCES.md is not configured; falling back to session default.`,
|
|
297
|
+
"warning",
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const sessionModelReady =
|
|
302
|
+
ctx.model && ctx.modelRegistry.isProviderRequestReady(ctx.model.provider);
|
|
303
|
+
const startModelSnapshot = manualSessionOverride
|
|
304
|
+
?? validatedPreferredModel
|
|
305
|
+
?? (sessionModelReady && ctx.model
|
|
276
306
|
? { provider: ctx.model.provider, id: ctx.model.id }
|
|
277
307
|
: null);
|
|
278
308
|
|
|
@@ -731,6 +761,7 @@ export async function bootstrapAutoSession(
|
|
|
731
761
|
id: startModelSnapshot.id,
|
|
732
762
|
};
|
|
733
763
|
}
|
|
764
|
+
s.manualSessionModelOverride = manualSessionOverride ?? null;
|
|
734
765
|
|
|
735
766
|
// Apply worker model override from parallel orchestrator (#worker-model).
|
|
736
767
|
// GSD_WORKER_MODEL is injected by the coordinator when parallel.worker_model
|
|
@@ -2043,7 +2043,7 @@ export function mergeMilestoneToMain(
|
|
|
2043
2043
|
// 12. Remove worktree directory first (must happen before branch deletion)
|
|
2044
2044
|
try {
|
|
2045
2045
|
removeWorktree(originalBasePath_, milestoneId, {
|
|
2046
|
-
branch:
|
|
2046
|
+
branch: milestoneBranch,
|
|
2047
2047
|
deleteBranch: false,
|
|
2048
2048
|
});
|
|
2049
2049
|
} catch (err) {
|
|
@@ -93,9 +93,6 @@ export function registerShortcuts(pi: ExtensionAPI): void {
|
|
|
93
93
|
handler: openParallelOverlay,
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
description: shortcutDesc(`${GSD_SHORTCUTS.parallel.action} (fallback)`, GSD_SHORTCUTS.parallel.command),
|
|
99
|
-
handler: openParallelOverlay,
|
|
100
|
-
});
|
|
96
|
+
// No Ctrl+Shift+P fallback — conflicts with cycleModelBackward (shift+ctrl+p).
|
|
97
|
+
// Use Ctrl+Alt+P or /gsd parallel watch instead.
|
|
101
98
|
}
|
|
@@ -8,6 +8,7 @@ import { ensurePreferencesFile, handlePrefs, handlePrefsMode, handlePrefsWizard
|
|
|
8
8
|
import { runEnvironmentChecks } from "../../doctor-environment.js";
|
|
9
9
|
import { deriveState } from "../../state.js";
|
|
10
10
|
import { handleCmux } from "../../commands-cmux.js";
|
|
11
|
+
import { setSessionModelOverride } from "../../session-model-override.js";
|
|
11
12
|
import { projectRoot } from "../context.js";
|
|
12
13
|
import { formattedShortcutPair } from "../../shortcut-defs.js";
|
|
13
14
|
|
|
@@ -336,6 +337,17 @@ async function handleModel(trimmedArgs: string, ctx: ExtensionCommandContext, pi
|
|
|
336
337
|
return;
|
|
337
338
|
}
|
|
338
339
|
|
|
340
|
+
// /gsd model is an explicit per-session pin for GSD dispatches.
|
|
341
|
+
// This is captured at auto bootstrap so it survives internal session
|
|
342
|
+
// switches during /gsd auto and /gsd next runs.
|
|
343
|
+
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
344
|
+
if (sessionId) {
|
|
345
|
+
setSessionModelOverride(sessionId, {
|
|
346
|
+
provider: targetModel.provider,
|
|
347
|
+
id: targetModel.id,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
339
351
|
ctx.ui.notify(`Model: ${targetModel.provider}/${targetModel.id}`, "info");
|
|
340
352
|
}
|
|
341
353
|
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
readNotifications,
|
|
10
10
|
markAllRead,
|
|
11
11
|
clearNotifications,
|
|
12
|
+
onNotificationStoreChange,
|
|
12
13
|
type NotificationEntry,
|
|
13
14
|
type NotifySeverity,
|
|
14
15
|
} from "./notification-store.js";
|
|
@@ -82,6 +83,7 @@ export class GSDNotificationOverlay {
|
|
|
82
83
|
private refreshTimer: ReturnType<typeof setInterval>;
|
|
83
84
|
private disposed = false;
|
|
84
85
|
private resizeHandler: (() => void) | null = null;
|
|
86
|
+
private unsubscribeStore: (() => void) | null = null;
|
|
85
87
|
|
|
86
88
|
constructor(
|
|
87
89
|
tui: { requestRender: () => void },
|
|
@@ -105,19 +107,17 @@ export class GSDNotificationOverlay {
|
|
|
105
107
|
};
|
|
106
108
|
process.stdout.on("resize", this.resizeHandler);
|
|
107
109
|
|
|
108
|
-
//
|
|
110
|
+
// Subscribe to store mutations for immediate updates
|
|
111
|
+
this.unsubscribeStore = onNotificationStoreChange(() => {
|
|
112
|
+
if (this.disposed) return;
|
|
113
|
+
this._refreshFromDisk();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// 30s safety-net for cross-process edits (web subprocess, parallel workers)
|
|
109
117
|
this.refreshTimer = setInterval(() => {
|
|
110
118
|
if (this.disposed) return;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (signature !== this.entriesSignature) {
|
|
114
|
-
markAllRead();
|
|
115
|
-
this.entries = readNotifications();
|
|
116
|
-
this.entriesSignature = notificationSignature(this.entries);
|
|
117
|
-
this.invalidate();
|
|
118
|
-
this.tui.requestRender();
|
|
119
|
-
}
|
|
120
|
-
}, 3000);
|
|
119
|
+
this._refreshFromDisk();
|
|
120
|
+
}, 30_000);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
private get filter(): FilterMode {
|
|
@@ -215,12 +215,28 @@ export class GSDNotificationOverlay {
|
|
|
215
215
|
dispose(): void {
|
|
216
216
|
this.disposed = true;
|
|
217
217
|
clearInterval(this.refreshTimer);
|
|
218
|
+
if (this.unsubscribeStore) {
|
|
219
|
+
this.unsubscribeStore();
|
|
220
|
+
this.unsubscribeStore = null;
|
|
221
|
+
}
|
|
218
222
|
if (this.resizeHandler) {
|
|
219
223
|
process.stdout.removeListener("resize", this.resizeHandler);
|
|
220
224
|
this.resizeHandler = null;
|
|
221
225
|
}
|
|
222
226
|
}
|
|
223
227
|
|
|
228
|
+
private _refreshFromDisk(): void {
|
|
229
|
+
const fresh = readNotifications();
|
|
230
|
+
const signature = notificationSignature(fresh);
|
|
231
|
+
if (signature !== this.entriesSignature) {
|
|
232
|
+
markAllRead();
|
|
233
|
+
this.entries = readNotifications();
|
|
234
|
+
this.entriesSignature = notificationSignature(this.entries);
|
|
235
|
+
this.invalidate();
|
|
236
|
+
this.tui.requestRender();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
224
240
|
private wrapInBox(inner: string[], width: number): string[] {
|
|
225
241
|
const th = this.theme;
|
|
226
242
|
const border = (s: string) => th.fg("borderAccent", s);
|
|
@@ -323,10 +323,11 @@ function _withLock<T>(basePath: string, fn: () => T): T {
|
|
|
323
323
|
}
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
-
//
|
|
327
|
-
|
|
326
|
+
// Best-effort: mutation runs regardless of lock status (idempotent overwrites).
|
|
327
|
+
// createdLock gates cleanup only — never skip fn() on lock failure.
|
|
328
|
+
const createdLock = fd !== null;
|
|
328
329
|
try {
|
|
329
|
-
if (
|
|
330
|
+
if (createdLock && fd !== null) {
|
|
330
331
|
// Write our PID timestamp into the lock for stale detection
|
|
331
332
|
writeFileSync(lockPath, String(Date.now()), "utf-8");
|
|
332
333
|
closeSync(fd);
|
|
@@ -334,7 +335,7 @@ function _withLock<T>(basePath: string, fn: () => T): T {
|
|
|
334
335
|
return fn();
|
|
335
336
|
} finally {
|
|
336
337
|
// Only delete the lock if we created it — never remove another process's lock
|
|
337
|
-
if (
|
|
338
|
+
if (createdLock) {
|
|
338
339
|
try { unlinkSync(lockPath); } catch { /* best-effort cleanup */ }
|
|
339
340
|
}
|
|
340
341
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface SessionModelOverride {
|
|
2
|
+
provider: string;
|
|
3
|
+
id: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const sessionOverrides = new Map<string, SessionModelOverride>();
|
|
7
|
+
|
|
8
|
+
function normalizeSessionId(sessionId: string): string {
|
|
9
|
+
return typeof sessionId === "string" ? sessionId.trim() : "";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function setSessionModelOverride(
|
|
13
|
+
sessionId: string,
|
|
14
|
+
override: SessionModelOverride,
|
|
15
|
+
): void {
|
|
16
|
+
const key = normalizeSessionId(sessionId);
|
|
17
|
+
if (!key) return;
|
|
18
|
+
sessionOverrides.set(key, {
|
|
19
|
+
provider: override.provider,
|
|
20
|
+
id: override.id,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getSessionModelOverride(
|
|
25
|
+
sessionId: string,
|
|
26
|
+
): SessionModelOverride | undefined {
|
|
27
|
+
const key = normalizeSessionId(sessionId);
|
|
28
|
+
if (!key) return undefined;
|
|
29
|
+
return sessionOverrides.get(key);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function clearSessionModelOverride(sessionId: string): void {
|
|
33
|
+
const key = normalizeSessionId(sessionId);
|
|
34
|
+
if (!key) return;
|
|
35
|
+
sessionOverrides.delete(key);
|
|
36
|
+
}
|
|
@@ -8,6 +8,8 @@ type GSDShortcutDef = {
|
|
|
8
8
|
key: "g" | "n" | "p";
|
|
9
9
|
action: string;
|
|
10
10
|
command: string;
|
|
11
|
+
/** Whether the Ctrl+Shift fallback is registered (false when it conflicts with an app keybinding). */
|
|
12
|
+
hasFallback: boolean;
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
export const GSD_SHORTCUTS: Record<GSDShortcutId, GSDShortcutDef> = {
|
|
@@ -15,16 +17,19 @@ export const GSD_SHORTCUTS: Record<GSDShortcutId, GSDShortcutDef> = {
|
|
|
15
17
|
key: "g",
|
|
16
18
|
action: "Open GSD dashboard",
|
|
17
19
|
command: "/gsd status",
|
|
20
|
+
hasFallback: true,
|
|
18
21
|
},
|
|
19
22
|
notifications: {
|
|
20
23
|
key: "n",
|
|
21
24
|
action: "Open notification history",
|
|
22
25
|
command: "/gsd notifications",
|
|
26
|
+
hasFallback: true,
|
|
23
27
|
},
|
|
24
28
|
parallel: {
|
|
25
29
|
key: "p",
|
|
26
30
|
action: "Open parallel worker monitor",
|
|
27
31
|
command: "/gsd parallel watch",
|
|
32
|
+
hasFallback: false, // Ctrl+Shift+P conflicts with cycleModelBackward
|
|
28
33
|
},
|
|
29
34
|
};
|
|
30
35
|
|
|
@@ -41,7 +46,9 @@ export function fallbackShortcutCombo(id: GSDShortcutId): string {
|
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
export function shortcutPair(id: GSDShortcutId, formatter: (combo: string) => string = (combo) => combo): string {
|
|
44
|
-
|
|
49
|
+
const primary = formatter(primaryShortcutCombo(id));
|
|
50
|
+
if (!GSD_SHORTCUTS[id].hasFallback) return primary;
|
|
51
|
+
return `${primary} / ${formatter(fallbackShortcutCombo(id))}`;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
export function formattedShortcutPair(id: GSDShortcutId): string {
|
|
@@ -7,9 +7,8 @@ const sourcePath = join(import.meta.dirname, "..", "auto-start.ts");
|
|
|
7
7
|
const source = readFileSync(sourcePath, "utf-8");
|
|
8
8
|
|
|
9
9
|
test("bootstrapAutoSession snapshots ctx.model before guided-flow entry (#2829)", () => {
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
const snapshotIdx = source.indexOf("const startModelSnapshot = preferredModel");
|
|
10
|
+
// The snapshot ordering guarantee still holds: build snapshot before guided-flow.
|
|
11
|
+
const snapshotIdx = source.indexOf("const startModelSnapshot = manualSessionOverride");
|
|
13
12
|
assert.ok(snapshotIdx > -1, "auto-start.ts should snapshot model at bootstrap start");
|
|
14
13
|
|
|
15
14
|
const firstDiscussIdx = source.indexOf('await showSmartEntry(ctx, pi, base, { step: requestedStepMode });');
|
|
@@ -29,8 +28,11 @@ test("bootstrapAutoSession restores autoModeStartModel from the early snapshot (
|
|
|
29
28
|
assert.ok(snapshotRefIdx > -1, "autoModeStartModel should be restored from startModelSnapshot");
|
|
30
29
|
});
|
|
31
30
|
|
|
32
|
-
test("bootstrapAutoSession
|
|
33
|
-
|
|
31
|
+
test("bootstrapAutoSession checks manual session override before preferences", () => {
|
|
32
|
+
const manualIdx = source.indexOf("const manualSessionOverride = getSessionModelOverride(");
|
|
33
|
+
assert.ok(manualIdx > -1, "auto-start.ts should read session model override first");
|
|
34
|
+
|
|
35
|
+
// resolveDefaultSessionModel() should still be called for fallback behavior
|
|
34
36
|
const preferredIdx = source.indexOf("const preferredModel = resolveDefaultSessionModel(");
|
|
35
37
|
assert.ok(preferredIdx > -1, "auto-start.ts should call resolveDefaultSessionModel()");
|
|
36
38
|
|
|
@@ -38,11 +40,25 @@ test("bootstrapAutoSession prefers GSD PREFERENCES.md over settings.json for sta
|
|
|
38
40
|
const withProviderIdx = source.indexOf("resolveDefaultSessionModel(ctx.model?.provider)");
|
|
39
41
|
assert.ok(withProviderIdx > -1, "auto-start.ts should pass ctx.model?.provider for bare ID resolution");
|
|
40
42
|
|
|
41
|
-
const snapshotIdx = source.indexOf("const startModelSnapshot =
|
|
42
|
-
assert.ok(snapshotIdx > -1, "startModelSnapshot should
|
|
43
|
+
const snapshotIdx = source.indexOf("const startModelSnapshot = manualSessionOverride");
|
|
44
|
+
assert.ok(snapshotIdx > -1, "startModelSnapshot should prefer manual session override");
|
|
43
45
|
|
|
44
46
|
assert.ok(
|
|
45
|
-
preferredIdx < snapshotIdx,
|
|
46
|
-
"
|
|
47
|
+
manualIdx < snapshotIdx && preferredIdx < snapshotIdx,
|
|
48
|
+
"manual override and preference fallback must be resolved before building startModelSnapshot",
|
|
47
49
|
);
|
|
48
50
|
});
|
|
51
|
+
|
|
52
|
+
test("bootstrapAutoSession validates preferred model against live registry auth (#unconfigured-models)", () => {
|
|
53
|
+
// The raw PREFERENCES.md value must be validated against getAvailable()
|
|
54
|
+
// before being captured as the snapshot, so an unconfigured provider
|
|
55
|
+
// (no API key / OAuth) can't become autoModeStartModel.
|
|
56
|
+
const validationIdx = source.indexOf("ctx.modelRegistry.getAvailable()");
|
|
57
|
+
assert.ok(validationIdx > -1, "auto-start.ts should validate preferred model against getAvailable()");
|
|
58
|
+
|
|
59
|
+
const resolveModelIdIdx = source.indexOf("resolveModelId");
|
|
60
|
+
assert.ok(resolveModelIdIdx > -1, "auto-start.ts should resolve preferred model against the registry");
|
|
61
|
+
|
|
62
|
+
const warningIdx = source.indexOf("is not configured; falling back to session default");
|
|
63
|
+
assert.ok(warningIdx > -1, "auto-start.ts should warn when preferred model is unconfigured");
|
|
64
|
+
});
|
|
@@ -82,3 +82,19 @@ test("shortcut-defs: formats shortcut pair using platform symbols", () => {
|
|
|
82
82
|
assert.equal(pair, "Ctrl+Alt+N / Ctrl+Shift+N");
|
|
83
83
|
}
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
test("shortcut-defs: parallel shortcut omits fallback (hasFallback: false)", () => {
|
|
87
|
+
const pair = formattedShortcutPair("parallel");
|
|
88
|
+
if (process.platform === "darwin") {
|
|
89
|
+
assert.equal(pair, "⌃⌥P", "parallel should only show primary combo");
|
|
90
|
+
} else {
|
|
91
|
+
assert.equal(pair, "Ctrl+Alt+P", "parallel should only show primary combo");
|
|
92
|
+
}
|
|
93
|
+
// Verify it does NOT contain the fallback separator
|
|
94
|
+
assert.ok(!pair.includes("/"), "parallel pair should not contain fallback separator");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("shortcut-defs: dashboard shortcut includes fallback (hasFallback: true)", () => {
|
|
98
|
+
const pair = formattedShortcutPair("dashboard");
|
|
99
|
+
assert.ok(pair.includes("/"), "dashboard pair should contain fallback separator");
|
|
100
|
+
});
|