gsd-pi 2.78.1-dev.84a383f51 → 2.78.1-dev.8a893322c

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 (147) hide show
  1. package/README.md +1 -0
  2. package/dist/bundled-resource-path.d.ts +7 -0
  3. package/dist/bundled-resource-path.js +34 -2
  4. package/dist/claude-cli-check.js +18 -6
  5. package/dist/headless-query.js +21 -6
  6. package/dist/loader.js +2 -3
  7. package/dist/resource-loader.js +2 -8
  8. package/dist/resources/.managed-resources-content-hash +1 -1
  9. package/dist/resources/extensions/claude-code-cli/readiness.js +19 -7
  10. package/dist/resources/extensions/google-search/index.js +2 -6
  11. package/dist/resources/extensions/gsd/auto/phases.js +3 -11
  12. package/dist/resources/extensions/gsd/auto/session.js +2 -6
  13. package/dist/resources/extensions/gsd/auto-dashboard.js +3 -2
  14. package/dist/resources/extensions/gsd/auto-dispatch.js +18 -6
  15. package/dist/resources/extensions/gsd/auto-prompts.js +63 -2
  16. package/dist/resources/extensions/gsd/auto-worktree.js +30 -13
  17. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +19 -1
  18. package/dist/resources/extensions/gsd/bootstrap/subagent-input.js +22 -0
  19. package/dist/resources/extensions/gsd/bootstrap/system-context.js +11 -0
  20. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +84 -2
  21. package/dist/resources/extensions/gsd/commands/catalog.js +8 -1
  22. package/dist/resources/extensions/gsd/commands/handlers/core.js +1 -0
  23. package/dist/resources/extensions/gsd/commands/handlers/ops.js +8 -0
  24. package/dist/resources/extensions/gsd/commands-config.js +3 -2
  25. package/dist/resources/extensions/gsd/commands-extensions.js +46 -3
  26. package/dist/resources/extensions/gsd/commands-handlers.js +3 -2
  27. package/dist/resources/extensions/gsd/commands-worktree.js +309 -0
  28. package/dist/resources/extensions/gsd/docs/preferences-reference.md +6 -0
  29. package/dist/resources/extensions/gsd/doctor-providers.js +2 -1
  30. package/dist/resources/extensions/gsd/forensics.js +8 -6
  31. package/dist/resources/extensions/gsd/guided-flow.js +2 -1
  32. package/dist/resources/extensions/gsd/home-dir.js +16 -0
  33. package/dist/resources/extensions/gsd/key-manager.js +2 -1
  34. package/dist/resources/extensions/gsd/migrate/command.js +3 -2
  35. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +10 -0
  36. package/dist/resources/extensions/gsd/prompts/complete-slice.md +10 -0
  37. package/dist/resources/extensions/gsd/prompts/plan-slice.md +10 -0
  38. package/dist/resources/extensions/gsd/prompts/refine-slice.md +10 -0
  39. package/dist/resources/extensions/gsd/unit-context-manifest.js +29 -4
  40. package/dist/resources/extensions/gsd/worktree-manager.js +20 -1
  41. package/dist/resources/extensions/gsd/worktree-resolver.js +4 -13
  42. package/dist/resources/extensions/gsd/worktree-root.js +124 -0
  43. package/dist/resources/extensions/gsd/worktree.js +4 -115
  44. package/dist/resources/extensions/mcp-client/index.js +0 -6
  45. package/dist/resources/extensions/ollama/index.js +15 -2
  46. package/dist/resources/extensions/ollama/model-capabilities.js +31 -0
  47. package/dist/resources/extensions/ollama/ollama-client.js +40 -4
  48. package/dist/resources/extensions/subagent/index.js +324 -178
  49. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  50. package/dist/web/standalone/.next/BUILD_ID +1 -1
  51. package/dist/web/standalone/.next/app-path-routes-manifest.json +17 -17
  52. package/dist/web/standalone/.next/build-manifest.json +2 -2
  53. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  54. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  55. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  56. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  57. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  58. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  59. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  60. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  61. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  62. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  63. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  64. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  65. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  66. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  67. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  68. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  69. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  70. package/dist/web/standalone/.next/server/app/index.html +1 -1
  71. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  72. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  73. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  74. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  75. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  76. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  77. package/dist/web/standalone/.next/server/app-paths-manifest.json +17 -17
  78. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  79. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  80. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  81. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  82. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  83. package/dist/welcome-screen.js +27 -1
  84. package/dist/worktree-cli.d.ts +1 -0
  85. package/dist/worktree-cli.js +9 -3
  86. package/package.json +1 -3
  87. package/packages/mcp-server/src/workflow-tools.test.ts +52 -0
  88. package/packages/native/tsconfig.tsbuildinfo +1 -1
  89. package/src/resources/extensions/claude-code-cli/readiness.ts +20 -7
  90. package/src/resources/extensions/google-search/index.ts +2 -9
  91. package/src/resources/extensions/gsd/auto/phases.ts +3 -11
  92. package/src/resources/extensions/gsd/auto/session.ts +2 -6
  93. package/src/resources/extensions/gsd/auto-dashboard.ts +3 -2
  94. package/src/resources/extensions/gsd/auto-dispatch.ts +18 -6
  95. package/src/resources/extensions/gsd/auto-prompts.ts +60 -2
  96. package/src/resources/extensions/gsd/auto-worktree.ts +44 -12
  97. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +19 -0
  98. package/src/resources/extensions/gsd/bootstrap/subagent-input.ts +20 -0
  99. package/src/resources/extensions/gsd/bootstrap/system-context.ts +11 -0
  100. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +103 -1
  101. package/src/resources/extensions/gsd/commands/catalog.ts +8 -1
  102. package/src/resources/extensions/gsd/commands/handlers/core.ts +1 -0
  103. package/src/resources/extensions/gsd/commands/handlers/ops.ts +10 -0
  104. package/src/resources/extensions/gsd/commands-config.ts +3 -2
  105. package/src/resources/extensions/gsd/commands-extensions.ts +43 -3
  106. package/src/resources/extensions/gsd/commands-handlers.ts +3 -2
  107. package/src/resources/extensions/gsd/commands-worktree.ts +383 -0
  108. package/src/resources/extensions/gsd/docs/preferences-reference.md +6 -0
  109. package/src/resources/extensions/gsd/doctor-providers.ts +2 -1
  110. package/src/resources/extensions/gsd/forensics.ts +10 -5
  111. package/src/resources/extensions/gsd/guided-flow.ts +2 -1
  112. package/src/resources/extensions/gsd/home-dir.ts +19 -0
  113. package/src/resources/extensions/gsd/journal.ts +4 -1
  114. package/src/resources/extensions/gsd/key-manager.ts +2 -1
  115. package/src/resources/extensions/gsd/migrate/command.ts +3 -2
  116. package/src/resources/extensions/gsd/prompts/complete-milestone.md +10 -0
  117. package/src/resources/extensions/gsd/prompts/complete-slice.md +10 -0
  118. package/src/resources/extensions/gsd/prompts/plan-slice.md +10 -0
  119. package/src/resources/extensions/gsd/prompts/refine-slice.md +10 -0
  120. package/src/resources/extensions/gsd/tests/auto-session-encapsulation.test.ts +15 -0
  121. package/src/resources/extensions/gsd/tests/bundled-skill-triggers.test.ts +50 -27
  122. package/src/resources/extensions/gsd/tests/commands-extensions-version-compare.test.ts +58 -0
  123. package/src/resources/extensions/gsd/tests/commands-worktree-clean.test.ts +48 -0
  124. package/src/resources/extensions/gsd/tests/google-search-stub.test.ts +25 -65
  125. package/src/resources/extensions/gsd/tests/home-dir.test.ts +52 -0
  126. package/src/resources/extensions/gsd/tests/integration/auto-worktree.test.ts +50 -1
  127. package/src/resources/extensions/gsd/tests/milestone-report-path.test.ts +18 -1
  128. package/src/resources/extensions/gsd/tests/safety-harness-false-positives.test.ts +34 -0
  129. package/src/resources/extensions/gsd/tests/steer-worktree-path.test.ts +17 -1
  130. package/src/resources/extensions/gsd/tests/unit-context-manifest.test.ts +38 -3
  131. package/src/resources/extensions/gsd/tests/worktree-symlink-removal.test.ts +34 -33
  132. package/src/resources/extensions/gsd/tests/worktree.test.ts +8 -0
  133. package/src/resources/extensions/gsd/tests/write-gate-planning-unit.test.ts +116 -1
  134. package/src/resources/extensions/gsd/unit-context-manifest.ts +36 -4
  135. package/src/resources/extensions/gsd/worktree-manager.ts +40 -1
  136. package/src/resources/extensions/gsd/worktree-resolver.ts +4 -14
  137. package/src/resources/extensions/gsd/worktree-root.ts +144 -0
  138. package/src/resources/extensions/gsd/worktree.ts +8 -119
  139. package/src/resources/extensions/mcp-client/index.ts +0 -7
  140. package/src/resources/extensions/ollama/index.ts +16 -2
  141. package/src/resources/extensions/ollama/model-capabilities.ts +34 -0
  142. package/src/resources/extensions/ollama/ollama-client.ts +41 -4
  143. package/src/resources/extensions/ollama/tests/model-capabilities.test.ts +96 -0
  144. package/src/resources/extensions/ollama/tests/ollama-client-timeout-env.test.ts +147 -0
  145. package/src/resources/extensions/subagent/index.ts +165 -7
  146. /package/dist/web/standalone/.next/static/{UF5VF4F1tB0miEtJS7LyX → QK8fABiGPmonfTgboN0Y9}/_buildManifest.js +0 -0
  147. /package/dist/web/standalone/.next/static/{UF5VF4F1tB0miEtJS7LyX → QK8fABiGPmonfTgboN0Y9}/_ssgManifest.js +0 -0
@@ -12,12 +12,16 @@
12
12
  * SLICE_BRANCH_RE) remain for backwards compatibility with legacy branches.
13
13
  */
14
14
 
15
- import { existsSync, readFileSync, realpathSync, utimesSync } from "node:fs";
16
- import { join, resolve, sep } from "node:path";
17
- import { homedir } from "node:os";
15
+ import { existsSync, readFileSync, utimesSync } from "node:fs";
16
+ import { join, resolve } from "node:path";
18
17
 
19
18
  import { GitServiceImpl, writeIntegrationBranch, type TaskCommitContext } from "./git-service.js";
20
19
  import { loadEffectiveGSDPreferences } from "./preferences.js";
20
+ import {
21
+ findWorktreeSegment,
22
+ resolveWorktreeProjectRoot,
23
+ } from "./worktree-root.js";
24
+ export { resolveWorktreeProjectRoot } from "./worktree-root.js";
21
25
 
22
26
  export { MergeConflictError } from "./git-service.js";
23
27
  export type { TaskCommitContext } from "./git-service.js";
@@ -78,29 +82,6 @@ export function captureIntegrationBranch(basePath: string, milestoneId: string):
78
82
 
79
83
  // ─── Pure Utility Functions (unchanged) ────────────────────────────────────
80
84
 
81
- /**
82
- * Find the worktrees segment in a path, supporting both direct
83
- * (`/.gsd/worktrees/`) and symlink-resolved (`/.gsd/projects/<hash>/worktrees/`)
84
- * layouts. When `.gsd` is a symlink to `~/.gsd/projects/<hash>`, resolved
85
- * paths contain the intermediate `projects/<hash>/` segment that the old
86
- * single-marker check missed.
87
- */
88
- function findWorktreeSegment(normalizedPath: string): { gsdIdx: number; afterWorktrees: number } | null {
89
- // Direct layout: /.gsd/worktrees/<name>
90
- const directMarker = "/.gsd/worktrees/";
91
- const idx = normalizedPath.indexOf(directMarker);
92
- if (idx !== -1) {
93
- return { gsdIdx: idx, afterWorktrees: idx + directMarker.length };
94
- }
95
- // Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/<name>
96
- const symlinkRe = /\/\.gsd\/projects\/[a-f0-9]+\/worktrees\//;
97
- const match = normalizedPath.match(symlinkRe);
98
- if (match && match.index !== undefined) {
99
- return { gsdIdx: match.index, afterWorktrees: match.index + match[0].length };
100
- }
101
- return null;
102
- }
103
-
104
85
  /**
105
86
  * Detect the active worktree name from the current working directory.
106
87
  * Returns null if not inside a GSD worktree (.gsd/worktrees/<name>/).
@@ -133,99 +114,7 @@ export function detectWorktreeName(basePath: string): string | null {
133
114
  * operate against the real project root, not a worktree subdirectory.
134
115
  */
135
116
  export function resolveProjectRoot(basePath: string): string {
136
- // Layer 1: If the coordinator passed the real project root, use it.
137
- if (process.env.GSD_PROJECT_ROOT) {
138
- return process.env.GSD_PROJECT_ROOT;
139
- }
140
-
141
- const normalizedPath = basePath.replaceAll("\\", "/");
142
- const seg = findWorktreeSegment(normalizedPath);
143
- if (!seg) return basePath;
144
-
145
- // Candidate root via the string-slice heuristic
146
- const sepChar = basePath.includes("\\") ? "\\" : "/";
147
- const gsdMarker = `${sepChar}.gsd${sepChar}`;
148
- const gsdIdx = basePath.indexOf(gsdMarker);
149
- const candidate = gsdIdx !== -1
150
- ? basePath.slice(0, gsdIdx)
151
- : basePath.slice(0, seg.gsdIdx);
152
-
153
- // Layer 2: Guard against resolving to the user's home directory.
154
- // When .gsd is a symlink into ~/.gsd/projects/<hash>, the resolved path
155
- // contains /.gsd/ at the user-level boundary. Slicing there yields ~ — wrong.
156
- const gsdHome = normalizePathForCompare(process.env.GSD_HOME || join(homedir(), ".gsd"));
157
- const candidateGsdPath = normalizePathForCompare(join(candidate, ".gsd"));
158
-
159
- if (candidateGsdPath === gsdHome || candidateGsdPath.startsWith(gsdHome + "/")) {
160
- // The candidate is the home directory (or within it in a way that .gsd
161
- // maps to the user-level GSD dir). Try to recover the real project root
162
- // from the worktree's .git file.
163
- const realRoot = resolveProjectRootFromGitFile(basePath);
164
- if (realRoot) return realRoot;
165
- // If git file resolution failed, return basePath unchanged rather than ~
166
- return basePath;
167
- }
168
-
169
- return candidate;
170
- }
171
-
172
- /**
173
- * Recover the real project root from a worktree's .git file.
174
- *
175
- * Each git worktree has a `.git` file (not directory) containing:
176
- * gitdir: /real/project/.git/worktrees/<name>
177
- *
178
- * Walking up from that gitdir gives us `/real/project/.git`, and its
179
- * parent is the real project root.
180
- */
181
- function resolveProjectRootFromGitFile(worktreePath: string): string | null {
182
- try {
183
- // Walk up from the worktree path to find the .git file
184
- let dir = worktreePath;
185
- for (let i = 0; i < 30; i++) {
186
- const gitPath = join(dir, ".git");
187
- if (existsSync(gitPath)) {
188
- const content = readFileSync(gitPath, "utf8").trim();
189
- if (content.startsWith("gitdir: ")) {
190
- // gitdir points to: <real-project>/.git/worktrees/<name>
191
- const gitDir = resolve(dir, content.slice(8));
192
- // Walk up: .git/worktrees/<name> → .git/worktrees → .git → project root
193
- const dotGitDir = resolve(gitDir, "..", "..");
194
- // Verify this looks like a .git directory
195
- if (dotGitDir.endsWith(".git") || dotGitDir.endsWith(".git/") || dotGitDir.endsWith(".git\\")) {
196
- return resolve(dotGitDir, "..");
197
- }
198
- // Alternative: the commondir file inside the worktree gitdir
199
- // points to the main .git directory
200
- const commonDirPath = join(gitDir, "commondir");
201
- if (existsSync(commonDirPath)) {
202
- const commonDir = readFileSync(commonDirPath, "utf8").trim();
203
- const resolvedCommonDir = resolve(gitDir, commonDir);
204
- return resolve(resolvedCommonDir, "..");
205
- }
206
- }
207
- break;
208
- }
209
- const parent = resolve(dir, "..");
210
- if (parent === dir) break;
211
- dir = parent;
212
- }
213
- } catch {
214
- // Non-fatal — caller will use fallback
215
- }
216
- return null;
217
- }
218
-
219
- function normalizePathForCompare(path: string): string {
220
- let normalized: string;
221
- try {
222
- normalized = realpathSync(path);
223
- } catch {
224
- normalized = resolve(path);
225
- }
226
- const slashed = normalized.replaceAll("\\", "/");
227
- const trimmed = slashed.replace(/\/+$/, "");
228
- return trimmed || "/";
117
+ return resolveWorktreeProjectRoot(basePath);
229
118
  }
230
119
 
231
120
  /**
@@ -627,13 +627,6 @@ export default function (pi: ExtensionAPI) {
627
627
 
628
628
  // ── Lifecycle ─────────────────────────────────────────────────────────────
629
629
 
630
- pi.on("session_start", async (_event, ctx) => {
631
- const servers = readConfigs();
632
- if (servers.length > 0) {
633
- ctx.ui.notify(`MCP client ready — ${servers.length} server(s) configured`, "info");
634
- }
635
- });
636
-
637
630
  pi.on("session_shutdown", async () => {
638
631
  await closeAll();
639
632
  });
@@ -119,17 +119,31 @@ export default function ollama(pi: ExtensionAPI) {
119
119
  // In headless/auto mode, await the probe so the fallback resolver can
120
120
  // see Ollama before the first LLM call (#3531 race condition).
121
121
  // In interactive mode, keep it async for fast startup.
122
+ // Surface probe failures under GSD_DEBUG so users can diagnose silent
123
+ // "Ollama is missing from /model" reports without patching dist/. The
124
+ // probe still soft-fails (registration is best-effort) — we just stop
125
+ // dropping the error on the floor. See #4982.
126
+ const debugOllama = (where: string, error: unknown): void => {
127
+ if (process.env.GSD_DEBUG) {
128
+ const msg = error instanceof Error ? error.message : String(error);
129
+ process.stderr.write(`[ollama] ${where} probe failed: ${msg}\n`);
130
+ }
131
+ };
132
+
122
133
  if (!ctx.hasUI) {
123
134
  try {
124
135
  await probeAndRegister(pi);
125
- } catch { /* non-fatal */ }
136
+ } catch (error) {
137
+ debugOllama("headless", error);
138
+ }
126
139
  } else {
127
140
  probeAndRegister(pi)
128
141
  .then((found) => {
129
142
  ctx.ui.setStatus("ollama", found ? "Ollama" : undefined);
130
143
  })
131
- .catch(() => {
144
+ .catch((error) => {
132
145
  ctx.ui.setStatus("ollama", undefined);
146
+ debugOllama("interactive", error);
133
147
  });
134
148
  }
135
149
  });
@@ -56,10 +56,44 @@ const KNOWN_MODELS: Array<[pattern: string, caps: ModelCapability]> = [
56
56
  ["llama2", { contextWindow: 4096, maxTokens: 4096, ollamaOptions: { num_ctx: 4096 } }],
57
57
 
58
58
  // ─── Qwen family ────────────────────────────────────────────────────
59
+ // Long-variant entries MUST appear before the bare `qwen3` base —
60
+ // `baseName.startsWith(pattern)` returns true for `qwen3.5`/`qwen3-coder`/
61
+ // `qwen3-next` against `qwen3`, and the first match wins (#4991).
62
+ // ref: qwen3-next 1M ctx — https://qwen.ai/blog?id=qwen3-next
63
+ ["qwen3-next", { contextWindow: 1048576, maxTokens: 32768, ollamaOptions: { num_ctx: 1048576 } }],
64
+ // ref: qwen3-coder 256K ctx — https://qwenlm.github.io/blog/qwen3-coder/
65
+ ["qwen3-coder", { contextWindow: 262144, maxTokens: 32768, ollamaOptions: { num_ctx: 262144 } }],
66
+ // ref: qwen3.5 / qwen3.6 1M ctx — Ollama Cloud release notes
67
+ ["qwen3.6", { contextWindow: 1048576, maxTokens: 32768, ollamaOptions: { num_ctx: 1048576 } }],
68
+ ["qwen3.5", { contextWindow: 1048576, maxTokens: 32768, ollamaOptions: { num_ctx: 1048576 } }],
59
69
  ["qwen3", { contextWindow: 131072, maxTokens: 32768, ollamaOptions: { num_ctx: 131072 } }],
60
70
  ["qwen2.5", { contextWindow: 131072, maxTokens: 32768, ollamaOptions: { num_ctx: 131072 } }],
61
71
  ["qwen2", { contextWindow: 131072, maxTokens: 32768, ollamaOptions: { num_ctx: 131072 } }],
62
72
 
73
+ // ─── GLM family (Z.ai, Ollama Cloud) ────────────────────────────────
74
+ // ref: glm 4.6 / 5.x 200K ctx — https://docs.z.ai/devpack/using5.1
75
+ // Long-variant entries before bare `glm-5` / `glm-4` would-be bases to
76
+ // avoid prefix shadowing (#4991).
77
+ ["glm-5.1", { contextWindow: 204800, maxTokens: 16384, ollamaOptions: { num_ctx: 204800 } }],
78
+ ["glm-5", { contextWindow: 204800, maxTokens: 16384, ollamaOptions: { num_ctx: 204800 } }],
79
+ ["glm-4.6", { contextWindow: 204800, maxTokens: 16384, ollamaOptions: { num_ctx: 204800 } }],
80
+ ["glm-4", { contextWindow: 131072, maxTokens: 16384, ollamaOptions: { num_ctx: 131072 } }],
81
+
82
+ // ─── Kimi K2 (Moonshot, Ollama Cloud) ──────────────────────────────
83
+ // ref: kimi-k2 256K ctx — https://platform.moonshot.ai/docs
84
+ // Same shadowing concern: kimi-k2-thinking and kimi-k2.{5,6} must
85
+ // match before any future bare `kimi-k2` entry (#4991).
86
+ ["kimi-k2-thinking", { contextWindow: 262144, maxTokens: 16384, ollamaOptions: { num_ctx: 262144 } }],
87
+ ["kimi-k2.6", { contextWindow: 262144, maxTokens: 16384, ollamaOptions: { num_ctx: 262144 } }],
88
+ ["kimi-k2.5", { contextWindow: 262144, maxTokens: 16384, ollamaOptions: { num_ctx: 262144 } }],
89
+ ["kimi-k2", { contextWindow: 262144, maxTokens: 16384, ollamaOptions: { num_ctx: 262144 } }],
90
+
91
+ // ─── MiniMax M2 (Ollama Cloud) ─────────────────────────────────────
92
+ // ref: minimax-m2 1M ctx — https://www.minimax.io/news/minimax-m2
93
+ ["minimax-m2.7", { contextWindow: 1048576, maxTokens: 16384, ollamaOptions: { num_ctx: 1048576 } }],
94
+ ["minimax-m2.5", { contextWindow: 1048576, maxTokens: 16384, ollamaOptions: { num_ctx: 1048576 } }],
95
+ ["minimax-m2", { contextWindow: 1048576, maxTokens: 16384, ollamaOptions: { num_ctx: 1048576 } }],
96
+
63
97
  // ─── Gemma family ───────────────────────────────────────────────────
64
98
  ["gemma3", { contextWindow: 131072, maxTokens: 16384, ollamaOptions: { num_ctx: 131072 } }],
65
99
  ["gemma2", { contextWindow: 8192, maxTokens: 8192, ollamaOptions: { num_ctx: 8192 } }],
@@ -19,8 +19,45 @@ import type {
19
19
  import { parseNDJsonStream } from "./ndjson-stream.js";
20
20
 
21
21
  const DEFAULT_HOST = "http://localhost:11434";
22
- const PROBE_TIMEOUT_MS = 1500;
23
- const REQUEST_TIMEOUT_MS = 10000;
22
+ const DEFAULT_PROBE_TIMEOUT_MS = 1500;
23
+ const DEFAULT_REQUEST_TIMEOUT_MS = 10000;
24
+ export const MAX_TIMER_DELAY_MS = 2_147_483_647;
25
+
26
+ /**
27
+ * Parse a positive integer from an environment variable, falling back to
28
+ * `fallback` when the var is unset, empty, non-numeric, zero, or negative.
29
+ *
30
+ * Defensive parsing: a typo like `OLLAMA_PROBE_TIMEOUT_MS=abc` or
31
+ * `OLLAMA_PROBE_TIMEOUT_MS=0` should not silently disable the timeout —
32
+ * fall back to the documented default instead.
33
+ */
34
+ export function envPositiveInt(name: string, fallback: number): number {
35
+ const raw = process.env[name];
36
+ if (!raw) return fallback;
37
+ const parsed = Number.parseInt(raw, 10);
38
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
39
+ return Math.min(parsed, MAX_TIMER_DELAY_MS);
40
+ }
41
+
42
+ /**
43
+ * Effective probe timeout for the startup `isRunning()` health check.
44
+ * Override with `OLLAMA_PROBE_TIMEOUT_MS=<ms>` for slower networks (LAN
45
+ * Ollama hosts, cloud endpoints, contended cold starts).
46
+ *
47
+ * Resolved at call time — tests and downstream callers can mutate
48
+ * `process.env` between invocations and pick up the new value.
49
+ */
50
+ export function getProbeTimeoutMs(): number {
51
+ return envPositiveInt("OLLAMA_PROBE_TIMEOUT_MS", DEFAULT_PROBE_TIMEOUT_MS);
52
+ }
53
+
54
+ /**
55
+ * Effective per-request timeout for REST calls. Override with
56
+ * `OLLAMA_REQUEST_TIMEOUT_MS=<ms>`.
57
+ */
58
+ export function getRequestTimeoutMs(): number {
59
+ return envPositiveInt("OLLAMA_REQUEST_TIMEOUT_MS", DEFAULT_REQUEST_TIMEOUT_MS);
60
+ }
24
61
 
25
62
  /**
26
63
  * Get the Ollama host URL from OLLAMA_HOST or default.
@@ -57,7 +94,7 @@ function withAuth(options: RequestInit = {}): RequestInit {
57
94
  };
58
95
  }
59
96
 
60
- async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = REQUEST_TIMEOUT_MS): Promise<Response> {
97
+ async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs = getRequestTimeoutMs()): Promise<Response> {
61
98
  const controller = new AbortController();
62
99
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
63
100
  try {
@@ -77,7 +114,7 @@ export async function isRunning(): Promise<boolean> {
77
114
  const host = getOllamaHost();
78
115
  const isCloud = host.includes("ollama.com") || host.includes("cloud");
79
116
  const probeUrl = isCloud ? `${host}/api/tags` : `${host}/`;
80
- const timeout = isCloud ? REQUEST_TIMEOUT_MS : PROBE_TIMEOUT_MS;
117
+ const timeout = isCloud ? getRequestTimeoutMs() : getProbeTimeoutMs();
81
118
  const response = await fetchWithTimeout(probeUrl, isCloud ? { method: "GET" } : {}, timeout);
82
119
  return response.ok;
83
120
  } catch {
@@ -84,6 +84,102 @@ describe("getModelCapabilities", () => {
84
84
  });
85
85
  });
86
86
 
87
+ // ─── Ordering / prefix-shadowing regression (#4991) ──────────────────────────
88
+ //
89
+ // The lookup is a linear scan over KNOWN_MODELS using `baseName.startsWith(pattern)`.
90
+ // Cloud and long-variant model names share prefixes with their base families,
91
+ // so the longer entries MUST appear earlier in the table — otherwise a base
92
+ // like `qwen3` shadows `qwen3-coder`/`qwen3-next`/`qwen3.5` and the picker
93
+ // reports the wrong context window. These tests pin the ordering.
94
+
95
+ describe("getModelCapabilities — long-variant overrides aren't shadowed (#4991)", () => {
96
+ it("qwen3-coder reports 256K, not the qwen3 131K", () => {
97
+ const caps = getModelCapabilities("qwen3-coder:480b");
98
+ assert.equal(caps.contextWindow, 262144);
99
+ assert.equal(caps.ollamaOptions?.num_ctx, 262144);
100
+ });
101
+
102
+ it("qwen3-coder-next still resolves via the qwen3-coder entry", () => {
103
+ const caps = getModelCapabilities("qwen3-coder-next");
104
+ assert.equal(caps.contextWindow, 262144);
105
+ });
106
+
107
+ it("qwen3-next:80b reports 1M, not the qwen3 131K", () => {
108
+ const caps = getModelCapabilities("qwen3-next:80b");
109
+ assert.equal(caps.contextWindow, 1048576);
110
+ });
111
+
112
+ it("qwen3.5 / qwen3.6 cloud variants report 1M", () => {
113
+ assert.equal(getModelCapabilities("qwen3.5:397b").contextWindow, 1048576);
114
+ assert.equal(getModelCapabilities("qwen3.6:cloud").contextWindow, 1048576);
115
+ });
116
+
117
+ it("base qwen3 still resolves to its 131K entry", () => {
118
+ const caps = getModelCapabilities("qwen3:8b");
119
+ assert.equal(caps.contextWindow, 131072);
120
+ });
121
+
122
+ it("glm-5.1:cloud reports 200K", () => {
123
+ const caps = getModelCapabilities("glm-5.1:cloud");
124
+ assert.equal(caps.contextWindow, 204800);
125
+ });
126
+
127
+ it("glm-4.6:cloud reports 200K", () => {
128
+ const caps = getModelCapabilities("glm-4.6:cloud");
129
+ assert.equal(caps.contextWindow, 204800);
130
+ });
131
+
132
+ it("glm-4 base still resolves to its 131K entry", () => {
133
+ const caps = getModelCapabilities("glm-4:9b");
134
+ assert.equal(caps.contextWindow, 131072);
135
+ });
136
+
137
+ it("kimi-k2-thinking reports 256K (not shadowed by kimi-k2)", () => {
138
+ const caps = getModelCapabilities("kimi-k2-thinking");
139
+ assert.equal(caps.contextWindow, 262144);
140
+ });
141
+
142
+ it("kimi-k2.5:cloud and kimi-k2.6:cloud both report 256K", () => {
143
+ assert.equal(getModelCapabilities("kimi-k2.5:cloud").contextWindow, 262144);
144
+ assert.equal(getModelCapabilities("kimi-k2.6:cloud").contextWindow, 262144);
145
+ });
146
+
147
+ it("kimi-k2 base resolves to 256K", () => {
148
+ const caps = getModelCapabilities("kimi-k2:cloud");
149
+ assert.equal(caps.contextWindow, 262144);
150
+ });
151
+
152
+ it("minimax-m2.5:cloud and minimax-m2.7:cloud report 1M", () => {
153
+ assert.equal(getModelCapabilities("minimax-m2.5:cloud").contextWindow, 1048576);
154
+ assert.equal(getModelCapabilities("minimax-m2.7:cloud").contextWindow, 1048576);
155
+ });
156
+
157
+ it("minimax-m2 base resolves to 1M", () => {
158
+ const caps = getModelCapabilities("minimax-m2:cloud");
159
+ assert.equal(caps.contextWindow, 1048576);
160
+ });
161
+
162
+ it("ollamaOptions.num_ctx mirrors contextWindow for all new entries", () => {
163
+ // Inference time: num_ctx is what gets sent to Ollama on each chat.
164
+ // If contextWindow is right but num_ctx is stale, the model still
165
+ // gets truncated. Pin both sides.
166
+ for (const name of [
167
+ "qwen3-next:80b",
168
+ "qwen3-coder:480b",
169
+ "glm-5.1:cloud",
170
+ "kimi-k2-thinking",
171
+ "minimax-m2.7:cloud",
172
+ ]) {
173
+ const caps = getModelCapabilities(name);
174
+ assert.equal(
175
+ caps.ollamaOptions?.num_ctx,
176
+ caps.contextWindow,
177
+ `${name}: num_ctx (${caps.ollamaOptions?.num_ctx}) must equal contextWindow (${caps.contextWindow})`,
178
+ );
179
+ }
180
+ });
181
+ });
182
+
87
183
  // ─── estimateContextFromParams ───────────────────────────────────────────────
88
184
 
89
185
  describe("estimateContextFromParams", () => {
@@ -0,0 +1,147 @@
1
+ // GSD2 — Tests for OLLAMA_PROBE_TIMEOUT_MS / OLLAMA_REQUEST_TIMEOUT_MS env vars (#5003 / #4982)
2
+ //
3
+ // Pinned defaults: 1500 ms probe, 10 000 ms request. The defaults must be
4
+ // preserved when the env var is unset, empty, non-numeric, zero, or negative
5
+ // so a typo or fat-fingered value can't silently disable the timeout. When
6
+ // the env var is set to a valid positive integer it overrides the default.
7
+
8
+ import { describe, it, beforeEach, afterEach } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import {
11
+ envPositiveInt,
12
+ getProbeTimeoutMs,
13
+ getRequestTimeoutMs,
14
+ MAX_TIMER_DELAY_MS,
15
+ } from "../ollama-client.js";
16
+
17
+ const PROBE_VAR = "OLLAMA_PROBE_TIMEOUT_MS";
18
+ const REQUEST_VAR = "OLLAMA_REQUEST_TIMEOUT_MS";
19
+
20
+ function withEnv(name: string, value: string | undefined, run: () => void): void {
21
+ const prior = process.env[name];
22
+ if (value === undefined) {
23
+ delete process.env[name];
24
+ } else {
25
+ process.env[name] = value;
26
+ }
27
+ try {
28
+ run();
29
+ } finally {
30
+ if (prior === undefined) {
31
+ delete process.env[name];
32
+ } else {
33
+ process.env[name] = prior;
34
+ }
35
+ }
36
+ }
37
+
38
+ describe("envPositiveInt — defensive fallback", () => {
39
+ it("returns fallback when var is unset", () => {
40
+ withEnv("__GSD_TEST_INT__", undefined, () => {
41
+ assert.equal(envPositiveInt("__GSD_TEST_INT__", 42), 42);
42
+ });
43
+ });
44
+
45
+ it("returns fallback when var is empty string", () => {
46
+ withEnv("__GSD_TEST_INT__", "", () => {
47
+ assert.equal(envPositiveInt("__GSD_TEST_INT__", 42), 42);
48
+ });
49
+ });
50
+
51
+ it("returns fallback when var is non-numeric", () => {
52
+ withEnv("__GSD_TEST_INT__", "abc", () => {
53
+ assert.equal(envPositiveInt("__GSD_TEST_INT__", 42), 42);
54
+ });
55
+ });
56
+
57
+ it("returns fallback when var is zero (would silently disable timeout)", () => {
58
+ withEnv("__GSD_TEST_INT__", "0", () => {
59
+ assert.equal(envPositiveInt("__GSD_TEST_INT__", 42), 42);
60
+ });
61
+ });
62
+
63
+ it("returns fallback when var is negative", () => {
64
+ withEnv("__GSD_TEST_INT__", "-100", () => {
65
+ assert.equal(envPositiveInt("__GSD_TEST_INT__", 42), 42);
66
+ });
67
+ });
68
+
69
+ it("returns parsed value when var is a positive integer", () => {
70
+ withEnv("__GSD_TEST_INT__", "5000", () => {
71
+ assert.equal(envPositiveInt("__GSD_TEST_INT__", 42), 5000);
72
+ });
73
+ });
74
+
75
+ it("parses leading digits and discards trailing junk (parseInt semantics)", () => {
76
+ withEnv("__GSD_TEST_INT__", "1500ms", () => {
77
+ assert.equal(envPositiveInt("__GSD_TEST_INT__", 42), 1500);
78
+ });
79
+ });
80
+
81
+ it("clamps values above MAX_TIMER_DELAY_MS to prevent setTimeout overflow", () => {
82
+ withEnv("__GSD_TEST_INT__", String(MAX_TIMER_DELAY_MS + 1), () => {
83
+ assert.equal(envPositiveInt("__GSD_TEST_INT__", 42), MAX_TIMER_DELAY_MS);
84
+ });
85
+ });
86
+
87
+ it("accepts MAX_TIMER_DELAY_MS exactly", () => {
88
+ withEnv("__GSD_TEST_INT__", String(MAX_TIMER_DELAY_MS), () => {
89
+ assert.equal(envPositiveInt("__GSD_TEST_INT__", 42), MAX_TIMER_DELAY_MS);
90
+ });
91
+ });
92
+ });
93
+
94
+ describe("getProbeTimeoutMs — OLLAMA_PROBE_TIMEOUT_MS override", () => {
95
+ beforeEach(() => {
96
+ delete process.env[PROBE_VAR];
97
+ });
98
+ afterEach(() => {
99
+ delete process.env[PROBE_VAR];
100
+ });
101
+
102
+ it("defaults to 1500 ms when unset", () => {
103
+ assert.equal(getProbeTimeoutMs(), 1500);
104
+ });
105
+
106
+ it("honours a positive override", () => {
107
+ process.env[PROBE_VAR] = "5000";
108
+ assert.equal(getProbeTimeoutMs(), 5000);
109
+ });
110
+
111
+ it("falls back to 1500 ms on a zero override (typo guard)", () => {
112
+ process.env[PROBE_VAR] = "0";
113
+ assert.equal(getProbeTimeoutMs(), 1500);
114
+ });
115
+
116
+ it("re-reads the env var on every call", () => {
117
+ process.env[PROBE_VAR] = "2000";
118
+ assert.equal(getProbeTimeoutMs(), 2000);
119
+ process.env[PROBE_VAR] = "8000";
120
+ assert.equal(getProbeTimeoutMs(), 8000);
121
+ delete process.env[PROBE_VAR];
122
+ assert.equal(getProbeTimeoutMs(), 1500);
123
+ });
124
+ });
125
+
126
+ describe("getRequestTimeoutMs — OLLAMA_REQUEST_TIMEOUT_MS override", () => {
127
+ beforeEach(() => {
128
+ delete process.env[REQUEST_VAR];
129
+ });
130
+ afterEach(() => {
131
+ delete process.env[REQUEST_VAR];
132
+ });
133
+
134
+ it("defaults to 10 000 ms when unset", () => {
135
+ assert.equal(getRequestTimeoutMs(), 10000);
136
+ });
137
+
138
+ it("honours a positive override", () => {
139
+ process.env[REQUEST_VAR] = "30000";
140
+ assert.equal(getRequestTimeoutMs(), 30000);
141
+ });
142
+
143
+ it("falls back to 10 000 ms on non-numeric input", () => {
144
+ process.env[REQUEST_VAR] = "thirty-seconds";
145
+ assert.equal(getRequestTimeoutMs(), 10000);
146
+ });
147
+ });