gsd-pi 2.73.0-dev.e1c09f2 → 2.73.1-dev.088d28f

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 (149) hide show
  1. package/dist/resource-loader.js +2 -2
  2. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +9 -3
  3. package/dist/resources/extensions/gsd/auto/phases.js +15 -9
  4. package/dist/resources/extensions/gsd/auto-dispatch.js +11 -3
  5. package/dist/resources/extensions/gsd/auto-model-selection.js +54 -11
  6. package/dist/resources/extensions/gsd/auto-start.js +23 -6
  7. package/dist/resources/extensions/gsd/auto.js +13 -1
  8. package/dist/resources/extensions/gsd/bootstrap/crash-log.js +31 -0
  9. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +18 -7
  10. package/dist/resources/extensions/gsd/commands-handlers.js +8 -2
  11. package/dist/resources/extensions/gsd/crash-recovery.js +51 -0
  12. package/dist/resources/extensions/gsd/docs/preferences-reference.md +1 -1
  13. package/dist/resources/extensions/gsd/gsd-db.js +36 -2
  14. package/dist/resources/extensions/gsd/milestone-actions.js +19 -1
  15. package/dist/resources/extensions/gsd/notification-widget.js +2 -2
  16. package/dist/resources/extensions/gsd/preferences-models.js +43 -0
  17. package/dist/resources/extensions/gsd/preferences-types.js +1 -0
  18. package/dist/resources/extensions/gsd/preferences-validation.js +22 -0
  19. package/dist/resources/extensions/gsd/state.js +36 -0
  20. package/dist/update-check.d.ts +1 -0
  21. package/dist/update-check.js +13 -5
  22. package/dist/update-cmd.js +4 -3
  23. package/dist/web/standalone/.next/BUILD_ID +1 -1
  24. package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
  25. package/dist/web/standalone/.next/build-manifest.json +2 -2
  26. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  27. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.html +1 -1
  44. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
  51. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  52. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  53. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  54. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  55. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  56. package/package.json +1 -1
  57. package/packages/pi-ai/dist/index.d.ts +1 -0
  58. package/packages/pi-ai/dist/index.d.ts.map +1 -1
  59. package/packages/pi-ai/dist/index.js +1 -0
  60. package/packages/pi-ai/dist/index.js.map +1 -1
  61. package/packages/pi-ai/dist/utils/overflow.d.ts.map +1 -1
  62. package/packages/pi-ai/dist/utils/overflow.js +12 -0
  63. package/packages/pi-ai/dist/utils/overflow.js.map +1 -1
  64. package/packages/pi-ai/dist/utils/tests/overflow.test.d.ts +2 -0
  65. package/packages/pi-ai/dist/utils/tests/overflow.test.d.ts.map +1 -0
  66. package/packages/pi-ai/dist/utils/tests/overflow.test.js +50 -0
  67. package/packages/pi-ai/dist/utils/tests/overflow.test.js.map +1 -0
  68. package/packages/pi-ai/src/index.ts +4 -0
  69. package/packages/pi-ai/src/utils/overflow.ts +14 -1
  70. package/packages/pi-ai/src/utils/tests/overflow.test.ts +58 -0
  71. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +313 -8
  72. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
  73. package/packages/pi-coding-agent/dist/core/compaction/utils.js +5 -5
  74. package/packages/pi-coding-agent/dist/core/compaction/utils.js.map +1 -1
  75. package/packages/pi-coding-agent/dist/core/compaction-utils.test.d.ts +2 -0
  76. package/packages/pi-coding-agent/dist/core/compaction-utils.test.d.ts.map +1 -0
  77. package/packages/pi-coding-agent/dist/core/compaction-utils.test.js +45 -0
  78. package/packages/pi-coding-agent/dist/core/compaction-utils.test.js.map +1 -0
  79. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts +12 -2
  80. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  81. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +51 -26
  82. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
  83. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts +2 -1
  84. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
  85. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js +9 -3
  86. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js.map +1 -1
  87. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.d.ts +2 -0
  88. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.d.ts.map +1 -0
  89. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.js +52 -0
  90. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.js.map +1 -0
  91. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  92. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +94 -16
  93. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  94. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  95. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +11 -3
  96. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  97. package/packages/pi-coding-agent/package.json +1 -1
  98. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +355 -8
  99. package/packages/pi-coding-agent/src/core/compaction/utils.ts +5 -5
  100. package/packages/pi-coding-agent/src/core/compaction-utils.test.ts +50 -0
  101. package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +62 -26
  102. package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.test.ts +73 -0
  103. package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts +9 -3
  104. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +113 -21
  105. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +11 -3
  106. package/packages/pi-tui/dist/__tests__/tui.test.js +60 -1
  107. package/packages/pi-tui/dist/__tests__/tui.test.js.map +1 -1
  108. package/packages/pi-tui/dist/tui.d.ts +8 -0
  109. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  110. package/packages/pi-tui/dist/tui.js +32 -3
  111. package/packages/pi-tui/dist/tui.js.map +1 -1
  112. package/packages/pi-tui/src/__tests__/tui.test.ts +76 -1
  113. package/packages/pi-tui/src/tui.ts +31 -3
  114. package/pkg/package.json +1 -1
  115. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +12 -4
  116. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +23 -2
  117. package/src/resources/extensions/gsd/auto/phases.ts +22 -9
  118. package/src/resources/extensions/gsd/auto-dispatch.ts +10 -4
  119. package/src/resources/extensions/gsd/auto-model-selection.ts +85 -11
  120. package/src/resources/extensions/gsd/auto-start.ts +30 -6
  121. package/src/resources/extensions/gsd/auto.ts +10 -0
  122. package/src/resources/extensions/gsd/bootstrap/crash-log.ts +32 -0
  123. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +19 -7
  124. package/src/resources/extensions/gsd/commands-handlers.ts +8 -2
  125. package/src/resources/extensions/gsd/crash-recovery.ts +59 -0
  126. package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -1
  127. package/src/resources/extensions/gsd/gsd-db.ts +52 -2
  128. package/src/resources/extensions/gsd/milestone-actions.ts +19 -1
  129. package/src/resources/extensions/gsd/notification-widget.ts +2 -2
  130. package/src/resources/extensions/gsd/preferences-models.ts +41 -0
  131. package/src/resources/extensions/gsd/preferences-types.ts +12 -0
  132. package/src/resources/extensions/gsd/preferences-validation.ts +23 -0
  133. package/src/resources/extensions/gsd/state.ts +46 -0
  134. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +2 -2
  135. package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +51 -2
  136. package/src/resources/extensions/gsd/tests/crash-handler-secondary.test.ts +235 -0
  137. package/src/resources/extensions/gsd/tests/derive-state-crossval.test.ts +3 -2
  138. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +3 -2
  139. package/src/resources/extensions/gsd/tests/derive-state.test.ts +3 -3
  140. package/src/resources/extensions/gsd/tests/flat-rate-routing-guard.test.ts +137 -1
  141. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +59 -1
  142. package/src/resources/extensions/gsd/tests/integration/state-machine-edge-cases.test.ts +4 -2
  143. package/src/resources/extensions/gsd/tests/model-isolation.test.ts +91 -2
  144. package/src/resources/extensions/gsd/tests/park-milestone.test.ts +64 -0
  145. package/src/resources/extensions/gsd/tests/preferences.test.ts +47 -0
  146. package/src/resources/extensions/gsd/tests/state-machine-full-walkthrough.test.ts +5 -7
  147. package/src/resources/extensions/gsd/tests/token-profile.test.ts +1 -1
  148. /package/dist/web/standalone/.next/static/{_XD_gUDcZNBbWV5rI8RgS → nwYTvJZ1-hZIfw98d9Wfg}/_buildManifest.js +0 -0
  149. /package/dist/web/standalone/.next/static/{_XD_gUDcZNBbWV5rI8RgS → nwYTvJZ1-hZIfw98d9Wfg}/_ssgManifest.js +0 -0
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
10
12
  import type { DynamicRoutingConfig } from "./model-router.js";
11
13
  import { defaultRoutingConfig } from "./model-router.js";
12
14
  import type { TokenProfile, InlineLevel } from "./types.js";
@@ -185,6 +187,45 @@ export function resolveDefaultSessionModel(
185
187
  return undefined;
186
188
  }
187
189
 
190
+ /**
191
+ * Returns true if `provider` is defined as a custom provider in the user's
192
+ * `~/.gsd/agent/models.json` (Ollama, vLLM, LM Studio, OpenAI-compatible
193
+ * proxies, etc.).
194
+ *
195
+ * Used by auto-mode bootstrap to decide whether the session model
196
+ * (set via `/gsd model`) should override `PREFERENCES.md`. Custom providers
197
+ * are never reachable from `PREFERENCES.md` (which only knows built-in
198
+ * providers), so when the user has explicitly selected one, it must take
199
+ * priority — otherwise auto-mode tries to start the built-in provider from
200
+ * PREFERENCES.md and fails with "Not logged in · Please run /login" (#4122).
201
+ *
202
+ * Reads models.json directly with a lightweight JSON parse to avoid
203
+ * pulling in the full model-registry at this call site. Falls back to
204
+ * `~/.pi/agent/models.json` for parity with `resolveModelsJsonPath()`.
205
+ * Any read or parse error yields `false` (treat as not-custom) so a
206
+ * malformed models.json never breaks the session bootstrap.
207
+ */
208
+ export function isCustomProvider(provider: string | undefined): boolean {
209
+ if (!provider) return false;
210
+ const candidates = [
211
+ join(homedir(), ".gsd", "agent", "models.json"),
212
+ join(homedir(), ".pi", "agent", "models.json"),
213
+ ];
214
+ for (const path of candidates) {
215
+ if (!existsSync(path)) continue;
216
+ try {
217
+ const raw = readFileSync(path, "utf-8");
218
+ const parsed = JSON.parse(raw) as { providers?: Record<string, unknown> };
219
+ if (parsed?.providers && Object.prototype.hasOwnProperty.call(parsed.providers, provider)) {
220
+ return true;
221
+ }
222
+ } catch {
223
+ // Ignore — malformed models.json must not break bootstrap.
224
+ }
225
+ }
226
+ return false;
227
+ }
228
+
188
229
  /**
189
230
  * Determines the next fallback model to try when the current model fails.
190
231
  * If the current model is not in the configured list, returns the primary model.
@@ -113,6 +113,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
113
113
  "discuss_preparation",
114
114
  "discuss_web_research",
115
115
  "discuss_depth",
116
+ "flat_rate_providers",
116
117
  ]);
117
118
 
118
119
  /** Canonical list of all dispatch unit types. */
@@ -359,6 +360,17 @@ export interface GSDPreferences {
359
360
  * Default: "standard".
360
361
  */
361
362
  discuss_depth?: "quick" | "standard" | "thorough";
363
+ /**
364
+ * Extra provider IDs to treat as flat-rate (no cost benefit from dynamic
365
+ * routing). Dynamic routing is suppressed for any provider listed here,
366
+ * in addition to the built-in list (github-copilot, copilot, claude-code)
367
+ * and any provider auto-detected via `authMode: "externalCli"`.
368
+ *
369
+ * Intended for private subscription-backed proxies, enterprise-gated
370
+ * deployments, and custom CLI wrappers where every request costs the
371
+ * same regardless of model. Case-insensitive.
372
+ */
373
+ flat_rate_providers?: string[];
362
374
  }
363
375
 
364
376
  export interface LoadedGSDPreferences {
@@ -180,6 +180,29 @@ export function validatePreferences(preferences: GSDPreferences): {
180
180
  }
181
181
  }
182
182
 
183
+ // ─── Flat-rate Providers ────────────────────────────────────────────
184
+ // User-declared flat-rate providers for dynamic routing suppression.
185
+ // Built-in providers (github-copilot, copilot, claude-code) and any
186
+ // externalCli provider are already auto-detected; this list layers on
187
+ // top for private subscription proxies and custom CLI wrappers.
188
+ if (preferences.flat_rate_providers !== undefined) {
189
+ if (Array.isArray(preferences.flat_rate_providers)) {
190
+ const allStrings = preferences.flat_rate_providers.every(
191
+ (item: unknown) => typeof item === "string",
192
+ );
193
+ if (allStrings) {
194
+ // Strip empty/whitespace-only entries to avoid false matches.
195
+ validated.flat_rate_providers = preferences.flat_rate_providers
196
+ .map((s: string) => s.trim())
197
+ .filter((s: string) => s.length > 0);
198
+ } else {
199
+ errors.push("flat_rate_providers must be an array of strings");
200
+ }
201
+ } else {
202
+ errors.push("flat_rate_providers must be an array of strings");
203
+ }
204
+ }
205
+
183
206
  // ─── Phase Skip Preferences ─────────────────────────────────────────
184
207
  if (preferences.phases !== undefined) {
185
208
  if (typeof preferences.phases === "object" && preferences.phases !== null) {
@@ -630,13 +630,39 @@ function resolveSliceDependencies(activeMilestoneSlices: SliceRow[]): { activeSl
630
630
  }
631
631
  }
632
632
 
633
+ // First pass: find a slice with ALL dependencies satisfied (strict)
634
+ let bestFallback: SliceRow | null = null;
635
+ let bestFallbackSatisfied = -1;
636
+
633
637
  for (const s of activeMilestoneSlices) {
634
638
  if (isStatusDone(s.status)) continue;
635
639
  if (isDeferredStatus(s.status)) continue;
636
640
  if (s.depends.every(dep => doneSliceIds.has(dep))) {
637
641
  return { activeSlice: { id: s.id, title: s.title }, activeSliceRow: s };
638
642
  }
643
+ // Track the slice with the most satisfied dependencies as fallback
644
+ const satisfied = s.depends.filter(dep => doneSliceIds.has(dep)).length;
645
+ if (satisfied > bestFallbackSatisfied || (satisfied === bestFallbackSatisfied && !bestFallback)) {
646
+ bestFallback = s;
647
+ bestFallbackSatisfied = satisfied;
648
+ }
639
649
  }
650
+
651
+ // Fallback: if no slice has all deps met but there ARE incomplete non-deferred
652
+ // slices, pick the one with the most deps satisfied. This prevents hard-blocking
653
+ // when dependency metadata is stale (e.g. after reassessment added/removed slices)
654
+ // or when deps reference slices from previous milestones.
655
+ if (bestFallback) {
656
+ const unmet = bestFallback.depends.filter(dep => !doneSliceIds.has(dep));
657
+ logWarning("state",
658
+ `No slice has all deps satisfied — falling back to ${bestFallback.id} ` +
659
+ `(${bestFallbackSatisfied}/${bestFallback.depends.length} deps met, ` +
660
+ `unmet: ${unmet.join(", ")})`,
661
+ { mid: activeMilestoneSlices[0]?.milestone_id, sid: bestFallback.id },
662
+ );
663
+ return { activeSlice: { id: bestFallback.id, title: bestFallback.title }, activeSliceRow: bestFallback };
664
+ }
665
+
640
666
  return { activeSlice: null, activeSliceRow: null };
641
667
  }
642
668
 
@@ -1431,12 +1457,32 @@ export async function _deriveStateImpl(basePath: string): Promise<GSDState> {
1431
1457
  };
1432
1458
  }
1433
1459
  } else {
1460
+ let bestFallbackLegacy: { id: string; title: string; depends: string[] } | null = null;
1461
+ let bestFallbackLegacySatisfied = -1;
1462
+
1434
1463
  for (const s of activeRoadmap.slices) {
1435
1464
  if (s.done) continue;
1436
1465
  if (s.depends.every(dep => doneSliceIds.has(dep))) {
1437
1466
  activeSlice = { id: s.id, title: s.title };
1438
1467
  break;
1439
1468
  }
1469
+ // Track best fallback
1470
+ const satisfied = s.depends.filter(dep => doneSliceIds.has(dep)).length;
1471
+ if (satisfied > bestFallbackLegacySatisfied) {
1472
+ bestFallbackLegacy = s;
1473
+ bestFallbackLegacySatisfied = satisfied;
1474
+ }
1475
+ }
1476
+
1477
+ // Fallback: if no slice has all deps met, pick the one with the most deps satisfied
1478
+ if (!activeSlice && bestFallbackLegacy) {
1479
+ const unmet = bestFallbackLegacy.depends.filter(dep => !doneSliceIds.has(dep));
1480
+ logWarning("state",
1481
+ `No slice has all deps satisfied — falling back to ${bestFallbackLegacy.id} ` +
1482
+ `(${bestFallbackLegacySatisfied}/${bestFallbackLegacy.depends.length} deps met, ` +
1483
+ `unmet: ${unmet.join(", ")})`,
1484
+ );
1485
+ activeSlice = { id: bestFallbackLegacy.id, title: bestFallbackLegacy.title };
1440
1486
  }
1441
1487
  }
1442
1488
 
@@ -688,8 +688,8 @@ test("autoLoop exits on terminal blocked state", async (t) => {
688
688
 
689
689
  assert.ok(deps.callLog.includes("deriveState"), "should have derived state");
690
690
  assert.ok(
691
- deps.callLog.includes("stopAuto"),
692
- "should have called stopAuto for blocked state",
691
+ deps.callLog.includes("pauseAuto"),
692
+ "should have called pauseAuto for blocked state",
693
693
  );
694
694
  assert.ok(
695
695
  !deps.callLog.includes("resolveDispatch"),
@@ -33,8 +33,12 @@ test("bootstrapAutoSession checks manual session override before preferences", (
33
33
  assert.ok(manualIdx > -1, "auto-start.ts should read session model override first");
34
34
 
35
35
  // resolveDefaultSessionModel() should still be called for fallback behavior
36
- const preferredIdx = source.indexOf("const preferredModel = resolveDefaultSessionModel(");
37
- assert.ok(preferredIdx > -1, "auto-start.ts should call resolveDefaultSessionModel()");
36
+ const preferredIdx = source.indexOf("const preferredModel = ");
37
+ assert.ok(preferredIdx > -1, "auto-start.ts should build preferredModel");
38
+ assert.ok(
39
+ source.indexOf("resolveDefaultSessionModel(") > -1,
40
+ "auto-start.ts should call resolveDefaultSessionModel()",
41
+ );
38
42
 
39
43
  // Session provider should be passed for bare model ID resolution
40
44
  const withProviderIdx = source.indexOf("resolveDefaultSessionModel(ctx.model?.provider)");
@@ -47,6 +51,51 @@ test("bootstrapAutoSession checks manual session override before preferences", (
47
51
  manualIdx < snapshotIdx && preferredIdx < snapshotIdx,
48
52
  "manual override and preference fallback must be resolved before building startModelSnapshot",
49
53
  );
54
+
55
+ // The validated preferred model must still appear as one of the snapshot
56
+ // sources so PREFERENCES.md continues to win over a stale settings.json
57
+ // default for built-in providers.
58
+ const snapshotBlock = source.slice(snapshotIdx, snapshotIdx + 400);
59
+ assert.ok(
60
+ snapshotBlock.includes("validatedPreferredModel") || snapshotBlock.includes("preferredModel"),
61
+ "startModelSnapshot must still consider preferredModel for built-in providers",
62
+ );
63
+ });
64
+
65
+ test("bootstrapAutoSession prefers session model over PREFERENCES.md when provider is custom (#4122)", () => {
66
+ // Custom providers (Ollama, vLLM, OpenAI-compatible proxies) live in
67
+ // ~/.gsd/agent/models.json, not PREFERENCES.md. When the user picks one
68
+ // via /gsd model, that selection must win over any preferredModel from
69
+ // PREFERENCES.md, otherwise auto-mode tries to start a built-in provider
70
+ // the user is not logged into and pauses with "Not logged in".
71
+ const customCheckIdx = source.indexOf("isCustomProvider(ctx.model?.provider)");
72
+ assert.ok(
73
+ customCheckIdx > -1,
74
+ "auto-start.ts should call isCustomProvider() to detect custom-model sessions",
75
+ );
76
+
77
+ // sessionProviderIsCustom must gate preferredModel resolution so that when the
78
+ // session provider is custom, preferredModel is null and PREFERENCES.md is
79
+ // skipped entirely — the snapshot then falls through to ctx.model.
80
+ const gateIdx = source.indexOf("sessionProviderIsCustom");
81
+ assert.ok(gateIdx > -1, "auto-start.ts should bind sessionProviderIsCustom");
82
+
83
+ const preferredIdx = source.indexOf("const preferredModel = ");
84
+ assert.ok(preferredIdx > -1, "auto-start.ts should build preferredModel");
85
+
86
+ const preferredBlock = source.slice(preferredIdx, preferredIdx + 200);
87
+ assert.ok(
88
+ preferredBlock.includes("sessionProviderIsCustom"),
89
+ "preferredModel must be gated on sessionProviderIsCustom so PREFERENCES.md is skipped for custom providers",
90
+ );
91
+
92
+ const snapshotIdx = source.indexOf("const startModelSnapshot = ");
93
+ assert.ok(snapshotIdx > -1, "auto-start.ts should build startModelSnapshot");
94
+
95
+ assert.ok(
96
+ customCheckIdx < preferredIdx && preferredIdx < snapshotIdx,
97
+ "isCustomProvider() must be evaluated before preferredModel, which must be resolved before startModelSnapshot",
98
+ );
50
99
  });
51
100
 
52
101
  test("bootstrapAutoSession validates preferred model against live registry auth (#unconfigured-models)", () => {
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Regression tests for #3348 secondary issues — crash handler gaps surfaced after #3696
3
+ *
4
+ * 1. register-extension.ts: writeCrashLog writes to ~/.gsd/crash/ directory
5
+ * 2. register-extension.ts: _gsdRejectionGuard registered for unhandledRejection
6
+ * 3. register-extension.ts: _gsdEpipeGuard exits with code 1 for unrecoverable errors (no log-and-continue)
7
+ * 4. crash-recovery.ts: emitCrashRecoveredUnitEnd closes open unit-start journal entries
8
+ */
9
+
10
+ import { describe, test } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { tmpdir } from 'node:os';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { dirname } from 'node:path';
18
+
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = dirname(__filename);
21
+
22
+ function makeTmpBase(): string {
23
+ const base = join(tmpdir(), `gsd-test-${randomUUID()}`);
24
+ mkdirSync(join(base, '.gsd'), { recursive: true });
25
+ return base;
26
+ }
27
+
28
+ // ─── register-extension source assertions ────────────────────────────────────
29
+
30
+ const registerExtSrc = readFileSync(
31
+ join(__dirname, '..', 'bootstrap', 'register-extension.ts'),
32
+ 'utf-8',
33
+ );
34
+
35
+ describe('register-extension crash handler secondary fixes (#3348)', () => {
36
+ test('writeCrashLog is exported and writes a file to the crash directory', async () => {
37
+ // Dynamic import so GSD_HOME can be pointed at a temp dir without polluting ~/.gsd
38
+ const tmpHome = join(tmpdir(), `gsd-crash-test-${randomUUID()}`);
39
+ const origHome = process.env.GSD_HOME;
40
+ process.env.GSD_HOME = tmpHome;
41
+ try {
42
+ const { writeCrashLog } = await import('../bootstrap/crash-log.ts');
43
+ const err = new Error('test crash from secondary regression test');
44
+ writeCrashLog(err, 'uncaughtException');
45
+
46
+ const crashDir = join(tmpHome, 'crash');
47
+ assert.ok(existsSync(crashDir), 'crash directory should be created');
48
+
49
+ const logs = readdirSync(crashDir).filter((f) => f.endsWith('.log'));
50
+ assert.equal(logs.length, 1, 'exactly one crash log should be written');
51
+
52
+ const content = readFileSync(join(crashDir, logs[0]), 'utf-8');
53
+ assert.ok(content.includes('test crash from secondary regression test'), 'log should contain error message');
54
+ assert.ok(content.includes('uncaughtException'), 'log should identify the source');
55
+ assert.ok(content.includes('pid:'), 'log should include process pid');
56
+ } finally {
57
+ process.env.GSD_HOME = origHome;
58
+ rmSync(tmpHome, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ test('_gsdRejectionGuard is registered for unhandledRejection', () => {
63
+ assert.match(
64
+ registerExtSrc,
65
+ /_gsdRejectionGuard/,
66
+ '_gsdRejectionGuard handler should be defined',
67
+ );
68
+ assert.match(
69
+ registerExtSrc,
70
+ /unhandledRejection/,
71
+ 'installEpipeGuard should register an unhandledRejection handler',
72
+ );
73
+ });
74
+
75
+ test('_gsdEpipeGuard calls process.exit(1) for unrecoverable errors, not log-and-continue', () => {
76
+ // The original #3696 fix replaced "throw err" with a log-and-continue.
77
+ // The secondary fix replaces that with writeCrashLog + process.exit(1).
78
+ assert.ok(
79
+ !registerExtSrc.includes('process.stderr.write(`[gsd] uncaught extension error (non-fatal)'),
80
+ '_gsdEpipeGuard should NOT log errors as non-fatal and continue',
81
+ );
82
+ assert.match(
83
+ registerExtSrc,
84
+ /process\.exit\(1\)/,
85
+ '_gsdEpipeGuard should call process.exit(1) for unrecoverable errors',
86
+ );
87
+ });
88
+
89
+ test('writeCrashLog never throws even when directory is unwritable', async () => {
90
+ const { writeCrashLog } = await import('../bootstrap/crash-log.ts');
91
+ const origHome = process.env.GSD_HOME;
92
+ // Point at a path that will fail to mkdir (e.g. a file that exists as non-dir)
93
+ const tmpFile = join(tmpdir(), `gsd-not-a-dir-${randomUUID()}`);
94
+ // Don't create it — mkdirSync with bad path should be caught internally
95
+ process.env.GSD_HOME = join(tmpFile, 'nested', 'deeply');
96
+ try {
97
+ // Should not throw
98
+ assert.doesNotThrow(() => {
99
+ writeCrashLog(new Error('should not throw'), 'test');
100
+ });
101
+ } finally {
102
+ process.env.GSD_HOME = origHome;
103
+ }
104
+ });
105
+ });
106
+
107
+ // ─── emitCrashRecoveredUnitEnd ────────────────────────────────────────────────
108
+
109
+ describe('emitCrashRecoveredUnitEnd (#3348)', () => {
110
+ test('emits synthetic unit-end when unit-start has no matching unit-end', async () => {
111
+ const base = makeTmpBase();
112
+ try {
113
+ const { emitJournalEvent, queryJournal } = await import('../journal.ts');
114
+ const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
115
+
116
+ const flowId = randomUUID();
117
+ const unitStartSeq = 5;
118
+
119
+ // Emit a unit-start with no corresponding unit-end (simulating a crash)
120
+ emitJournalEvent(base, {
121
+ ts: new Date().toISOString(),
122
+ flowId,
123
+ seq: unitStartSeq,
124
+ eventType: 'unit-start',
125
+ data: { unitType: 'execute-task', unitId: 'M001/S01/T01' },
126
+ });
127
+
128
+ const lock = {
129
+ pid: 99999,
130
+ startedAt: new Date().toISOString(),
131
+ unitType: 'execute-task',
132
+ unitId: 'M001/S01/T01',
133
+ unitStartedAt: new Date().toISOString(),
134
+ };
135
+
136
+ emitCrashRecoveredUnitEnd(base, lock);
137
+
138
+ const events = queryJournal(base);
139
+ const ends = events.filter((e) => e.eventType === 'unit-end');
140
+ assert.equal(ends.length, 1, 'should emit exactly one unit-end');
141
+ assert.equal(ends[0].data?.unitId, 'M001/S01/T01');
142
+ assert.equal(ends[0].data?.status, 'crash-recovered');
143
+ assert.equal(ends[0].causedBy?.flowId, flowId);
144
+ assert.equal(ends[0].causedBy?.seq, unitStartSeq);
145
+ assert.ok(ends[0].seq > unitStartSeq, 'unit-end seq must be higher than unit-start seq');
146
+ } finally {
147
+ rmSync(base, { recursive: true, force: true });
148
+ }
149
+ });
150
+
151
+ test('is a no-op when unit-end was already emitted (e.g. hard timeout fired)', async () => {
152
+ const base = makeTmpBase();
153
+ try {
154
+ const { emitJournalEvent, queryJournal } = await import('../journal.ts');
155
+ const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
156
+
157
+ const flowId = randomUUID();
158
+ emitJournalEvent(base, {
159
+ ts: new Date().toISOString(),
160
+ flowId,
161
+ seq: 3,
162
+ eventType: 'unit-start',
163
+ data: { unitType: 'plan-slice', unitId: 'M001/S02' },
164
+ });
165
+ // Hard timeout already emitted a unit-end
166
+ emitJournalEvent(base, {
167
+ ts: new Date().toISOString(),
168
+ flowId,
169
+ seq: 4,
170
+ eventType: 'unit-end',
171
+ data: { unitType: 'plan-slice', unitId: 'M001/S02', status: 'cancelled' },
172
+ causedBy: { flowId, seq: 3 },
173
+ });
174
+
175
+ const lock = {
176
+ pid: 99999,
177
+ startedAt: new Date().toISOString(),
178
+ unitType: 'plan-slice',
179
+ unitId: 'M001/S02',
180
+ unitStartedAt: new Date().toISOString(),
181
+ };
182
+ emitCrashRecoveredUnitEnd(base, lock);
183
+
184
+ const ends = queryJournal(base).filter((e) => e.eventType === 'unit-end');
185
+ assert.equal(ends.length, 1, 'should not emit a duplicate unit-end');
186
+ assert.equal(ends[0].data?.status, 'cancelled', 'original unit-end should be preserved');
187
+ } finally {
188
+ rmSync(base, { recursive: true, force: true });
189
+ }
190
+ });
191
+
192
+ test('is a no-op for "starting" pseudo-units (bootstrap crash)', async () => {
193
+ const base = makeTmpBase();
194
+ try {
195
+ const { queryJournal } = await import('../journal.ts');
196
+ const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
197
+
198
+ const lock = {
199
+ pid: 99999,
200
+ startedAt: new Date().toISOString(),
201
+ unitType: 'starting',
202
+ unitId: 'bootstrap',
203
+ unitStartedAt: new Date().toISOString(),
204
+ };
205
+ emitCrashRecoveredUnitEnd(base, lock);
206
+
207
+ const events = queryJournal(base);
208
+ assert.equal(events.length, 0, 'should emit nothing for starting/bootstrap pseudo-units');
209
+ } finally {
210
+ rmSync(base, { recursive: true, force: true });
211
+ }
212
+ });
213
+
214
+ test('is a no-op when no unit-start exists in the journal', async () => {
215
+ const base = makeTmpBase();
216
+ try {
217
+ const { queryJournal } = await import('../journal.ts');
218
+ const { emitCrashRecoveredUnitEnd } = await import('../crash-recovery.ts');
219
+
220
+ const lock = {
221
+ pid: 99999,
222
+ startedAt: new Date().toISOString(),
223
+ unitType: 'execute-task',
224
+ unitId: 'M002/S01/T03',
225
+ unitStartedAt: new Date().toISOString(),
226
+ };
227
+ emitCrashRecoveredUnitEnd(base, lock);
228
+
229
+ const events = queryJournal(base);
230
+ assert.equal(events.length, 0, 'should emit nothing when there is no journal entry to close');
231
+ } finally {
232
+ rmSync(base, { recursive: true, force: true });
233
+ }
234
+ });
235
+ });
@@ -351,8 +351,9 @@ skills_used: []
351
351
  const dbState = await deriveStateFromDb(base);
352
352
 
353
353
  assertStatesEqual(dbState, fileState, 'E-blocked');
354
- assert.deepStrictEqual(dbState.phase, 'blocked', 'E-blocked: phase is blocked');
355
- assert.ok(dbState.blockers.length > 0, 'E-blocked: has blockers');
354
+ // With partial-dep fallback, circular deps no longer block — fallback picks first eligible slice
355
+ assert.deepStrictEqual(dbState.phase, 'planning', 'E-blocked: phase is planning (fallback picks a slice)');
356
+ assert.ok(dbState.activeSlice !== null, 'E-blocked: activeSlice is set via fallback');
356
357
 
357
358
  closeDatabase();
358
359
  } finally {
@@ -616,9 +616,10 @@ describe('derive-state-db', async () => {
616
616
  invalidateStateCache();
617
617
  const dbState = await deriveStateFromDb(base);
618
618
 
619
- assert.deepStrictEqual(dbState.phase, 'blocked', 'blocked-db: phase is blocked');
619
+ // With partial-dep fallback, circular deps no longer block — fallback picks first eligible slice
620
+ assert.deepStrictEqual(dbState.phase, 'planning', 'blocked-db: phase is planning (fallback picks a slice)');
620
621
  assert.deepStrictEqual(dbState.phase, fileState.phase, 'blocked-db: phase matches filesystem');
621
- assert.ok(dbState.blockers.length > 0, 'blocked-db: has blockers');
622
+ assert.ok(dbState.activeSlice !== null, 'blocked-db: activeSlice is set via fallback');
622
623
 
623
624
  closeDatabase();
624
625
  } finally {
@@ -446,9 +446,9 @@ Continue from step 2.
446
446
 
447
447
  const state2 = await deriveState(base2);
448
448
 
449
- assert.deepStrictEqual(state2.phase, 'blocked', 'blocked-B: phase is blocked');
450
- assert.deepStrictEqual(state2.activeSlice, null, 'blocked-B: activeSlice is null');
451
- assert.ok(state2.blockers.length > 0, 'blocked-B: blockers array is non-empty');
449
+ // With partial-dep fallback, S01 is picked despite unmet dep on S99
450
+ assert.deepStrictEqual(state2.phase, 'planning', 'blocked-B: phase is planning (fallback picks S01)');
451
+ assert.deepStrictEqual(state2.activeSlice?.id, 'S01', 'blocked-B: activeSlice is S01 via fallback');
452
452
  } finally {
453
453
  cleanup(base2);
454
454
  }