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
@@ -0,0 +1,32 @@
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
+
9
+ import { appendFileSync, mkdirSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ /**
14
+ * Write a crash log to ~/.gsd/crash/<timestamp>.log (or $GSD_HOME/crash/).
15
+ * Never throws — must be safe to call from any error handler.
16
+ */
17
+ export function writeCrashLog(err: Error, source: string): void {
18
+ try {
19
+ const crashDir = join(process.env.GSD_HOME ?? join(homedir(), ".gsd"), "crash");
20
+ mkdirSync(crashDir, { recursive: true });
21
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
22
+ const logPath = join(crashDir, `${ts}.log`);
23
+ const lines = [
24
+ `[gsd] ${source}: ${err.message}`,
25
+ `timestamp: ${new Date().toISOString()}`,
26
+ `pid: ${process.pid}`,
27
+ err.stack ?? "(no stack trace available)",
28
+ "",
29
+ ];
30
+ appendFileSync(logPath, lines.join("\n"));
31
+ } catch { /* never throw from crash handler */ }
32
+ }
@@ -11,6 +11,9 @@ import { registerJournalTools } from "./journal-tools.js";
11
11
  import { registerQueryTools } from "./query-tools.js";
12
12
  import { registerHooks } from "./register-hooks.js";
13
13
  import { registerShortcuts } from "./register-shortcuts.js";
14
+ import { writeCrashLog } from "./crash-log.js";
15
+
16
+ export { writeCrashLog } from "./crash-log.js";
14
17
 
15
18
  export function handleRecoverableExtensionProcessError(err: Error): boolean {
16
19
  if ((err as NodeJS.ErrnoException).code === "EPIPE") {
@@ -33,16 +36,25 @@ export function handleRecoverableExtensionProcessError(err: Error): boolean {
33
36
  function installEpipeGuard(): void {
34
37
  if (!process.listeners("uncaughtException").some((listener) => listener.name === "_gsdEpipeGuard")) {
35
38
  const _gsdEpipeGuard = (err: Error): void => {
36
- if (handleRecoverableExtensionProcessError(err)) {
37
- return;
38
- }
39
- // Log unhandled errors instead of re-throwing throwing inside an
40
- // uncaughtException handler is a fatal double-fault in Node.js (#3163).
41
- process.stderr.write(`[gsd] uncaught extension error (non-fatal): ${err.message}\n`);
42
- if (err.stack) process.stderr.write(`${err.stack}\n`);
39
+ if (handleRecoverableExtensionProcessError(err)) return;
40
+ // Write crash log and exit cleanly for unrecoverable errors.
41
+ // Logging and continuing was the original double-fault fix (#3163), but
42
+ // continuing in an indeterminate state is worse than a clean exit (#3348).
43
+ writeCrashLog(err, "uncaughtException");
44
+ process.exit(1);
43
45
  };
44
46
  process.on("uncaughtException", _gsdEpipeGuard);
45
47
  }
48
+
49
+ if (!process.listeners("unhandledRejection").some((listener) => listener.name === "_gsdRejectionGuard")) {
50
+ const _gsdRejectionGuard = (reason: unknown, _promise: Promise<unknown>): void => {
51
+ const err = reason instanceof Error ? reason : new Error(String(reason));
52
+ if (handleRecoverableExtensionProcessError(err)) return;
53
+ writeCrashLog(err, "unhandledRejection");
54
+ process.exit(1);
55
+ };
56
+ process.on("unhandledRejection", _gsdRejectionGuard);
57
+ }
46
58
  }
47
59
 
48
60
  export function registerGsdExtension(pi: ExtensionAPI): void {
@@ -15,6 +15,7 @@ import { join } from "node:path";
15
15
  import { gsdRoot } from "./paths.js";
16
16
  import { atomicWriteSync } from "./atomic-write.js";
17
17
  import { effectiveLockFile } from "./session-lock.js";
18
+ import { emitJournalEvent, queryJournal } from "./journal.js";
18
19
 
19
20
  export interface LockData {
20
21
  pid: number;
@@ -118,3 +119,61 @@ export function formatCrashInfo(lock: LockData): string {
118
119
 
119
120
  return lines.join("\n");
120
121
  }
122
+
123
+ /**
124
+ * Emit a synthetic unit-end event for a unit that crashed without emitting its own.
125
+ *
126
+ * Queries the journal to find the most recent unit-start for the crashed unit.
127
+ * If a matching unit-end already exists (e.g. the hard timeout fired), this is a
128
+ * no-op. Called during crash recovery, before clearing the stale lock.
129
+ *
130
+ * Addresses the gap reported in #3348 where `unit-start` was emitted but no
131
+ * `unit-end` followed — side effects landed but the worker died before closeout.
132
+ */
133
+ export function emitCrashRecoveredUnitEnd(basePath: string, lock: LockData): void {
134
+ // Skip bootstrap / starting pseudo-units — they have no meaningful unit-start event.
135
+ if (!lock.unitType || !lock.unitId || lock.unitType === "starting") return;
136
+
137
+ try {
138
+ const all = queryJournal(basePath);
139
+
140
+ // Find the most recent unit-start for this unitId
141
+ const starts = all.filter(
142
+ (e) => e.eventType === "unit-start" && e.data?.unitId === lock.unitId,
143
+ );
144
+ if (starts.length === 0) return;
145
+
146
+ const lastStart = starts[starts.length - 1];
147
+
148
+ // Check if a unit-end was already emitted (e.g. hard timeout fired after the crash)
149
+ const alreadyClosed = all.some(
150
+ (e) =>
151
+ e.eventType === "unit-end" &&
152
+ e.data?.unitId === lock.unitId &&
153
+ e.causedBy?.flowId === lastStart.flowId &&
154
+ e.causedBy?.seq === lastStart.seq,
155
+ );
156
+ if (alreadyClosed) return;
157
+
158
+ // Find the highest seq in this flow for monotonic ordering
159
+ const maxSeq = all
160
+ .filter((e) => e.flowId === lastStart.flowId)
161
+ .reduce((max, e) => Math.max(max, e.seq), lastStart.seq);
162
+
163
+ emitJournalEvent(basePath, {
164
+ ts: new Date().toISOString(),
165
+ flowId: lastStart.flowId,
166
+ seq: maxSeq + 1,
167
+ eventType: "unit-end",
168
+ data: {
169
+ unitType: lock.unitType,
170
+ unitId: lock.unitId,
171
+ status: "crash-recovered",
172
+ artifactVerified: false,
173
+ },
174
+ causedBy: { flowId: lastStart.flowId, seq: lastStart.seq },
175
+ });
176
+ } catch {
177
+ // Never throw from crash recovery path — journal failure must not block recovery
178
+ }
179
+ }
@@ -1564,6 +1564,23 @@ export interface TaskRow {
1564
1564
  sequence: number;
1565
1565
  }
1566
1566
 
1567
+ function parseTaskArrayColumn(raw: unknown): string[] {
1568
+ if (typeof raw !== "string" || raw.trim() === "") return [];
1569
+
1570
+ try {
1571
+ const parsed = JSON.parse(raw);
1572
+ if (Array.isArray(parsed)) return parsed.map((value) => String(value));
1573
+ if (parsed === null || parsed === undefined || parsed === "") return [];
1574
+ return [String(parsed)];
1575
+ } catch {
1576
+ // Older/corrupt rows may contain comma-separated strings instead of JSON.
1577
+ return raw
1578
+ .split(",")
1579
+ .map((value) => value.trim())
1580
+ .filter(Boolean);
1581
+ }
1582
+ }
1583
+
1567
1584
  function rowToTask(row: Record<string, unknown>): TaskRow {
1568
1585
  const parseTaskArray = (value: unknown): string[] => {
1569
1586
  if (Array.isArray(value)) {
@@ -1603,8 +1620,8 @@ function rowToTask(row: Record<string, unknown>): TaskRow {
1603
1620
  blocker_discovered: (row["blocker_discovered"] as number) === 1,
1604
1621
  deviations: row["deviations"] as string,
1605
1622
  known_issues: row["known_issues"] as string,
1606
- key_files: JSON.parse((row["key_files"] as string) || "[]"),
1607
- key_decisions: JSON.parse((row["key_decisions"] as string) || "[]"),
1623
+ key_files: parseTaskArrayColumn(row["key_files"]),
1624
+ key_decisions: parseTaskArrayColumn(row["key_decisions"]),
1608
1625
  full_summary_md: row["full_summary_md"] as string,
1609
1626
  description: (row["description"] as string) ?? "",
1610
1627
  estimate: (row["estimate"] as string) ?? "",
@@ -2200,6 +2217,39 @@ export function deleteSlice(milestoneId: string, sliceId: string): void {
2200
2217
  });
2201
2218
  }
2202
2219
 
2220
+ export function deleteMilestone(milestoneId: string): void {
2221
+ if (!currentDb) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
2222
+ transaction(() => {
2223
+ currentDb!.prepare(
2224
+ `DELETE FROM verification_evidence WHERE milestone_id = :mid`,
2225
+ ).run({ ":mid": milestoneId });
2226
+ currentDb!.prepare(
2227
+ `DELETE FROM quality_gates WHERE milestone_id = :mid`,
2228
+ ).run({ ":mid": milestoneId });
2229
+ currentDb!.prepare(
2230
+ `DELETE FROM tasks WHERE milestone_id = :mid`,
2231
+ ).run({ ":mid": milestoneId });
2232
+ currentDb!.prepare(
2233
+ `DELETE FROM slice_dependencies WHERE milestone_id = :mid`,
2234
+ ).run({ ":mid": milestoneId });
2235
+ currentDb!.prepare(
2236
+ `DELETE FROM slices WHERE milestone_id = :mid`,
2237
+ ).run({ ":mid": milestoneId });
2238
+ currentDb!.prepare(
2239
+ `DELETE FROM replan_history WHERE milestone_id = :mid`,
2240
+ ).run({ ":mid": milestoneId });
2241
+ currentDb!.prepare(
2242
+ `DELETE FROM assessments WHERE milestone_id = :mid`,
2243
+ ).run({ ":mid": milestoneId });
2244
+ currentDb!.prepare(
2245
+ `DELETE FROM artifacts WHERE milestone_id = :mid`,
2246
+ ).run({ ":mid": milestoneId });
2247
+ currentDb!.prepare(
2248
+ `DELETE FROM milestones WHERE id = :mid`,
2249
+ ).run({ ":mid": milestoneId });
2250
+ });
2251
+ }
2252
+
2203
2253
  export function updateSliceFields(milestoneId: string, sliceId: string, fields: {
2204
2254
  title?: string;
2205
2255
  risk?: string;
@@ -20,7 +20,8 @@ import {
20
20
  } from "./paths.js";
21
21
  import { invalidateAllCaches } from "./cache.js";
22
22
  import { loadQueueOrder, saveQueueOrder } from "./queue-order.js";
23
- import { getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js";
23
+ import { deleteMilestone, getMilestone, isDbAvailable, updateMilestoneStatus } from "./gsd-db.js";
24
+ import { removeWorktree } from "./worktree-manager.js";
24
25
  import { logWarning } from "./workflow-logger.js";
25
26
 
26
27
  // ─── Park ──────────────────────────────────────────────────────────────────
@@ -110,6 +111,15 @@ export function discardMilestone(basePath: string, milestoneId: string): boolean
110
111
  const mDir = resolveMilestonePath(basePath, milestoneId);
111
112
  if (!mDir || !existsSync(mDir)) return false;
112
113
 
114
+ try {
115
+ removeWorktree(basePath, milestoneId, {
116
+ branch: `milestone/${milestoneId}`,
117
+ deleteBranch: true,
118
+ });
119
+ } catch (err) {
120
+ logWarning("engine", `discardMilestone worktree cleanup failed for ${milestoneId}: ${(err as Error).message}`);
121
+ }
122
+
113
123
  rmSync(mDir, { recursive: true, force: true });
114
124
 
115
125
  // Prune from queue order if present
@@ -118,6 +128,14 @@ export function discardMilestone(basePath: string, milestoneId: string): boolean
118
128
  saveQueueOrder(basePath, order.filter(id => id !== milestoneId));
119
129
  }
120
130
 
131
+ if (isDbAvailable()) {
132
+ try {
133
+ deleteMilestone(milestoneId);
134
+ } catch (err) {
135
+ logWarning("engine", `discardMilestone DB cleanup failed for ${milestoneId}: ${(err as Error).message}`);
136
+ }
137
+ }
138
+
121
139
  invalidateAllCaches();
122
140
  return true;
123
141
  }
@@ -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) {
@@ -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
+ });