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.
Files changed (101) hide show
  1. package/dist/resources/extensions/gsd/auto/phases.js +1 -1
  2. package/dist/resources/extensions/gsd/auto/session.js +3 -0
  3. package/dist/resources/extensions/gsd/auto-model-selection.js +10 -2
  4. package/dist/resources/extensions/gsd/auto-start.js +30 -6
  5. package/dist/resources/extensions/gsd/auto-worktree.js +1 -1
  6. package/dist/resources/extensions/gsd/bootstrap/register-shortcuts.js +2 -5
  7. package/dist/resources/extensions/gsd/commands/handlers/core.js +11 -0
  8. package/dist/resources/extensions/gsd/notification-overlay.js +26 -12
  9. package/dist/resources/extensions/gsd/notification-store.js +5 -4
  10. package/dist/resources/extensions/gsd/session-model-override.js +25 -0
  11. package/dist/resources/extensions/gsd/shortcut-defs.js +7 -1
  12. package/dist/resources/extensions/ollama/index.js +13 -5
  13. package/dist/startup-model-validation.d.ts +0 -1
  14. package/dist/startup-model-validation.js +6 -2
  15. package/dist/web/standalone/.next/BUILD_ID +1 -1
  16. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  17. package/dist/web/standalone/.next/build-manifest.json +2 -2
  18. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  19. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.html +1 -1
  36. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  43. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  44. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  45. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  46. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  47. package/package.json +1 -1
  48. package/packages/pi-coding-agent/dist/core/model-resolver-initial-model-auth.test.d.ts +2 -0
  49. package/packages/pi-coding-agent/dist/core/model-resolver-initial-model-auth.test.d.ts.map +1 -0
  50. package/packages/pi-coding-agent/dist/core/model-resolver-initial-model-auth.test.js +64 -0
  51. package/packages/pi-coding-agent/dist/core/model-resolver-initial-model-auth.test.js.map +1 -0
  52. package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
  53. package/packages/pi-coding-agent/dist/core/model-resolver.js +22 -18
  54. package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
  55. package/packages/pi-coding-agent/dist/core/model-resolver.test.d.ts +8 -0
  56. package/packages/pi-coding-agent/dist/core/model-resolver.test.d.ts.map +1 -0
  57. package/packages/pi-coding-agent/dist/core/model-resolver.test.js +75 -0
  58. package/packages/pi-coding-agent/dist/core/model-resolver.test.js.map +1 -0
  59. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/login-dialog.test.d.ts +2 -0
  60. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/login-dialog.test.d.ts.map +1 -0
  61. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/login-dialog.test.js +13 -0
  62. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/login-dialog.test.js.map +1 -0
  63. package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.d.ts +4 -0
  64. package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.d.ts.map +1 -1
  65. package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.js +24 -2
  66. package/packages/pi-coding-agent/dist/modes/interactive/components/login-dialog.js.map +1 -1
  67. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  68. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js +9 -2
  69. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js.map +1 -1
  70. package/packages/pi-coding-agent/dist/modes/interactive/controllers/model-controller.d.ts.map +1 -1
  71. package/packages/pi-coding-agent/dist/modes/interactive/controllers/model-controller.js +6 -1
  72. package/packages/pi-coding-agent/dist/modes/interactive/controllers/model-controller.js.map +1 -1
  73. package/packages/pi-coding-agent/src/core/model-resolver-initial-model-auth.test.ts +78 -0
  74. package/packages/pi-coding-agent/src/core/model-resolver.test.ts +85 -0
  75. package/packages/pi-coding-agent/src/core/model-resolver.ts +22 -18
  76. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/login-dialog.test.ts +24 -0
  77. package/packages/pi-coding-agent/src/modes/interactive/components/login-dialog.ts +30 -2
  78. package/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts +15 -6
  79. package/packages/pi-coding-agent/src/modes/interactive/controllers/model-controller.ts +6 -1
  80. package/src/resources/extensions/gsd/auto/loop-deps.ts +2 -0
  81. package/src/resources/extensions/gsd/auto/phases.ts +2 -0
  82. package/src/resources/extensions/gsd/auto/session.ts +3 -0
  83. package/src/resources/extensions/gsd/auto-model-selection.ts +9 -1
  84. package/src/resources/extensions/gsd/auto-start.ts +37 -6
  85. package/src/resources/extensions/gsd/auto-worktree.ts +1 -1
  86. package/src/resources/extensions/gsd/bootstrap/register-shortcuts.ts +2 -5
  87. package/src/resources/extensions/gsd/commands/handlers/core.ts +12 -0
  88. package/src/resources/extensions/gsd/notification-overlay.ts +27 -11
  89. package/src/resources/extensions/gsd/notification-store.ts +5 -4
  90. package/src/resources/extensions/gsd/session-model-override.ts +36 -0
  91. package/src/resources/extensions/gsd/shortcut-defs.ts +8 -1
  92. package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +25 -9
  93. package/src/resources/extensions/gsd/tests/format-shortcut.test.ts +16 -0
  94. package/src/resources/extensions/gsd/tests/integration/auto-worktree-milestone-merge.test.ts +66 -1
  95. package/src/resources/extensions/gsd/tests/model-isolation.test.ts +36 -51
  96. package/src/resources/extensions/gsd/tests/register-shortcuts.test.ts +3 -2
  97. package/src/resources/extensions/gsd/tests/session-model-override.test.ts +35 -0
  98. package/src/resources/extensions/ollama/index.ts +13 -3
  99. package/src/resources/extensions/ollama/ollama-status-indicator.test.ts +28 -0
  100. /package/dist/web/standalone/.next/static/{ug91LJa0m7OdzrTVaz_48 → IRnpNeY-_eO7SxKBIkTbL}/_buildManifest.js +0 -0
  101. /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
- const found = modelRegistry.find(defaultProvider, defaultModelId);
508
- if (found) {
509
- // Check if the provider's recommended default is a higher-capability variant
510
- // of the saved model (e.g. saved "claude-opus-4-6" vs recommended "claude-opus-4-6-extended").
511
- // If so, prefer the recommended variant to avoid using a smaller context window (#1125).
512
- const recommendedId = defaultModelPerProvider[defaultProvider as KnownProvider];
513
- if (recommendedId && recommendedId !== defaultModelId && recommendedId.startsWith(defaultModelId)) {
514
- const recommended = modelRegistry.find(defaultProvider, recommendedId);
515
- if (recommended) {
516
- model = recommended;
517
- if (defaultThinkingLevel) {
518
- thinkingLevel = defaultThinkingLevel;
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
 
@@ -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 maxUrlWidth = Math.max(20, this.tui.terminal.columns - 4);
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
- this.scope = scopedModels.length > 0 ? "scoped" : "all";
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.map((scoped) => ({
220
- provider: scoped.model.provider,
221
- id: scoped.model.id,
222
- model: scoped.model,
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
- return host.session.scopedModels.map((scoped: any) => scoped.model);
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 modelConfig = resolvePreferredModelConfig(unitType, autoModeStartModel, isAutoMode);
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
- // GSD PREFERENCES.md takes priority over the session model from settings.json
270
- // (#3517). The session model (ctx.model) comes from findInitialModel() which
271
- // reads defaultProvider/defaultModel from ~/.gsd/agent/settings.json. When
272
- // the user has explicit model preferences in PREFERENCES.md, those should win.
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
- const startModelSnapshot = preferredModel
275
- ?? (ctx.model
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: null as unknown as string,
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
- // Fallback for terminals where Ctrl+Alt letter chords are not forwarded reliably.
97
- pi.registerShortcut(Key.ctrlShift(GSD_SHORTCUTS.parallel.key), {
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
- // Refresh every 3s for new notifications
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
- const fresh = readNotifications();
112
- const signature = notificationSignature(fresh);
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
- // Only run the mutation if we actually own the lock
327
- const ownsLock = fd !== null;
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 (ownsLock && fd !== null) {
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 (ownsLock) {
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
- return `${formatter(primaryShortcutCombo(id))} / ${formatter(fallbackShortcutCombo(id))}`;
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
- // #3517 changed the snapshot to prefer GSD preferences, but the ordering
11
- // guarantee still holds: the snapshot must be built before guided-flow.
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 prefers GSD PREFERENCES.md over settings.json for start model (#3517)", () => {
33
- // resolveDefaultSessionModel() should be called before the snapshot is built
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 = preferredModel");
42
- assert.ok(snapshotIdx > -1, "startModelSnapshot should use preferredModel when available");
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
- "resolveDefaultSessionModel() must be called before building startModelSnapshot",
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
+ });