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
@@ -3,8 +3,15 @@ import { Container, Markdown, type MarkdownTheme, Spacer, Text } from "@gsd/pi-t
3
3
  import { getMarkdownTheme, theme } from "../theme/theme.js";
4
4
  import { formatTimestamp, type TimestampFormat } from "./timestamp.js";
5
5
 
6
+ export interface ContentRange {
7
+ startIndex: number;
8
+ endIndex: number;
9
+ }
10
+
6
11
  /**
7
- * Component that renders a complete assistant message
12
+ * Component that renders a complete assistant message, or a sub-range of its content[].
13
+ * When `range` is provided, only content[startIndex..endIndex] (inclusive) is rendered.
14
+ * Non-text/thinking blocks within the range are silently skipped.
8
15
  */
9
16
  export class AssistantMessageComponent extends Container {
10
17
  private contentContainer: Container;
@@ -12,18 +19,26 @@ export class AssistantMessageComponent extends Container {
12
19
  private markdownTheme: MarkdownTheme;
13
20
  private lastMessage?: AssistantMessage;
14
21
  private timestampFormat: TimestampFormat;
22
+ private range?: ContentRange;
23
+ private showMetadata: boolean;
15
24
 
16
25
  constructor(
17
26
  message?: AssistantMessage,
18
27
  hideThinkingBlock = false,
19
28
  markdownTheme: MarkdownTheme = getMarkdownTheme(),
20
29
  timestampFormat: TimestampFormat = "date-time-iso",
30
+ range?: ContentRange,
21
31
  ) {
22
32
  super();
23
33
 
24
34
  this.hideThinkingBlock = hideThinkingBlock;
25
35
  this.markdownTheme = markdownTheme;
26
36
  this.timestampFormat = timestampFormat;
37
+ this.range = range;
38
+ // No range = legacy full-message rendering; show metadata by default.
39
+ // Ranged (interleaved) instances start with metadata hidden; chat-controller
40
+ // calls setShowMetadata(true) on the last segment at message_end.
41
+ this.showMetadata = !range;
27
42
 
28
43
  // Container for text/thinking content
29
44
  this.contentContainer = new Container();
@@ -34,6 +49,20 @@ export class AssistantMessageComponent extends Container {
34
49
  }
35
50
  }
36
51
 
52
+ setRange(range: ContentRange | undefined): void {
53
+ this.range = range;
54
+ if (this.lastMessage) {
55
+ this.updateContent(this.lastMessage);
56
+ }
57
+ }
58
+
59
+ setShowMetadata(show: boolean): void {
60
+ this.showMetadata = show;
61
+ if (this.lastMessage) {
62
+ this.updateContent(this.lastMessage);
63
+ }
64
+ }
65
+
37
66
  override invalidate(): void {
38
67
  super.invalidate();
39
68
  if (this.lastMessage) {
@@ -51,7 +80,11 @@ export class AssistantMessageComponent extends Container {
51
80
  // Clear content container
52
81
  this.contentContainer.clear();
53
82
 
54
- const hasVisibleContent = message.content.some(
83
+ const start = this.range?.startIndex ?? 0;
84
+ const end = this.range?.endIndex ?? message.content.length - 1;
85
+ const slice = message.content.slice(start, end + 1);
86
+
87
+ const hasVisibleContent = slice.some(
55
88
  (c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
56
89
  );
57
90
 
@@ -59,9 +92,9 @@ export class AssistantMessageComponent extends Container {
59
92
  this.contentContainer.addChild(new Spacer(1));
60
93
  }
61
94
 
62
- // Render content in order
63
- for (let i = 0; i < message.content.length; i++) {
64
- const content = message.content[i];
95
+ // Render content in order; non-text/thinking blocks are silently skipped
96
+ for (let i = 0; i < slice.length; i++) {
97
+ const content = slice[i];
65
98
  if (content.type === "text" && content.text.trim()) {
66
99
  // Assistant text messages with no background - trim the text
67
100
  // Set paddingY=0 to avoid extra spacing before tool executions
@@ -69,7 +102,7 @@ export class AssistantMessageComponent extends Container {
69
102
  } else if (content.type === "thinking" && content.thinking.trim()) {
70
103
  // Add spacing only when another visible assistant content block follows.
71
104
  // This avoids a superfluous blank line before separately-rendered tool execution blocks.
72
- const hasVisibleContentAfter = message.content
105
+ const hasVisibleContentAfter = slice
73
106
  .slice(i + 1)
74
107
  .some((c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()));
75
108
 
@@ -94,30 +127,33 @@ export class AssistantMessageComponent extends Container {
94
127
  }
95
128
  }
96
129
 
97
- // Check if aborted - show after partial content
98
- // But only if there are no tool calls (tool execution components will show the error)
99
- const hasToolCalls = message.content.some((c) => c.type === "toolCall");
100
- if (!hasToolCalls) {
101
- if (message.stopReason === "aborted") {
102
- const abortMessage =
103
- message.errorMessage && message.errorMessage !== "Request was aborted"
104
- ? message.errorMessage
105
- : "Operation aborted";
106
- if (hasVisibleContent) {
130
+ // Metadata (errors, timestamp): gated on showMetadata so ranged instances stay clean
131
+ // until chat-controller explicitly enables it on the last segment at message_end.
132
+ if (this.showMetadata) {
133
+ // Check if aborted - show after partial content
134
+ // But only if there are no tool calls (tool execution components will show the error)
135
+ const hasToolCalls = message.content.some((c) => c.type === "toolCall");
136
+ if (!hasToolCalls) {
137
+ if (message.stopReason === "aborted") {
138
+ const abortMessage =
139
+ message.errorMessage && message.errorMessage !== "Request was aborted"
140
+ ? message.errorMessage
141
+ : "Operation aborted";
142
+ if (hasVisibleContent) {
143
+ this.contentContainer.addChild(new Spacer(1));
144
+ }
145
+ this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0));
146
+ } else if (message.stopReason === "error") {
147
+ const errorMsg = message.errorMessage || "Unknown error";
107
148
  this.contentContainer.addChild(new Spacer(1));
149
+ this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
108
150
  }
109
- this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0));
110
- } else if (message.stopReason === "error") {
111
- const errorMsg = message.errorMessage || "Unknown error";
112
- this.contentContainer.addChild(new Spacer(1));
113
- this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
114
151
  }
115
- }
116
152
 
117
- // Show timestamp when the message is complete (has a stop reason)
118
- if (message.stopReason && message.timestamp) {
119
- const timeStr = formatTimestamp(message.timestamp, this.timestampFormat);
120
- this.contentContainer.addChild(new Text(theme.fg("dim", timeStr), 1, 0));
153
+ if (message.stopReason && message.timestamp) {
154
+ const timeStr = formatTimestamp(message.timestamp, this.timestampFormat);
155
+ this.contentContainer.addChild(new Text(theme.fg("dim", timeStr), 1, 0));
156
+ }
121
157
  }
122
158
  }
123
159
  }
@@ -10,6 +10,13 @@ import { appKey } from "../components/keybinding-hints.js";
10
10
  // Tracks the last processed content index to avoid re-scanning all blocks on every message_update
11
11
  let lastProcessedContentIndex = 0;
12
12
 
13
+ // --- Segment walker state (per streaming assistant turn) ---
14
+ type RenderedSegment =
15
+ | { kind: "text-run"; startIndex: number; endIndex: number; component: AssistantMessageComponent }
16
+ | { kind: "tool"; contentIndex: number; component: ToolExecutionComponent };
17
+
18
+ let renderedSegments: RenderedSegment[] = [];
19
+
13
20
  function hasVisibleAssistantContent(message: { content: Array<any> }): boolean {
14
21
  return message.content.some(
15
22
  (c) =>
@@ -80,6 +87,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
80
87
  lastProcessedContentIndex = 0;
81
88
  lastPinnedText = "";
82
89
  hasToolsInTurn = false;
90
+ renderedSegments = [];
83
91
  if (pinnedBorder) pinnedBorder.stopSpinner();
84
92
  pinnedBorder = undefined;
85
93
  pinnedTextComponent = undefined;
@@ -99,6 +107,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
99
107
  host.pinnedMessageContainer.clear();
100
108
  lastPinnedText = "";
101
109
  hasToolsInTurn = false;
110
+ renderedSegments = [];
102
111
  if (pinnedBorder) pinnedBorder.stopSpinner();
103
112
  pinnedBorder = undefined;
104
113
  pinnedTextComponent = undefined;
@@ -273,24 +282,88 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
273
282
  }
274
283
  }
275
284
 
276
- // Render assistant text/thinking after tool components so mixed
277
- // streams keep chronological ordering in the chat container.
278
- const hasToolBlocks = hasAssistantToolBlocks(host.streamingMessage);
279
- if (!host.streamingComponent && hasVisibleAssistantContent(host.streamingMessage)) {
280
- host.streamingComponent = new AssistantMessageComponent(
281
- undefined,
282
- host.hideThinkingBlock,
283
- host.getMarkdownThemeWithSettings(),
284
- host.settingsManager.getTimestampFormat(),
285
- );
286
- host.chatContainer.addChild(host.streamingComponent);
287
- }
288
- if (host.streamingComponent) {
289
- if (hasToolBlocks) {
290
- host.chatContainer.removeChild(host.streamingComponent);
291
- host.chatContainer.addChild(host.streamingComponent);
285
+ // Segment walker: render content blocks in stream order, append-only.
286
+ // Build desired segment plan from content[].
287
+ {
288
+ const blocks = host.streamingMessage.content;
289
+ type DesiredSegment =
290
+ | { kind: "text-run"; startIndex: number; endIndex: number }
291
+ | { kind: "tool"; contentIndex: number; toolId: string };
292
+ const desired: DesiredSegment[] = [];
293
+ let runStart = -1;
294
+ for (let i = 0; i < blocks.length; i++) {
295
+ const b = blocks[i];
296
+ const isText = b.type === "text" || b.type === "thinking";
297
+ const isTool = b.type === "toolCall" || b.type === "serverToolUse";
298
+ if (isText) {
299
+ if (runStart === -1) runStart = i;
300
+ } else {
301
+ if (runStart !== -1) {
302
+ desired.push({ kind: "text-run", startIndex: runStart, endIndex: i - 1 });
303
+ runStart = -1;
304
+ }
305
+ if (isTool) {
306
+ desired.push({ kind: "tool", contentIndex: i, toolId: b.id });
307
+ }
308
+ }
309
+ }
310
+ if (runStart !== -1) {
311
+ desired.push({ kind: "text-run", startIndex: runStart, endIndex: blocks.length - 1 });
312
+ }
313
+
314
+ // Append any newly needed segments (never reorder existing ones).
315
+ for (const seg of desired) {
316
+ if (seg.kind === "tool") {
317
+ // Tool segments are already handled above via pendingTools; just
318
+ // register them in renderedSegments if not yet tracked.
319
+ const existing = renderedSegments.find(
320
+ (s) => s.kind === "tool" && s.contentIndex === seg.contentIndex,
321
+ );
322
+ if (!existing) {
323
+ const comp = host.pendingTools.get(seg.toolId);
324
+ if (comp) {
325
+ renderedSegments.push({ kind: "tool", contentIndex: seg.contentIndex, component: comp });
326
+ }
327
+ }
328
+ } else {
329
+ // text-run segment
330
+ const existing = renderedSegments.find(
331
+ (s) => s.kind === "text-run" && s.startIndex === seg.startIndex,
332
+ );
333
+ if (!existing) {
334
+ const comp = new AssistantMessageComponent(
335
+ undefined,
336
+ host.hideThinkingBlock,
337
+ host.getMarkdownThemeWithSettings(),
338
+ host.settingsManager.getTimestampFormat(),
339
+ { startIndex: seg.startIndex, endIndex: seg.endIndex },
340
+ );
341
+ host.chatContainer.addChild(comp);
342
+ renderedSegments.push({ kind: "text-run", startIndex: seg.startIndex, endIndex: seg.endIndex, component: comp });
343
+ host.streamingComponent = comp;
344
+ }
345
+ }
346
+ }
347
+
348
+ // Update all trailing text-run segments with the latest message so
349
+ // streaming text grows in place.
350
+ for (const seg of renderedSegments) {
351
+ if (seg.kind === "text-run") {
352
+ // Find corresponding desired segment to get current endIndex
353
+ const d = desired.find((ds) => ds.kind === "text-run" && ds.startIndex === seg.startIndex);
354
+ if (d && d.kind === "text-run" && d.endIndex !== seg.endIndex) {
355
+ seg.endIndex = d.endIndex;
356
+ seg.component.setRange({ startIndex: seg.startIndex, endIndex: seg.endIndex });
357
+ }
358
+ seg.component.updateContent(host.streamingMessage);
359
+ }
360
+ }
361
+
362
+ // Keep streamingComponent pointing at the last text-run for message_end compatibility.
363
+ const lastTextSeg = [...renderedSegments].reverse().find((s) => s.kind === "text-run");
364
+ if (lastTextSeg && lastTextSeg.kind === "text-run") {
365
+ host.streamingComponent = lastTextSeg.component;
292
366
  }
293
- host.streamingComponent.updateContent(host.streamingMessage);
294
367
  }
295
368
 
296
369
  // Update index: fully processed blocks won't need re-scanning.
@@ -376,6 +449,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
376
449
  host.chatContainer.addChild(host.streamingComponent);
377
450
  }
378
451
  if (host.streamingComponent) {
452
+ host.streamingComponent.setShowMetadata(true);
379
453
  host.streamingComponent.updateContent(host.streamingMessage);
380
454
  }
381
455
 
@@ -399,6 +473,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
399
473
  }
400
474
  host.streamingComponent = undefined;
401
475
  host.streamingMessage = undefined;
476
+ renderedSegments = [];
402
477
  // Clear pinned output once the message is finalized in the chat
403
478
  // container — prevents duplicate display when the agent continues
404
479
  // (e.g. form elicitation) after the assistant message ends.
package/pkg/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittercowboy/gsd",
3
- "version": "2.73.0",
3
+ "version": "2.73.1",
4
4
  "piConfig": {
5
5
  "name": "gsd",
6
6
  "configDir": ".gsd"
@@ -14,10 +14,11 @@ import type {
14
14
  Context,
15
15
  Model,
16
16
  SimpleStreamOptions,
17
+ ThinkingLevel,
17
18
  ToolCall,
18
19
  } from "@gsd/pi-ai";
19
20
  import type { ExtensionUIContext } from "@gsd/pi-coding-agent";
20
- import { EventStream } from "@gsd/pi-ai";
21
+ import { EventStream, mapThinkingLevelToEffort, supportsAdaptiveThinking } from "@gsd/pi-ai";
21
22
  import { execSync } from "node:child_process";
22
23
  import { PartialMessageBuilder, ZERO_USAGE, mapUsage } from "./partial-builder.js";
23
24
  import { buildWorkflowMcpServers } from "../gsd/workflow-mcp.js";
@@ -600,8 +601,9 @@ export function buildSdkOptions(
600
601
  modelId: string,
601
602
  prompt: string,
602
603
  overrides?: { permissionMode?: "bypassPermissions" | "acceptEdits" | "default" | "plan" },
603
- extraOptions: Record<string, unknown> = {},
604
+ extraOptions: Record<string, unknown> & { reasoning?: ThinkingLevel } = {},
604
605
  ): Record<string, unknown> {
606
+ const { reasoning, ...sdkExtraOptions } = extraOptions;
605
607
  const mcpServers = buildWorkflowMcpServers();
606
608
  const permissionMode = overrides?.permissionMode ?? "bypassPermissions";
607
609
  const disallowedTools = ["AskUserQuestion"];
@@ -620,6 +622,10 @@ export function buildSdkOptions(
620
622
  "Bash(pwd)",
621
623
  ...(mcpServers ? Object.keys(mcpServers).map((serverName) => `mcp__${serverName}__*`) : []),
622
624
  ];
625
+ const effort =
626
+ reasoning && supportsAdaptiveThinking(modelId)
627
+ ? mapThinkingLevelToEffort(reasoning, modelId)
628
+ : undefined;
623
629
  return {
624
630
  pathToClaudeCodeExecutable: getClaudePath(),
625
631
  model: modelId,
@@ -634,7 +640,8 @@ export function buildSdkOptions(
634
640
  ...(allowedTools.length > 0 ? { allowedTools } : {}),
635
641
  ...(mcpServers ? { mcpServers } : {}),
636
642
  betas: modelId.includes("sonnet") ? ["context-1m-2025-08-07"] : [],
637
- ...extraOptions,
643
+ ...(effort ? { effort } : {}),
644
+ ...sdkExtraOptions,
638
645
  };
639
646
  }
640
647
 
@@ -828,11 +835,12 @@ async function pumpSdkMessages(
828
835
  { permissionMode },
829
836
  typeof (options as ClaudeCodeStreamOptions | undefined)?.extensionUIContext === "object"
830
837
  ? {
838
+ reasoning: options?.reasoning,
831
839
  onElicitation: createClaudeCodeElicitationHandler(
832
840
  (options as ClaudeCodeStreamOptions | undefined)?.extensionUIContext,
833
841
  ),
834
842
  }
835
- : {},
843
+ : { reasoning: options?.reasoning },
836
844
  );
837
845
 
838
846
  const queryResult = sdk.query({
@@ -343,6 +343,26 @@ describe("stream-adapter — session persistence (#2859)", () => {
343
343
  );
344
344
  });
345
345
 
346
+ test("buildSdkOptions maps reasoning to effort for adaptive Claude Code models (#3917)", () => {
347
+ const options = buildSdkOptions("claude-sonnet-4-6", "test", undefined, { reasoning: "high" });
348
+ assert.equal(options.effort, "high");
349
+ });
350
+
351
+ test("buildSdkOptions upgrades xhigh reasoning to max for opus 4.6 (#3917)", () => {
352
+ const options = buildSdkOptions("claude-opus-4-6", "test", undefined, { reasoning: "xhigh" });
353
+ assert.equal(options.effort, "max");
354
+ });
355
+
356
+ test("buildSdkOptions omits effort when reasoning is undefined (#3917)", () => {
357
+ const options = buildSdkOptions("claude-sonnet-4-6", "test");
358
+ assert.equal("effort" in options, false);
359
+ });
360
+
361
+ test("buildSdkOptions omits effort for non-adaptive Claude models (#3917)", () => {
362
+ const options = buildSdkOptions("claude-sonnet-4-20250514", "test", undefined, { reasoning: "high" });
363
+ assert.equal("effort" in options, false);
364
+ });
365
+
346
366
  test("buildSdkOptions includes workflow MCP server config when env is set", () => {
347
367
  const prev = {
348
368
  GSD_WORKFLOW_MCP_COMMAND: process.env.GSD_WORKFLOW_MCP_COMMAND,
@@ -774,11 +794,12 @@ describe("stream-adapter — MCP elicitation bridge", () => {
774
794
  },
775
795
  };
776
796
 
797
+ const secureValue = "ui-collected-value";
777
798
  const inputCalls: Array<{ opts?: { secure?: boolean } }> = [];
778
799
  const handler = createClaudeCodeElicitationHandler({
779
800
  input: async (_title: string, _placeholder?: string, opts?: { secure?: boolean }) => {
780
801
  inputCalls.push({ opts });
781
- return "example-secure-input";
802
+ return secureValue;
782
803
  },
783
804
  } as any);
784
805
  assert.ok(handler);
@@ -787,7 +808,7 @@ describe("stream-adapter — MCP elicitation bridge", () => {
787
808
  assert.deepEqual(result, {
788
809
  action: "accept",
789
810
  content: {
790
- TEST_SECURE_FIELD: "example-secure-input",
811
+ TEST_SECURE_FIELD: secureValue,
791
812
  },
792
813
  });
793
814
  assert.equal(inputCalls.length, 1);
@@ -15,6 +15,7 @@ import { resolveModelForComplexity, escalateTier, getEligibleModels, loadCapabil
15
15
  import { getLedger, getProjectTotals } from "./metrics.js";
16
16
  import { unitPhaseLabel } from "./auto-dashboard.js";
17
17
  import { getSessionModelOverride } from "./session-model-override.js";
18
+ import { logWarning } from "./workflow-logger.js";
18
19
 
19
20
  export interface ModelSelectionResult {
20
21
  /** Routing metadata for metrics recording */
@@ -25,9 +26,7 @@ export interface ModelSelectionResult {
25
26
 
26
27
  export function resolvePreferredModelConfig(
27
28
  unitType: string,
28
- autoModeStartModel: { provider: string; id: string } | null,
29
- /** When false, only return explicit per-phase model configs — do not
30
- * synthesize a routing ceiling from dynamic_routing.tier_models (#3962). */
29
+ autoModeStartModel: { provider: string; id: string; flatRateCtx?: FlatRateContext } | null,
31
30
  isAutoMode = true,
32
31
  ) {
33
32
  const explicitConfig = resolveModelWithFallbacksForUnit(unitType);
@@ -41,7 +40,7 @@ export function resolvePreferredModelConfig(
41
40
  if (!routingConfig.enabled || !routingConfig.tier_models) return undefined;
42
41
 
43
42
  // Don't synthesize a routing config for flat-rate providers (#3453).
44
- if (autoModeStartModel && isFlatRateProvider(autoModeStartModel.provider)) return undefined;
43
+ if (autoModeStartModel && isFlatRateProvider(autoModeStartModel.provider, autoModeStartModel.flatRateCtx)) return undefined;
45
44
 
46
45
  const ceilingModel = routingConfig.tier_models.heavy
47
46
  ?? (autoModeStartModel ? `${autoModeStartModel.provider}/${autoModeStartModel.id}` : undefined);
@@ -68,7 +67,7 @@ export async function selectAndApplyModel(
68
67
  basePath: string,
69
68
  prefs: GSDPreferences | undefined,
70
69
  verbose: boolean,
71
- autoModeStartModel: { provider: string; id: string } | null,
70
+ autoModeStartModel: { provider: string; id: string; flatRateCtx?: FlatRateContext } | null,
72
71
  retryContext?: { isRetry: boolean; previousTier?: string },
73
72
  /** When false (interactive/guided-flow), skip dynamic routing and use the session model.
74
73
  * Dynamic routing only applies in auto-mode where cost optimization is expected. (#3962) */
@@ -79,6 +78,17 @@ export async function selectAndApplyModel(
79
78
  const effectiveSessionModelOverride = sessionModelOverride === undefined
80
79
  ? getSessionModelOverride(ctx.sessionManager.getSessionId())
81
80
  : (sessionModelOverride ?? undefined);
81
+ // Enrich the start model with a flat-rate context up front so routing
82
+ // synthesis and the dispatch-time guard see the same signals (built-in
83
+ // list + user `flat_rate_providers` preference + externalCli auto-
84
+ // detection). The dispatch-time primary-model check below builds its
85
+ // own per-provider context when it has a resolved primary model.
86
+ if (autoModeStartModel) {
87
+ autoModeStartModel = {
88
+ ...autoModeStartModel,
89
+ flatRateCtx: buildFlatRateContext(autoModeStartModel.provider, ctx, prefs),
90
+ };
91
+ }
82
92
  const modelConfig = effectiveSessionModelOverride
83
93
  ? undefined
84
94
  : resolvePreferredModelConfig(unitType, autoModeStartModel, isAutoMode);
@@ -107,12 +117,16 @@ export async function selectAndApplyModel(
107
117
  if (routingConfig.enabled) {
108
118
  const primaryModel = resolveModelId(modelConfig.primary, availableModels, ctx.model?.provider);
109
119
  if (primaryModel) {
110
- if (isFlatRateProvider(primaryModel.provider)) {
120
+ const primaryFlatRateCtx = buildFlatRateContext(primaryModel.provider, ctx, prefs);
121
+ if (isFlatRateProvider(primaryModel.provider, primaryFlatRateCtx)) {
111
122
  routingConfig.enabled = false;
112
123
  }
113
124
  } else if (
114
- (autoModeStartModel && isFlatRateProvider(autoModeStartModel.provider))
115
- || (ctx.model?.provider && isFlatRateProvider(ctx.model.provider))
125
+ (autoModeStartModel && isFlatRateProvider(autoModeStartModel.provider, autoModeStartModel.flatRateCtx))
126
+ || (ctx.model?.provider && isFlatRateProvider(
127
+ ctx.model.provider,
128
+ buildFlatRateContext(ctx.model.provider, ctx, prefs),
129
+ ))
116
130
  ) {
117
131
  // Primary model unresolvable but provider signals indicate flat-rate —
118
132
  // disable routing to prevent quality degradation.
@@ -416,8 +430,68 @@ export function resolveModelId<T extends { id: string; provider: string }>(
416
430
  * Uses case-insensitive matching with alias support to prevent fail-open on
417
431
  * provider naming variations (e.g. "copilot" vs "github-copilot").
418
432
  */
419
- const FLAT_RATE_PROVIDERS = new Set(["github-copilot", "copilot", "claude-code"]);
433
+ const BUILTIN_FLAT_RATE = new Set(["github-copilot", "copilot", "claude-code"]);
434
+
435
+ /**
436
+ * Optional context that lets callers extend flat-rate detection beyond the
437
+ * hard-coded built-in list. Either signal on its own is enough to classify
438
+ * a provider as flat-rate.
439
+ */
440
+ export interface FlatRateContext {
441
+ /**
442
+ * Auth mode for the specific provider being checked, as returned by
443
+ * `ctx.modelRegistry.getProviderAuthMode(provider)`. Any provider that
444
+ * wraps a local CLI (externalCli) is, by definition, a flat-rate
445
+ * subscription wrapper — every request costs the same regardless of
446
+ * model, so dynamic routing only degrades quality.
447
+ */
448
+ authMode?: "apiKey" | "oauth" | "externalCli" | "none";
449
+ /**
450
+ * Case-insensitive list of extra provider IDs the user has declared as
451
+ * flat-rate via `preferences.flat_rate_providers`. Used for private
452
+ * subscription-backed proxies and enterprise-gated deployments that the
453
+ * built-in list doesn't know about.
454
+ */
455
+ userFlatRate?: readonly string[];
456
+ }
457
+
458
+ export function isFlatRateProvider(provider: string, opts?: FlatRateContext): boolean {
459
+ const p = provider.toLowerCase();
460
+ if (BUILTIN_FLAT_RATE.has(p)) return true;
461
+ if (opts?.userFlatRate?.some(id => id.toLowerCase() === p)) return true;
462
+ if (opts?.authMode === "externalCli") return true;
463
+ return false;
464
+ }
420
465
 
421
- export function isFlatRateProvider(provider: string): boolean {
422
- return FLAT_RATE_PROVIDERS.has(provider.toLowerCase());
466
+ /**
467
+ * Build a FlatRateContext for a given provider from live runtime state.
468
+ * Safe to call when ctx or prefs are undefined — missing pieces are
469
+ * treated as "no signal".
470
+ */
471
+ export function buildFlatRateContext(
472
+ provider: string,
473
+ ctx?: { modelRegistry?: { getProviderAuthMode?: (p: string) => string } },
474
+ prefs?: { flat_rate_providers?: readonly string[] },
475
+ ): FlatRateContext {
476
+ let authMode: FlatRateContext["authMode"];
477
+ const getAuthMode = ctx?.modelRegistry?.getProviderAuthMode;
478
+ if (typeof getAuthMode === "function") {
479
+ try {
480
+ const mode = getAuthMode(provider);
481
+ if (mode === "apiKey" || mode === "oauth" || mode === "externalCli" || mode === "none") {
482
+ authMode = mode;
483
+ }
484
+ } catch (err) {
485
+ // Registry lookup failure must never break flat-rate detection —
486
+ // fall through with authMode undefined and surface the cause.
487
+ logWarning(
488
+ "dispatch",
489
+ `flat-rate auth-mode lookup failed for ${provider}: ${err instanceof Error ? err.message : String(err)}`,
490
+ );
491
+ }
492
+ }
493
+ return {
494
+ authMode,
495
+ userFlatRate: prefs?.flat_rate_providers,
496
+ };
423
497
  }
@@ -83,7 +83,11 @@ import { join } from "node:path";
83
83
  import { sep as pathSep } from "node:path";
84
84
 
85
85
  import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js";
86
- import { resolveDefaultSessionModel, resolveDynamicRoutingConfig } from "./preferences-models.js";
86
+ import {
87
+ isCustomProvider,
88
+ resolveDefaultSessionModel,
89
+ resolveDynamicRoutingConfig,
90
+ } from "./preferences-models.js";
87
91
  import type { WorktreeResolver } from "./worktree-resolver.js";
88
92
  import { getSessionModelOverride } from "./session-model-override.js";
89
93
 
@@ -274,8 +278,18 @@ export async function bootstrapAutoSession(
274
278
  //
275
279
  // This preserves #3517 defaults while honoring explicit runtime model
276
280
  // selection for subsequent /gsd runs in the same session.
281
+ //
282
+ // Exception (#4122): when the session provider is a custom provider declared
283
+ // in ~/.gsd/agent/models.json (Ollama, vLLM, OpenAI-compatible proxy, etc.),
284
+ // PREFERENCES.md is skipped entirely. PREFERENCES.md cannot reference custom
285
+ // providers, so honoring it would silently reroute auto-mode to a built-in
286
+ // provider the user is not logged into and surface as "Not logged in · Please
287
+ // run /login" before pausing and resetting to claude-code/claude-sonnet-4-6.
277
288
  const manualSessionOverride = getSessionModelOverride(ctx.sessionManager.getSessionId());
278
- const preferredModel = resolveDefaultSessionModel(ctx.model?.provider);
289
+ const sessionProviderIsCustom = isCustomProvider(ctx.model?.provider);
290
+ const preferredModel = sessionProviderIsCustom
291
+ ? null
292
+ : resolveDefaultSessionModel(ctx.model?.provider);
279
293
  // Validate the preferred model against the live registry + provider auth so
280
294
  // an unconfigured PREFERENCES.md entry (no API key / OAuth) can't become the
281
295
  // start-model snapshot. Without this, every subsequent unit would try to
@@ -811,12 +825,19 @@ export async function bootstrapAutoSession(
811
825
  ? `${s.autoModeStartModel.provider}/${s.autoModeStartModel.id}`
812
826
  : ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "default";
813
827
 
814
- // Flat-rate providers (e.g. GitHub Copilot, claude-code) suppress routing
815
- // at dispatch time (#3453) reflect that in the banner.
816
- const { isFlatRateProvider } = await import("./auto-model-selection.js");
828
+ // Flat-rate providers (e.g. GitHub Copilot, claude-code, user-declared
829
+ // subscription proxies, externalCli CLIs) suppress routing at dispatch
830
+ // time (#3453) reflect that in the banner. Thread the same
831
+ // FlatRateContext used by selectAndApplyModel so user-declared
832
+ // flat-rate providers and externalCli auto-detection are respected.
833
+ const { isFlatRateProvider, buildFlatRateContext } = await import("./auto-model-selection.js");
834
+ const bannerPrefs = loadEffectiveGSDPreferences()?.preferences;
817
835
  const effectiveProvider = s.autoModeStartModel?.provider ?? ctx.model?.provider;
818
836
  const effectivelyEnabled = routingConfig.enabled
819
- && !(effectiveProvider && isFlatRateProvider(effectiveProvider));
837
+ && !(effectiveProvider && isFlatRateProvider(
838
+ effectiveProvider,
839
+ buildFlatRateContext(effectiveProvider, ctx, bannerPrefs),
840
+ ));
820
841
 
821
842
  // The actual ceiling may come from tier_models.heavy, not the start model.
822
843
  const effectiveCeiling = (routingConfig.enabled && routingConfig.tier_models?.heavy)
@@ -52,6 +52,7 @@ import {
52
52
  readCrashLock,
53
53
  isLockProcessAlive,
54
54
  formatCrashInfo,
55
+ emitCrashRecoveredUnitEnd,
55
56
  } from "./crash-recovery.js";
56
57
  import {
57
58
  acquireSessionLock,
@@ -1332,6 +1333,10 @@ export async function startAuto(
1332
1333
  }
1333
1334
 
1334
1335
  if (freshStartAssessment.lock) {
1336
+ // Emit a synthetic unit-end for any unit-start that has no closing event.
1337
+ // This closes the journal gap reported in #3348 where the worker wrote side
1338
+ // effects (SUMMARY.md, DB updates) but died before emitting unit-end.
1339
+ emitCrashRecoveredUnitEnd(base, freshStartAssessment.lock);
1335
1340
  clearLock(base);
1336
1341
  }
1337
1342