gsd-pi 2.73.0-dev.e1c09f2 → 2.73.1-dev.6ddfa43

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 (87) hide show
  1. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +9 -3
  2. package/dist/resources/extensions/gsd/auto-model-selection.js +54 -11
  3. package/dist/resources/extensions/gsd/auto-start.js +20 -6
  4. package/dist/resources/extensions/gsd/auto.js +5 -1
  5. package/dist/resources/extensions/gsd/bootstrap/crash-log.js +31 -0
  6. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +18 -7
  7. package/dist/resources/extensions/gsd/crash-recovery.js +51 -0
  8. package/dist/resources/extensions/gsd/gsd-db.js +36 -2
  9. package/dist/resources/extensions/gsd/milestone-actions.js +19 -1
  10. package/dist/resources/extensions/gsd/preferences-models.js +43 -0
  11. package/dist/resources/extensions/gsd/preferences-types.js +1 -0
  12. package/dist/resources/extensions/gsd/preferences-validation.js +22 -0
  13. package/dist/web/standalone/.next/BUILD_ID +1 -1
  14. package/dist/web/standalone/.next/app-path-routes-manifest.json +10 -10
  15. package/dist/web/standalone/.next/build-manifest.json +2 -2
  16. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  17. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.html +1 -1
  34. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app-paths-manifest.json +10 -10
  41. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  42. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  43. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  44. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  45. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  46. package/package.json +1 -1
  47. package/packages/pi-ai/dist/index.d.ts +1 -0
  48. package/packages/pi-ai/dist/index.d.ts.map +1 -1
  49. package/packages/pi-ai/dist/index.js +1 -0
  50. package/packages/pi-ai/dist/index.js.map +1 -1
  51. package/packages/pi-ai/src/index.ts +4 -0
  52. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +175 -8
  53. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
  54. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts +12 -2
  55. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  56. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +51 -26
  57. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
  58. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  59. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +73 -12
  60. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  61. package/packages/pi-coding-agent/package.json +1 -1
  62. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +198 -8
  63. package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +62 -26
  64. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +92 -17
  65. package/pkg/package.json +1 -1
  66. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +12 -4
  67. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +23 -2
  68. package/src/resources/extensions/gsd/auto-model-selection.ts +85 -11
  69. package/src/resources/extensions/gsd/auto-start.ts +27 -6
  70. package/src/resources/extensions/gsd/auto.ts +5 -0
  71. package/src/resources/extensions/gsd/bootstrap/crash-log.ts +32 -0
  72. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +19 -7
  73. package/src/resources/extensions/gsd/crash-recovery.ts +59 -0
  74. package/src/resources/extensions/gsd/gsd-db.ts +52 -2
  75. package/src/resources/extensions/gsd/milestone-actions.ts +19 -1
  76. package/src/resources/extensions/gsd/preferences-models.ts +41 -0
  77. package/src/resources/extensions/gsd/preferences-types.ts +12 -0
  78. package/src/resources/extensions/gsd/preferences-validation.ts +23 -0
  79. package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +51 -2
  80. package/src/resources/extensions/gsd/tests/crash-handler-secondary.test.ts +235 -0
  81. package/src/resources/extensions/gsd/tests/flat-rate-routing-guard.test.ts +137 -1
  82. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +59 -1
  83. package/src/resources/extensions/gsd/tests/model-isolation.test.ts +91 -2
  84. package/src/resources/extensions/gsd/tests/park-milestone.test.ts +64 -0
  85. package/src/resources/extensions/gsd/tests/preferences.test.ts +47 -0
  86. /package/dist/web/standalone/.next/static/{_XD_gUDcZNBbWV5rI8RgS → r6AvNu-aMwn4nwqjHqAfw}/_buildManifest.js +0 -0
  87. /package/dist/web/standalone/.next/static/{_XD_gUDcZNBbWV5rI8RgS → r6AvNu-aMwn4nwqjHqAfw}/_ssgManifest.js +0 -0
@@ -6,7 +6,7 @@
6
6
  * AssistantMessageEvents for TUI rendering, then strips tool-call blocks from
7
7
  * the final AssistantMessage so GSD's agent loop doesn't try to dispatch them.
8
8
  */
9
- import { EventStream } from "@gsd/pi-ai";
9
+ import { EventStream, mapThinkingLevelToEffort, supportsAdaptiveThinking } from "@gsd/pi-ai";
10
10
  import { execSync } from "node:child_process";
11
11
  import { PartialMessageBuilder, ZERO_USAGE, mapUsage } from "./partial-builder.js";
12
12
  import { buildWorkflowMcpServers } from "../gsd/workflow-mcp.js";
@@ -437,6 +437,7 @@ export async function resolveClaudePermissionMode(env = process.env) {
437
437
  * behaviour pass `permissionMode: "bypassPermissions"` explicitly.
438
438
  */
439
439
  export function buildSdkOptions(modelId, prompt, overrides, extraOptions = {}) {
440
+ const { reasoning, ...sdkExtraOptions } = extraOptions;
440
441
  const mcpServers = buildWorkflowMcpServers();
441
442
  const permissionMode = overrides?.permissionMode ?? "bypassPermissions";
442
443
  const disallowedTools = ["AskUserQuestion"];
@@ -455,6 +456,9 @@ export function buildSdkOptions(modelId, prompt, overrides, extraOptions = {}) {
455
456
  "Bash(pwd)",
456
457
  ...(mcpServers ? Object.keys(mcpServers).map((serverName) => `mcp__${serverName}__*`) : []),
457
458
  ];
459
+ const effort = reasoning && supportsAdaptiveThinking(modelId)
460
+ ? mapThinkingLevelToEffort(reasoning, modelId)
461
+ : undefined;
458
462
  return {
459
463
  pathToClaudeCodeExecutable: getClaudePath(),
460
464
  model: modelId,
@@ -469,7 +473,8 @@ export function buildSdkOptions(modelId, prompt, overrides, extraOptions = {}) {
469
473
  ...(allowedTools.length > 0 ? { allowedTools } : {}),
470
474
  ...(mcpServers ? { mcpServers } : {}),
471
475
  betas: modelId.includes("sonnet") ? ["context-1m-2025-08-07"] : [],
472
- ...extraOptions,
476
+ ...(effort ? { effort } : {}),
477
+ ...sdkExtraOptions,
473
478
  };
474
479
  }
475
480
  function normalizeToolResultContent(content) {
@@ -620,9 +625,10 @@ async function pumpSdkMessages(model, context, options, stream) {
620
625
  const permissionMode = await resolveClaudePermissionMode();
621
626
  const sdkOpts = buildSdkOptions(modelId, prompt, { permissionMode }, typeof options?.extensionUIContext === "object"
622
627
  ? {
628
+ reasoning: options?.reasoning,
623
629
  onElicitation: createClaudeCodeElicitationHandler(options?.extensionUIContext),
624
630
  }
625
- : {});
631
+ : { reasoning: options?.reasoning });
626
632
  const queryResult = sdk.query({
627
633
  prompt,
628
634
  options: {
@@ -9,10 +9,8 @@ import { resolveModelForComplexity, escalateTier, getEligibleModels, loadCapabil
9
9
  import { getLedger, getProjectTotals } from "./metrics.js";
10
10
  import { unitPhaseLabel } from "./auto-dashboard.js";
11
11
  import { getSessionModelOverride } from "./session-model-override.js";
12
- export function resolvePreferredModelConfig(unitType, autoModeStartModel,
13
- /** When false, only return explicit per-phase model configs — do not
14
- * synthesize a routing ceiling from dynamic_routing.tier_models (#3962). */
15
- isAutoMode = true) {
12
+ import { logWarning } from "./workflow-logger.js";
13
+ export function resolvePreferredModelConfig(unitType, autoModeStartModel, isAutoMode = true) {
16
14
  const explicitConfig = resolveModelWithFallbacksForUnit(unitType);
17
15
  if (explicitConfig)
18
16
  return explicitConfig;
@@ -24,7 +22,7 @@ isAutoMode = true) {
24
22
  if (!routingConfig.enabled || !routingConfig.tier_models)
25
23
  return undefined;
26
24
  // Don't synthesize a routing config for flat-rate providers (#3453).
27
- if (autoModeStartModel && isFlatRateProvider(autoModeStartModel.provider))
25
+ if (autoModeStartModel && isFlatRateProvider(autoModeStartModel.provider, autoModeStartModel.flatRateCtx))
28
26
  return undefined;
29
27
  const ceilingModel = routingConfig.tier_models.heavy
30
28
  ?? (autoModeStartModel ? `${autoModeStartModel.provider}/${autoModeStartModel.id}` : undefined);
@@ -51,6 +49,17 @@ sessionModelOverride) {
51
49
  const effectiveSessionModelOverride = sessionModelOverride === undefined
52
50
  ? getSessionModelOverride(ctx.sessionManager.getSessionId())
53
51
  : (sessionModelOverride ?? undefined);
52
+ // Enrich the start model with a flat-rate context up front so routing
53
+ // synthesis and the dispatch-time guard see the same signals (built-in
54
+ // list + user `flat_rate_providers` preference + externalCli auto-
55
+ // detection). The dispatch-time primary-model check below builds its
56
+ // own per-provider context when it has a resolved primary model.
57
+ if (autoModeStartModel) {
58
+ autoModeStartModel = {
59
+ ...autoModeStartModel,
60
+ flatRateCtx: buildFlatRateContext(autoModeStartModel.provider, ctx, prefs),
61
+ };
62
+ }
54
63
  const modelConfig = effectiveSessionModelOverride
55
64
  ? undefined
56
65
  : resolvePreferredModelConfig(unitType, autoModeStartModel, isAutoMode);
@@ -76,12 +85,13 @@ sessionModelOverride) {
76
85
  if (routingConfig.enabled) {
77
86
  const primaryModel = resolveModelId(modelConfig.primary, availableModels, ctx.model?.provider);
78
87
  if (primaryModel) {
79
- if (isFlatRateProvider(primaryModel.provider)) {
88
+ const primaryFlatRateCtx = buildFlatRateContext(primaryModel.provider, ctx, prefs);
89
+ if (isFlatRateProvider(primaryModel.provider, primaryFlatRateCtx)) {
80
90
  routingConfig.enabled = false;
81
91
  }
82
92
  }
83
- else if ((autoModeStartModel && isFlatRateProvider(autoModeStartModel.provider))
84
- || (ctx.model?.provider && isFlatRateProvider(ctx.model.provider))) {
93
+ else if ((autoModeStartModel && isFlatRateProvider(autoModeStartModel.provider, autoModeStartModel.flatRateCtx))
94
+ || (ctx.model?.provider && isFlatRateProvider(ctx.model.provider, buildFlatRateContext(ctx.model.provider, ctx, prefs)))) {
85
95
  // Primary model unresolvable but provider signals indicate flat-rate —
86
96
  // disable routing to prevent quality degradation.
87
97
  routingConfig.enabled = false;
@@ -331,7 +341,40 @@ export function resolveModelId(modelId, availableModels, currentProvider) {
331
341
  * Uses case-insensitive matching with alias support to prevent fail-open on
332
342
  * provider naming variations (e.g. "copilot" vs "github-copilot").
333
343
  */
334
- const FLAT_RATE_PROVIDERS = new Set(["github-copilot", "copilot", "claude-code"]);
335
- export function isFlatRateProvider(provider) {
336
- return FLAT_RATE_PROVIDERS.has(provider.toLowerCase());
344
+ const BUILTIN_FLAT_RATE = new Set(["github-copilot", "copilot", "claude-code"]);
345
+ export function isFlatRateProvider(provider, opts) {
346
+ const p = provider.toLowerCase();
347
+ if (BUILTIN_FLAT_RATE.has(p))
348
+ return true;
349
+ if (opts?.userFlatRate?.some(id => id.toLowerCase() === p))
350
+ return true;
351
+ if (opts?.authMode === "externalCli")
352
+ return true;
353
+ return false;
354
+ }
355
+ /**
356
+ * Build a FlatRateContext for a given provider from live runtime state.
357
+ * Safe to call when ctx or prefs are undefined — missing pieces are
358
+ * treated as "no signal".
359
+ */
360
+ export function buildFlatRateContext(provider, ctx, prefs) {
361
+ let authMode;
362
+ const getAuthMode = ctx?.modelRegistry?.getProviderAuthMode;
363
+ if (typeof getAuthMode === "function") {
364
+ try {
365
+ const mode = getAuthMode(provider);
366
+ if (mode === "apiKey" || mode === "oauth" || mode === "externalCli" || mode === "none") {
367
+ authMode = mode;
368
+ }
369
+ }
370
+ catch (err) {
371
+ // Registry lookup failure must never break flat-rate detection —
372
+ // fall through with authMode undefined and surface the cause.
373
+ logWarning("dispatch", `flat-rate auth-mode lookup failed for ${provider}: ${err instanceof Error ? err.message : String(err)}`);
374
+ }
375
+ }
376
+ return {
377
+ authMode,
378
+ userFlatRate: prefs?.flat_rate_providers,
379
+ };
337
380
  }
@@ -38,7 +38,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, unlinkSync, } fro
38
38
  import { join } from "node:path";
39
39
  import { sep as pathSep } from "node:path";
40
40
  import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js";
41
- import { resolveDefaultSessionModel, resolveDynamicRoutingConfig } from "./preferences-models.js";
41
+ import { isCustomProvider, resolveDefaultSessionModel, resolveDynamicRoutingConfig, } from "./preferences-models.js";
42
42
  import { getSessionModelOverride } from "./session-model-override.js";
43
43
  /**
44
44
  * Bootstrap a fresh auto-mode session. Handles everything from git init
@@ -195,8 +195,18 @@ export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, reques
195
195
  //
196
196
  // This preserves #3517 defaults while honoring explicit runtime model
197
197
  // selection for subsequent /gsd runs in the same session.
198
+ //
199
+ // Exception (#4122): when the session provider is a custom provider declared
200
+ // in ~/.gsd/agent/models.json (Ollama, vLLM, OpenAI-compatible proxy, etc.),
201
+ // PREFERENCES.md is skipped entirely. PREFERENCES.md cannot reference custom
202
+ // providers, so honoring it would silently reroute auto-mode to a built-in
203
+ // provider the user is not logged into and surface as "Not logged in · Please
204
+ // run /login" before pausing and resetting to claude-code/claude-sonnet-4-6.
198
205
  const manualSessionOverride = getSessionModelOverride(ctx.sessionManager.getSessionId());
199
- const preferredModel = resolveDefaultSessionModel(ctx.model?.provider);
206
+ const sessionProviderIsCustom = isCustomProvider(ctx.model?.provider);
207
+ const preferredModel = sessionProviderIsCustom
208
+ ? null
209
+ : resolveDefaultSessionModel(ctx.model?.provider);
200
210
  // Validate the preferred model against the live registry + provider auth so
201
211
  // an unconfigured PREFERENCES.md entry (no API key / OAuth) can't become the
202
212
  // start-model snapshot. Without this, every subsequent unit would try to
@@ -636,12 +646,16 @@ export async function bootstrapAutoSession(s, ctx, pi, base, verboseMode, reques
636
646
  const startModelLabel = s.autoModeStartModel
637
647
  ? `${s.autoModeStartModel.provider}/${s.autoModeStartModel.id}`
638
648
  : ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "default";
639
- // Flat-rate providers (e.g. GitHub Copilot, claude-code) suppress routing
640
- // at dispatch time (#3453) reflect that in the banner.
641
- const { isFlatRateProvider } = await import("./auto-model-selection.js");
649
+ // Flat-rate providers (e.g. GitHub Copilot, claude-code, user-declared
650
+ // subscription proxies, externalCli CLIs) suppress routing at dispatch
651
+ // time (#3453) reflect that in the banner. Thread the same
652
+ // FlatRateContext used by selectAndApplyModel so user-declared
653
+ // flat-rate providers and externalCli auto-detection are respected.
654
+ const { isFlatRateProvider, buildFlatRateContext } = await import("./auto-model-selection.js");
655
+ const bannerPrefs = loadEffectiveGSDPreferences()?.preferences;
642
656
  const effectiveProvider = s.autoModeStartModel?.provider ?? ctx.model?.provider;
643
657
  const effectivelyEnabled = routingConfig.enabled
644
- && !(effectiveProvider && isFlatRateProvider(effectiveProvider));
658
+ && !(effectiveProvider && isFlatRateProvider(effectiveProvider, buildFlatRateContext(effectiveProvider, ctx, bannerPrefs)));
645
659
  // The actual ceiling may come from tier_models.heavy, not the start model.
646
660
  const effectiveCeiling = (routingConfig.enabled && routingConfig.tier_models?.heavy)
647
661
  ? routingConfig.tier_models.heavy
@@ -19,7 +19,7 @@ import { gsdRoot, resolveMilestoneFile, resolveMilestonePath, resolveDir, milest
19
19
  import { invalidateAllCaches } from "./cache.js";
20
20
  import { clearActivityLogState } from "./activity-log.js";
21
21
  import { synthesizeCrashRecovery, getDeepDiagnostic, readActiveMilestoneId, } from "./session-forensics.js";
22
- import { writeLock, clearLock, readCrashLock, isLockProcessAlive, formatCrashInfo, } from "./crash-recovery.js";
22
+ import { writeLock, clearLock, readCrashLock, isLockProcessAlive, formatCrashInfo, emitCrashRecoveredUnitEnd, } from "./crash-recovery.js";
23
23
  import { acquireSessionLock, getSessionLockStatus, releaseSessionLock, updateSessionLock, } from "./session-lock.js";
24
24
  import { resolveAutoSupervisorConfig, loadEffectiveGSDPreferences, getIsolationMode, } from "./preferences.js";
25
25
  import { sendDesktopNotification } from "./notifications.js";
@@ -1014,6 +1014,10 @@ export async function startAuto(ctx, pi, base, verboseMode, options) {
1014
1014
  s.stepMode = requestedStepMode;
1015
1015
  }
1016
1016
  if (freshStartAssessment.lock) {
1017
+ // Emit a synthetic unit-end for any unit-start that has no closing event.
1018
+ // This closes the journal gap reported in #3348 where the worker wrote side
1019
+ // effects (SUMMARY.md, DB updates) but died before emitting unit-end.
1020
+ emitCrashRecoveredUnitEnd(base, freshStartAssessment.lock);
1017
1021
  clearLock(base);
1018
1022
  }
1019
1023
  if (!s.paused) {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * crash-log.ts — Write crash diagnostics to ~/.gsd/crash/<timestamp>.log
3
+ *
4
+ * Zero cross-dependencies: only uses Node.js built-ins so it can be imported
5
+ * safely from uncaughtException / unhandledRejection handlers and from tests
6
+ * without pulling in the full extension dependency tree.
7
+ */
8
+ import { appendFileSync, mkdirSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ /**
12
+ * Write a crash log to ~/.gsd/crash/<timestamp>.log (or $GSD_HOME/crash/).
13
+ * Never throws — must be safe to call from any error handler.
14
+ */
15
+ export function writeCrashLog(err, source) {
16
+ try {
17
+ const crashDir = join(process.env.GSD_HOME ?? join(homedir(), ".gsd"), "crash");
18
+ mkdirSync(crashDir, { recursive: true });
19
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
20
+ const logPath = join(crashDir, `${ts}.log`);
21
+ const lines = [
22
+ `[gsd] ${source}: ${err.message}`,
23
+ `timestamp: ${new Date().toISOString()}`,
24
+ `pid: ${process.pid}`,
25
+ err.stack ?? "(no stack trace available)",
26
+ "",
27
+ ];
28
+ appendFileSync(logPath, lines.join("\n"));
29
+ }
30
+ catch { /* never throw from crash handler */ }
31
+ }
@@ -8,6 +8,8 @@ import { registerJournalTools } from "./journal-tools.js";
8
8
  import { registerQueryTools } from "./query-tools.js";
9
9
  import { registerHooks } from "./register-hooks.js";
10
10
  import { registerShortcuts } from "./register-shortcuts.js";
11
+ import { writeCrashLog } from "./crash-log.js";
12
+ export { writeCrashLog } from "./crash-log.js";
11
13
  export function handleRecoverableExtensionProcessError(err) {
12
14
  if (err.code === "EPIPE") {
13
15
  process.exit(0);
@@ -28,17 +30,26 @@ export function handleRecoverableExtensionProcessError(err) {
28
30
  function installEpipeGuard() {
29
31
  if (!process.listeners("uncaughtException").some((listener) => listener.name === "_gsdEpipeGuard")) {
30
32
  const _gsdEpipeGuard = (err) => {
31
- if (handleRecoverableExtensionProcessError(err)) {
33
+ if (handleRecoverableExtensionProcessError(err))
32
34
  return;
33
- }
34
- // Log unhandled errors instead of re-throwing throwing inside an
35
- // uncaughtException handler is a fatal double-fault in Node.js (#3163).
36
- process.stderr.write(`[gsd] uncaught extension error (non-fatal): ${err.message}\n`);
37
- if (err.stack)
38
- process.stderr.write(`${err.stack}\n`);
35
+ // Write crash log and exit cleanly for unrecoverable errors.
36
+ // Logging and continuing was the original double-fault fix (#3163), but
37
+ // continuing in an indeterminate state is worse than a clean exit (#3348).
38
+ writeCrashLog(err, "uncaughtException");
39
+ process.exit(1);
39
40
  };
40
41
  process.on("uncaughtException", _gsdEpipeGuard);
41
42
  }
43
+ if (!process.listeners("unhandledRejection").some((listener) => listener.name === "_gsdRejectionGuard")) {
44
+ const _gsdRejectionGuard = (reason, _promise) => {
45
+ const err = reason instanceof Error ? reason : new Error(String(reason));
46
+ if (handleRecoverableExtensionProcessError(err))
47
+ return;
48
+ writeCrashLog(err, "unhandledRejection");
49
+ process.exit(1);
50
+ };
51
+ process.on("unhandledRejection", _gsdRejectionGuard);
52
+ }
42
53
  }
43
54
  export function registerGsdExtension(pi) {
44
55
  registerGSDCommand(pi);
@@ -14,6 +14,7 @@ import { join } from "node:path";
14
14
  import { gsdRoot } from "./paths.js";
15
15
  import { atomicWriteSync } from "./atomic-write.js";
16
16
  import { effectiveLockFile } from "./session-lock.js";
17
+ import { emitJournalEvent, queryJournal } from "./journal.js";
17
18
  function lockPath(basePath) {
18
19
  return join(gsdRoot(basePath), effectiveLockFile());
19
20
  }
@@ -110,3 +111,53 @@ export function formatCrashInfo(lock) {
110
111
  }
111
112
  return lines.join("\n");
112
113
  }
114
+ /**
115
+ * Emit a synthetic unit-end event for a unit that crashed without emitting its own.
116
+ *
117
+ * Queries the journal to find the most recent unit-start for the crashed unit.
118
+ * If a matching unit-end already exists (e.g. the hard timeout fired), this is a
119
+ * no-op. Called during crash recovery, before clearing the stale lock.
120
+ *
121
+ * Addresses the gap reported in #3348 where `unit-start` was emitted but no
122
+ * `unit-end` followed — side effects landed but the worker died before closeout.
123
+ */
124
+ export function emitCrashRecoveredUnitEnd(basePath, lock) {
125
+ // Skip bootstrap / starting pseudo-units — they have no meaningful unit-start event.
126
+ if (!lock.unitType || !lock.unitId || lock.unitType === "starting")
127
+ return;
128
+ try {
129
+ const all = queryJournal(basePath);
130
+ // Find the most recent unit-start for this unitId
131
+ const starts = all.filter((e) => e.eventType === "unit-start" && e.data?.unitId === lock.unitId);
132
+ if (starts.length === 0)
133
+ return;
134
+ const lastStart = starts[starts.length - 1];
135
+ // Check if a unit-end was already emitted (e.g. hard timeout fired after the crash)
136
+ const alreadyClosed = all.some((e) => e.eventType === "unit-end" &&
137
+ e.data?.unitId === lock.unitId &&
138
+ e.causedBy?.flowId === lastStart.flowId &&
139
+ e.causedBy?.seq === lastStart.seq);
140
+ if (alreadyClosed)
141
+ return;
142
+ // Find the highest seq in this flow for monotonic ordering
143
+ const maxSeq = all
144
+ .filter((e) => e.flowId === lastStart.flowId)
145
+ .reduce((max, e) => Math.max(max, e.seq), lastStart.seq);
146
+ emitJournalEvent(basePath, {
147
+ ts: new Date().toISOString(),
148
+ flowId: lastStart.flowId,
149
+ seq: maxSeq + 1,
150
+ eventType: "unit-end",
151
+ data: {
152
+ unitType: lock.unitType,
153
+ unitId: lock.unitId,
154
+ status: "crash-recovered",
155
+ artifactVerified: false,
156
+ },
157
+ causedBy: { flowId: lastStart.flowId, seq: lastStart.seq },
158
+ });
159
+ }
160
+ catch {
161
+ // Never throw from crash recovery path — journal failure must not block recovery
162
+ }
163
+ }
@@ -1352,6 +1352,25 @@ export function setSliceSummaryMd(milestoneId, sliceId, summaryMd, uatMd) {
1352
1352
  throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
1353
1353
  currentDb.prepare(`UPDATE slices SET full_summary_md = :summary_md, full_uat_md = :uat_md WHERE milestone_id = :mid AND id = :sid`).run({ ":mid": milestoneId, ":sid": sliceId, ":summary_md": summaryMd, ":uat_md": uatMd });
1354
1354
  }
1355
+ function parseTaskArrayColumn(raw) {
1356
+ if (typeof raw !== "string" || raw.trim() === "")
1357
+ return [];
1358
+ try {
1359
+ const parsed = JSON.parse(raw);
1360
+ if (Array.isArray(parsed))
1361
+ return parsed.map((value) => String(value));
1362
+ if (parsed === null || parsed === undefined || parsed === "")
1363
+ return [];
1364
+ return [String(parsed)];
1365
+ }
1366
+ catch {
1367
+ // Older/corrupt rows may contain comma-separated strings instead of JSON.
1368
+ return raw
1369
+ .split(",")
1370
+ .map((value) => value.trim())
1371
+ .filter(Boolean);
1372
+ }
1373
+ }
1355
1374
  function rowToTask(row) {
1356
1375
  const parseTaskArray = (value) => {
1357
1376
  if (Array.isArray(value)) {
@@ -1390,8 +1409,8 @@ function rowToTask(row) {
1390
1409
  blocker_discovered: row["blocker_discovered"] === 1,
1391
1410
  deviations: row["deviations"],
1392
1411
  known_issues: row["known_issues"],
1393
- key_files: JSON.parse(row["key_files"] || "[]"),
1394
- key_decisions: JSON.parse(row["key_decisions"] || "[]"),
1412
+ key_files: parseTaskArrayColumn(row["key_files"]),
1413
+ key_decisions: parseTaskArrayColumn(row["key_decisions"]),
1395
1414
  full_summary_md: row["full_summary_md"],
1396
1415
  description: row["description"] ?? "",
1397
1416
  estimate: row["estimate"] ?? "",
@@ -1855,6 +1874,21 @@ export function deleteSlice(milestoneId, sliceId) {
1855
1874
  currentDb.prepare(`DELETE FROM slices WHERE milestone_id = :mid AND id = :sid`).run({ ":mid": milestoneId, ":sid": sliceId });
1856
1875
  });
1857
1876
  }
1877
+ export function deleteMilestone(milestoneId) {
1878
+ if (!currentDb)
1879
+ throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
1880
+ transaction(() => {
1881
+ currentDb.prepare(`DELETE FROM verification_evidence WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1882
+ currentDb.prepare(`DELETE FROM quality_gates WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1883
+ currentDb.prepare(`DELETE FROM tasks WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1884
+ currentDb.prepare(`DELETE FROM slice_dependencies WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1885
+ currentDb.prepare(`DELETE FROM slices WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1886
+ currentDb.prepare(`DELETE FROM replan_history WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1887
+ currentDb.prepare(`DELETE FROM assessments WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1888
+ currentDb.prepare(`DELETE FROM artifacts WHERE milestone_id = :mid`).run({ ":mid": milestoneId });
1889
+ currentDb.prepare(`DELETE FROM milestones WHERE id = :mid`).run({ ":mid": milestoneId });
1890
+ });
1891
+ }
1858
1892
  export function updateSliceFields(milestoneId, sliceId, fields) {
1859
1893
  if (!currentDb)
1860
1894
  throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
@@ -15,7 +15,8 @@ import { join } from "node:path";
15
15
  import { resolveMilestonePath, resolveMilestoneFile, buildMilestoneFileName, } from "./paths.js";
16
16
  import { invalidateAllCaches } from "./cache.js";
17
17
  import { loadQueueOrder, saveQueueOrder } from "./queue-order.js";
18
- import { getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js";
18
+ import { deleteMilestone, getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js";
19
+ import { removeWorktree } from "./worktree-manager.js";
19
20
  import { logWarning } from "./workflow-logger.js";
20
21
  // ─── Park ──────────────────────────────────────────────────────────────────
21
22
  /**
@@ -99,12 +100,29 @@ export function discardMilestone(basePath, milestoneId) {
99
100
  const mDir = resolveMilestonePath(basePath, milestoneId);
100
101
  if (!mDir || !existsSync(mDir))
101
102
  return false;
103
+ try {
104
+ removeWorktree(basePath, milestoneId, {
105
+ branch: `milestone/${milestoneId}`,
106
+ deleteBranch: true,
107
+ });
108
+ }
109
+ catch (err) {
110
+ logWarning("engine", `discardMilestone worktree cleanup failed for ${milestoneId}: ${err.message}`);
111
+ }
102
112
  rmSync(mDir, { recursive: true, force: true });
103
113
  // Prune from queue order if present
104
114
  const order = loadQueueOrder(basePath);
105
115
  if (order && order.includes(milestoneId)) {
106
116
  saveQueueOrder(basePath, order.filter(id => id !== milestoneId));
107
117
  }
118
+ if (isDbAvailable()) {
119
+ try {
120
+ deleteMilestone(milestoneId);
121
+ }
122
+ catch (err) {
123
+ logWarning("engine", `discardMilestone DB cleanup failed for ${milestoneId}: ${err.message}`);
124
+ }
125
+ }
108
126
  invalidateAllCaches();
109
127
  return true;
110
128
  }
@@ -6,6 +6,8 @@
6
6
  * and dynamic routing configuration.
7
7
  */
8
8
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
9
11
  import { defaultRoutingConfig } from "./model-router.js";
10
12
  import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js";
11
13
  /**
@@ -161,6 +163,47 @@ export function resolveDefaultSessionModel(sessionProvider) {
161
163
  }
162
164
  return undefined;
163
165
  }
166
+ /**
167
+ * Returns true if `provider` is defined as a custom provider in the user's
168
+ * `~/.gsd/agent/models.json` (Ollama, vLLM, LM Studio, OpenAI-compatible
169
+ * proxies, etc.).
170
+ *
171
+ * Used by auto-mode bootstrap to decide whether the session model
172
+ * (set via `/gsd model`) should override `PREFERENCES.md`. Custom providers
173
+ * are never reachable from `PREFERENCES.md` (which only knows built-in
174
+ * providers), so when the user has explicitly selected one, it must take
175
+ * priority — otherwise auto-mode tries to start the built-in provider from
176
+ * PREFERENCES.md and fails with "Not logged in · Please run /login" (#4122).
177
+ *
178
+ * Reads models.json directly with a lightweight JSON parse to avoid
179
+ * pulling in the full model-registry at this call site. Falls back to
180
+ * `~/.pi/agent/models.json` for parity with `resolveModelsJsonPath()`.
181
+ * Any read or parse error yields `false` (treat as not-custom) so a
182
+ * malformed models.json never breaks the session bootstrap.
183
+ */
184
+ export function isCustomProvider(provider) {
185
+ if (!provider)
186
+ return false;
187
+ const candidates = [
188
+ join(homedir(), ".gsd", "agent", "models.json"),
189
+ join(homedir(), ".pi", "agent", "models.json"),
190
+ ];
191
+ for (const path of candidates) {
192
+ if (!existsSync(path))
193
+ continue;
194
+ try {
195
+ const raw = readFileSync(path, "utf-8");
196
+ const parsed = JSON.parse(raw);
197
+ if (parsed?.providers && Object.prototype.hasOwnProperty.call(parsed.providers, provider)) {
198
+ return true;
199
+ }
200
+ }
201
+ catch {
202
+ // Ignore — malformed models.json must not break bootstrap.
203
+ }
204
+ }
205
+ return false;
206
+ }
164
207
  /**
165
208
  * Determines the next fallback model to try when the current model fails.
166
209
  * If the current model is not in the configured list, returns the primary model.
@@ -83,6 +83,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([
83
83
  "discuss_preparation",
84
84
  "discuss_web_research",
85
85
  "discuss_depth",
86
+ "flat_rate_providers",
86
87
  ]);
87
88
  /** Canonical list of all dispatch unit types. */
88
89
  export const KNOWN_UNIT_TYPES = [
@@ -155,6 +155,28 @@ export function validatePreferences(preferences) {
155
155
  errors.push(`search_provider must be one of: brave, tavily, ollama, native, auto`);
156
156
  }
157
157
  }
158
+ // ─── Flat-rate Providers ────────────────────────────────────────────
159
+ // User-declared flat-rate providers for dynamic routing suppression.
160
+ // Built-in providers (github-copilot, copilot, claude-code) and any
161
+ // externalCli provider are already auto-detected; this list layers on
162
+ // top for private subscription proxies and custom CLI wrappers.
163
+ if (preferences.flat_rate_providers !== undefined) {
164
+ if (Array.isArray(preferences.flat_rate_providers)) {
165
+ const allStrings = preferences.flat_rate_providers.every((item) => typeof item === "string");
166
+ if (allStrings) {
167
+ // Strip empty/whitespace-only entries to avoid false matches.
168
+ validated.flat_rate_providers = preferences.flat_rate_providers
169
+ .map((s) => s.trim())
170
+ .filter((s) => s.length > 0);
171
+ }
172
+ else {
173
+ errors.push("flat_rate_providers must be an array of strings");
174
+ }
175
+ }
176
+ else {
177
+ errors.push("flat_rate_providers must be an array of strings");
178
+ }
179
+ }
158
180
  // ─── Phase Skip Preferences ─────────────────────────────────────────
159
181
  if (preferences.phases !== undefined) {
160
182
  if (typeof preferences.phases === "object" && preferences.phases !== null) {
@@ -1 +1 @@
1
- _XD_gUDcZNBbWV5rI8RgS
1
+ r6AvNu-aMwn4nwqjHqAfw
@@ -9,39 +9,39 @@
9
9
  "/api/cleanup/route": "/api/cleanup",
10
10
  "/api/doctor/route": "/api/doctor",
11
11
  "/api/export-data/route": "/api/export-data",
12
- "/api/browse-directories/route": "/api/browse-directories",
13
12
  "/api/captures/route": "/api/captures",
14
13
  "/api/forensics/route": "/api/forensics",
15
- "/api/history/route": "/api/history",
14
+ "/api/browse-directories/route": "/api/browse-directories",
15
+ "/api/experimental/route": "/api/experimental",
16
16
  "/api/git/route": "/api/git",
17
+ "/api/history/route": "/api/history",
17
18
  "/api/hooks/route": "/api/hooks",
18
- "/api/knowledge/route": "/api/knowledge",
19
19
  "/api/inspect/route": "/api/inspect",
20
- "/api/experimental/route": "/api/experimental",
20
+ "/api/knowledge/route": "/api/knowledge",
21
21
  "/api/live-state/route": "/api/live-state",
22
22
  "/api/notifications/route": "/api/notifications",
23
23
  "/api/preferences/route": "/api/preferences",
24
24
  "/api/recovery/route": "/api/recovery",
25
25
  "/api/onboarding/route": "/api/onboarding",
26
- "/api/session/browser/route": "/api/session/browser",
27
26
  "/api/projects/route": "/api/projects",
27
+ "/api/session/browser/route": "/api/session/browser",
28
28
  "/api/session/command/route": "/api/session/command",
29
- "/api/session/manage/route": "/api/session/manage",
30
- "/api/session/events/route": "/api/session/events",
29
+ "/api/files/route": "/api/files",
31
30
  "/api/settings-data/route": "/api/settings-data",
32
31
  "/api/shutdown/route": "/api/shutdown",
32
+ "/api/session/events/route": "/api/session/events",
33
33
  "/api/skill-health/route": "/api/skill-health",
34
+ "/api/session/manage/route": "/api/session/manage",
34
35
  "/api/steer/route": "/api/steer",
35
- "/api/files/route": "/api/files",
36
36
  "/api/terminal/input/route": "/api/terminal/input",
37
- "/api/terminal/resize/route": "/api/terminal/resize",
38
37
  "/api/switch-root/route": "/api/switch-root",
38
+ "/api/terminal/resize/route": "/api/terminal/resize",
39
39
  "/api/terminal/sessions/route": "/api/terminal/sessions",
40
40
  "/api/terminal/stream/route": "/api/terminal/stream",
41
+ "/api/update/route": "/api/update",
41
42
  "/api/undo/route": "/api/undo",
42
43
  "/api/visualizer/route": "/api/visualizer",
43
44
  "/api/terminal/upload/route": "/api/terminal/upload",
44
- "/api/update/route": "/api/update",
45
45
  "/api/remote-questions/route": "/api/remote-questions",
46
46
  "/page": "/"
47
47
  }
@@ -4,8 +4,8 @@
4
4
  ],
5
5
  "devFiles": [],
6
6
  "lowPriorityFiles": [
7
- "static/_XD_gUDcZNBbWV5rI8RgS/_buildManifest.js",
8
- "static/_XD_gUDcZNBbWV5rI8RgS/_ssgManifest.js"
7
+ "static/r6AvNu-aMwn4nwqjHqAfw/_buildManifest.js",
8
+ "static/r6AvNu-aMwn4nwqjHqAfw/_ssgManifest.js"
9
9
  ],
10
10
  "rootMainFiles": [
11
11
  "static/chunks/webpack-b868033a5834586d.js",
@@ -78,8 +78,8 @@
78
78
  "dynamicRoutes": {},
79
79
  "notFoundRoutes": [],
80
80
  "preview": {
81
- "previewModeId": "4283da6741183fc0cc8d512df6295628",
82
- "previewModeSigningKey": "cdf7d7cfc64c1c34a89272bf9cd51d42b833e77ed6cca6783bb040758c5debcb",
83
- "previewModeEncryptionKey": "05a6e01cb4dec0b5ed78f3ef384323f7926e7212560fac1b76e648ccf9b43ed0"
81
+ "previewModeId": "1ad2aeb0370b98cc01860d4aa27c2cc6",
82
+ "previewModeSigningKey": "d1af9f025aa721de88357535f1bcb3b731aa041032a52fdda4caf17b0a7d3960",
83
+ "previewModeEncryptionKey": "812d78941c2b0688748da1f190f3e6071d14c1206bf1b75b1407d9e101368741"
84
84
  }
85
85
  }