bingocode 1.1.200-beta.2 → 1.1.200-beta.21

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.
@@ -100,6 +100,7 @@ export function ResumeConversation({
100
100
  agentName?: string;
101
101
  agentColor?: AgentColorName;
102
102
  mainThreadAgentDefinition?: AgentDefinition;
103
+ brainMode?: boolean;
103
104
  } | null>(null);
104
105
  const [crossProjectCommand, setCrossProjectCommand] = React.useState<string | null>(null);
105
106
  const sessionLogResultRef = React.useRef<SessionLogResult | null>(null);
@@ -250,6 +251,10 @@ export function ResumeConversation({
250
251
  standaloneAgentContext
251
252
  }));
252
253
  }
254
+ setAppState(prev_2 => ({
255
+ ...prev_2,
256
+ brainMode: result_3.brainMode ?? false
257
+ }));
253
258
  void updateSessionName(result_3.agentName);
254
259
  restoreSessionMetadata(forkSession ? {
255
260
  ...result_3,
@@ -279,7 +284,8 @@ export function ResumeConversation({
279
284
  contentReplacements: result_3.contentReplacements,
280
285
  agentName: result_3.agentName,
281
286
  agentColor: (result_3.agentColor === 'default' ? undefined : result_3.agentColor) as AgentColorName | undefined,
282
- mainThreadAgentDefinition: resolvedAgentDef
287
+ mainThreadAgentDefinition: resolvedAgentDef,
288
+ brainMode: result_3.brainMode ?? false
283
289
  });
284
290
  } catch (e) {
285
291
  logEvent('tengu_session_resumed', {
@@ -25,6 +25,7 @@ import {
25
25
  import type { SlotName } from '../types/provider.js'
26
26
  import { ApiError, errorResponse } from '../middleware/errorHandler.js'
27
27
 
28
+
28
29
  const providerService = new ProviderService()
29
30
  providerService.init().catch((err) =>
30
31
  console.error('[ProviderService] init failed:', err),
@@ -42,6 +43,22 @@ function sanitizeProvider(provider: Record<string, unknown>): Record<string, unk
42
43
  return provider
43
44
  }
44
45
 
46
+ function isLocalhostRequest(req: Request): boolean {
47
+ const origin = req.headers.get('Origin')
48
+ const host = req.headers.get('Host')
49
+ const isLocalOrigin =
50
+ !!origin &&
51
+ (origin.startsWith('http://localhost') ||
52
+ origin.startsWith('http://127.0.0.1') ||
53
+ origin.startsWith('http://[::1]'))
54
+ const isLocalHost =
55
+ !!host &&
56
+ (host.startsWith('localhost') ||
57
+ host.startsWith('127.0.0.1') ||
58
+ host.startsWith('[::1]'))
59
+ return isLocalOrigin || isLocalHost
60
+ }
61
+
45
62
  export async function handleProvidersApi(
46
63
  req: Request,
47
64
  _url: URL,
@@ -81,6 +98,13 @@ export async function handleProvidersApi(
81
98
 
82
99
  // /api/providers/link-vscode — manage VS Code linking
83
100
  if (id === 'link-vscode' && !action) {
101
+ // Defense-in-depth: deny requests from non-localhost origins unconditionally
102
+ if (!isLocalhostRequest(req)) {
103
+ return Response.json(
104
+ { error: 'Forbidden', message: 'link-vscode only accessible from localhost' },
105
+ { status: 403 },
106
+ )
107
+ }
84
108
  if (req.method === 'GET') {
85
109
  const status = await providerService.getVscodeStatus()
86
110
  return Response.json(status)
@@ -118,6 +118,27 @@ presets:
118
118
  secret: true
119
119
  placeholder: 'sk-...'
120
120
 
121
+ - id: nvidia
122
+ name: NVIDIA API
123
+ baseUrl: https://integrate.api.nvidia.com/v1
124
+ apiFormat: openai_chat
125
+ needsApiKey: true
126
+ websiteUrl: https://build.nvidia.com/explore/discover
127
+ modelsUrl: /models
128
+ modelsAuthStyle: bearer
129
+ modelsDataPath: data
130
+ fields:
131
+ - key: name
132
+ label: Provider Nickname
133
+ required: true
134
+ secret: false
135
+ placeholder: 'e.g. My NVIDIA'
136
+ - key: apiKey
137
+ label: API Key
138
+ required: true
139
+ secret: true
140
+ placeholder: 'nvapi-...'
141
+
121
142
  - id: zhipuglm
122
143
  name: Zhipu GLM
123
144
  baseUrl: https://open.bigmodel.cn/api/paas/v4
@@ -35,6 +35,19 @@ async function logToFile(message: string) {
35
35
  // Disabled log output for production
36
36
  }
37
37
 
38
+ /**
39
+ * Normalize a base URL that may already contain a /v1 suffix,
40
+ * then append /v1/<path> so that double /v1 segments are avoided.
41
+ *
42
+ * Example:
43
+ * "https://integrate.api.nvidia.com/v1" + "chat/completions"
44
+ * => "https://integrate.api.nvidia.com/v1/chat/completions" (not /v1/v1/...)
45
+ */
46
+ function v1Url(baseUrl: string, path: string): string {
47
+ const normalized = baseUrl.replace(/\/v1\/?$/, '')
48
+ return `${normalized}/v1/${path.replace(/^\//, '')}`
49
+ }
50
+
38
51
  function sendAnthropicError(message: string, _model: string | undefined, status = 502): Response {
39
52
  const fullMessage = `[Bingo Proxy] ${message}`
40
53
  void logToFile(`ERROR: ${fullMessage} (status: ${status})`)
@@ -176,7 +189,7 @@ async function handleAnthropicPassthrough(
176
189
  uiLabel: string | null = null,
177
190
  betaHeader: string | null = null,
178
191
  ): Promise<Response> {
179
- const url = `${baseUrl}/v1/messages`
192
+ const url = v1Url(baseUrl, 'messages')
180
193
  const upstreamHeaders: Record<string, string> = {
181
194
  'Content-Type': 'application/json',
182
195
  'x-api-key': apiKey,
@@ -226,7 +239,7 @@ async function handleOpenaiChat(
226
239
  uiLabel: string | null = null,
227
240
  ): Promise<Response> {
228
241
  const transformed = anthropicToOpenaiChat(body)
229
- const url = `${baseUrl}/v1/chat/completions`
242
+ const url = v1Url(baseUrl, 'chat/completions')
230
243
 
231
244
  const upstream = await fetch(url, {
232
245
  method: 'POST',
@@ -277,7 +290,7 @@ async function handleOpenaiResponses(
277
290
  uiLabel: string | null = null,
278
291
  ): Promise<Response> {
279
292
  const transformed = anthropicToOpenaiResponses(body)
280
- const url = `${baseUrl}/v1/responses`
293
+ const url = v1Url(baseUrl, 'responses')
281
294
 
282
295
  const upstream = await fetch(url, {
283
296
  method: 'POST',
@@ -9,6 +9,7 @@
9
9
  import * as fs from 'fs/promises'
10
10
  import * as path from 'path'
11
11
  import * as os from 'os'
12
+ import { parse as parseJsonc } from 'jsonc-parser/lib/esm/main.js'
12
13
  import { getDirectFetchOptions } from '../../utils/proxy.ts'
13
14
  import { ApiError } from '../middleware/errorHandler.js'
14
15
  import { anthropicToOpenaiChat } from '../proxy/transform/anthropicToOpenaiChat.js'
@@ -32,6 +33,10 @@ import type {
32
33
  } from '../types/provider.ts'
33
34
  import { SlotTableSchema } from '../types/provider.ts'
34
35
 
36
+ // NOTE: When the proxy is active, ANTHROPIC_AUTH_TOKEN is written as "proxy-managed"
37
+ // (a sentinel placeholder). This tells the proxy to resolve the real API key from the
38
+ // active provider config at request time. It is NOT a real credential and is expected
39
+ // to be overwritten every time a provider is activated or VS Code is linked.
35
40
  const MANAGED_ENV_KEYS = [
36
41
  'ANTHROPIC_BASE_URL',
37
42
  'ANTHROPIC_AUTH_TOKEN',
@@ -264,6 +269,18 @@ export class ProviderService {
264
269
  // --- Settings sync ---
265
270
 
266
271
  private async syncToSettings(provider: SavedProvider): Promise<void> {
272
+ // Prefer slot-based model IDs when slots are configured, so that the
273
+ // slot table (set via "Configure Model Slots" in the TUI) is the single
274
+ // source of truth. Falling back to provider.models only when no slots
275
+ // exist keeps things working for users who haven't configured slots yet.
276
+ const slots = await this.readSlots()
277
+ const hasSlots = Object.keys(slots).length > 0
278
+ if (hasSlots) {
279
+ await this.syncSettingsForSlots(slots)
280
+ return
281
+ }
282
+
283
+ // Legacy fallback: no slots configured — use provider.models directly
267
284
  const settings = await this.readSettings()
268
285
  const existingEnv = (settings.env as Record<string, string>) || {}
269
286
 
@@ -530,6 +547,14 @@ export class ProviderService {
530
547
 
531
548
  // --- VS Code linking ---
532
549
 
550
+ private buildBingoEnv(envVars: Array<{ name: string; value: string }>): Record<string, string> {
551
+ const env: Record<string, string> = {}
552
+ for (const v of envVars) {
553
+ env[v.name] = v.value
554
+ }
555
+ return env
556
+ }
557
+
533
558
  /**
534
559
  * Build the environmentVariables array for VS Code claudeCode settings
535
560
  * based on current slot configuration. Uses user-set labels or falls back
@@ -576,11 +601,24 @@ export class ProviderService {
576
601
  * Write claudeCode settings into ALL detected VS Code editor instances
577
602
  * (stable, Insiders, Cursor). Persists linked state in bingo settings so
578
603
  * it survives restarts.
604
+ *
605
+ * Fixes applied:
606
+ * #8 - Saves deep copy of original settings.claudeCode as vscodePreLinkClaudeCode
607
+ * so unlink can restore exactly what was there before.
608
+ * #9 - Collects failed paths; on partial failure rollback successful writes.
609
+ * #11 - Creates .bak backup before mutating VS Code settings.json; restores from
610
+ * .bak or in-memory copy on write failure.
579
611
  */
580
- async linkVscode(): Promise<{ paths: string[]; linked: boolean }> {
612
+ async linkVscode(): Promise<{ paths: string[]; linked: boolean; failedPaths?: string[] }> {
581
613
  const envVars = await this.buildVscodeEnvVars()
582
614
  const candidatePaths = getVscodeSettingsPaths()
583
615
  const writtenPaths: string[] = []
616
+ const failedPaths: string[] = []
617
+
618
+ // Fix #8: Snapshot original VS Code claudeCode per path for unlink restore.
619
+ const vscodePreLinkClaudeCode: Record<string, unknown> = {}
620
+ // Fix #9: Backup original contents for rollback on partial failure.
621
+ const originalContents = new Map<string, string | null>()
584
622
 
585
623
  for (const settingsPath of candidatePaths) {
586
624
  // Only write if the editor config directory exists (editor is installed)
@@ -592,12 +630,31 @@ export class ProviderService {
592
630
  }
593
631
 
594
632
  let settings: Record<string, unknown> = {}
633
+ let originalContent: string | null = null
595
634
  try {
596
- const raw = await fs.readFile(settingsPath, "utf-8")
597
- settings = JSON.parse(raw)
635
+ originalContent = await fs.readFile(settingsPath, "utf-8")
636
+ // VS Code settings.json uses JSONC (JSON with comments)
637
+ // parseJsonc handles comments/trailing commas, unlike JSON.parse
638
+ settings = parseJsonc(originalContent) as Record<string, unknown>
598
639
  } catch {
599
640
  // File does not exist yet — start fresh
600
641
  }
642
+ originalContents.set(settingsPath, originalContent)
643
+
644
+ // Fix #8: Save deep copy of original claudeCode before overwriting
645
+ vscodePreLinkClaudeCode[settingsPath] = settings.claudeCode !== undefined
646
+ ? JSON.parse(JSON.stringify(settings.claudeCode))
647
+ : undefined
648
+
649
+ // Fix #11: Write backup before mutating
650
+ if (originalContent !== null) {
651
+ const bakPath = `${settingsPath}.bak`
652
+ try {
653
+ await fs.writeFile(bakPath, originalContent, "utf-8")
654
+ } catch {
655
+ // best-effort backup — proceed without it
656
+ }
657
+ }
601
658
 
602
659
  settings.claudeCode = {
603
660
  ...(settings.claudeCode as Record<string, unknown> || {}),
@@ -606,9 +663,73 @@ export class ProviderService {
606
663
  environmentVariables: envVars,
607
664
  }
608
665
 
609
- await fs.mkdir(path.dirname(settingsPath), { recursive: true })
610
- await fs.writeFile(settingsPath, JSON.stringify(settings, null, 4) + '\n')
611
- writtenPaths.push(settingsPath)
666
+ try {
667
+ await fs.mkdir(path.dirname(settingsPath), { recursive: true })
668
+ const tmpPath = `${settingsPath}.tmp.${Date.now()}`
669
+ await fs.writeFile(tmpPath, JSON.stringify(settings, null, 4) + '\n')
670
+ await fs.rename(tmpPath, settingsPath)
671
+ writtenPaths.push(settingsPath)
672
+ } catch (err) {
673
+ failedPaths.push(settingsPath)
674
+ console.error(
675
+ `[ProviderService] Failed to write VS Code settings to ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`,
676
+ )
677
+
678
+ // Fix #11: Attempt restore from backup on write failure
679
+ if (originalContent !== null) {
680
+ try {
681
+ const tmpPath2 = `${settingsPath}.tmp.${Date.now()}`
682
+ await fs.writeFile(tmpPath2, originalContent, "utf-8")
683
+ await fs.rename(tmpPath2, settingsPath)
684
+ console.error(`[ProviderService] Restored ${settingsPath} from in-memory copy after write failure`)
685
+ } catch {
686
+ // In-memory restore failed, try .bak file
687
+ try {
688
+ await fs.copyFile(`${settingsPath}.bak`, settingsPath)
689
+ console.error(`[ProviderService] Restored ${settingsPath} from .bak after write failure`)
690
+ } catch (restoreErr) {
691
+ console.error(
692
+ `[ProviderService] Failed to restore ${settingsPath} after write failure: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)}`,
693
+ )
694
+ }
695
+ }
696
+ }
697
+ }
698
+ }
699
+
700
+ // Clean up .bak files for successfully written paths
701
+ for (const p of writtenPaths) {
702
+ await fs.unlink(`${p}.bak`).catch(() => {})
703
+ }
704
+
705
+ // Fix #9: Rollback successful writes on partial failure for atomicity.
706
+ // If any writes failed AND some succeeded, attempt to restore originals.
707
+ if (failedPaths.length > 0 && writtenPaths.length > 0) {
708
+ console.error(
709
+ `[ProviderService] Partial VS Code link failure: ${writtenPaths.length} succeeded, ${failedPaths.length} failed. Rolling back successful writes.`,
710
+ )
711
+ let allRolledBack = true
712
+ for (const p of writtenPaths) {
713
+ const original = originalContents.get(p)
714
+ try {
715
+ if (original !== null) {
716
+ const tmpPath = `${p}.tmp.${Date.now()}`
717
+ await fs.writeFile(tmpPath, original, "utf-8")
718
+ await fs.rename(tmpPath, p)
719
+ } else {
720
+ await fs.unlink(p).catch(() => {})
721
+ }
722
+ } catch (rollbackErr) {
723
+ allRolledBack = false
724
+ console.error(
725
+ `[ProviderService] Rollback failed for ${p}: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
726
+ )
727
+ }
728
+ }
729
+ if (allRolledBack) {
730
+ writtenPaths.length = 0
731
+ }
732
+ // If rollback itself failed we have a mess — leave writtenPaths as-is so caller sees what happened.
612
733
  }
613
734
 
614
735
  if (writtenPaths.length === 0) {
@@ -621,55 +742,49 @@ export class ProviderService {
621
742
  const bingoSettings = await this.readSettings()
622
743
  bingoSettings.vscodeLinked = true
623
744
  bingoSettings.vscodeLinkedPaths = writtenPaths
745
+ bingoSettings.vscodePreLinkClaudeCode = vscodePreLinkClaudeCode
746
+ bingoSettings.env = this.buildBingoEnv(envVars)
624
747
  await this.writeSettings(bingoSettings)
625
748
 
626
- return { paths: writtenPaths, linked: true }
749
+ const result: { paths: string[]; linked: boolean; failedPaths?: string[] } = {
750
+ paths: writtenPaths,
751
+ linked: true,
752
+ }
753
+ if (failedPaths.length > 0) {
754
+ result.failedPaths = failedPaths
755
+ }
756
+ return result
627
757
  }
628
758
 
629
759
  /**
630
- * Remove claudeCode keys from all previously-linked VS Code settings files.
631
- * Does NOT delete the entire file it only removes the claudeCode key.
760
+ * Reset VS Code settings to pre-link state. Does NOT touching bingo
761
+ * settings.env the CLI keeps routing through the proxy independently.
762
+ *
763
+ * Restores the original claudeCode snapshot (vscodePreLinkClaudeCode)
764
+ * when available, otherwise writes an override that points VS Code to
765
+ * api.anthropic.com so the extension shows its native login screen.
632
766
  */
633
767
  async unlinkVscode(): Promise<{ paths: string[]; linked: boolean }> {
634
768
  const bingoSettings = await this.readSettings()
635
769
  const linkedPaths = (bingoSettings.vscodeLinkedPaths as string[]) || []
636
770
 
771
+ // Restore original claudeCode snapshot per-path. Fall back to a clean
772
+ const preLinkClaudeCode =
773
+ (bingoSettings.vscodePreLinkClaudeCode as Record<string, unknown> | undefined) || {}
774
+
637
775
  for (const settingsPath of linkedPaths) {
638
776
  try {
639
777
  const raw = await fs.readFile(settingsPath, "utf-8")
640
- const settings = JSON.parse(raw)
641
- // Point away from bingo proxy to official Anthropic API.
642
- // env vars take precedence over ~/.claude/bingo/settings.json
643
- // so the spawned CLI does NOT route through bingo.
644
- settings.claudeCode = {
778
+ const settings = parseJsonc(raw) as Record<string, unknown>
779
+ settings.claudeCode = preLinkClaudeCode[settingsPath] ?? {
645
780
  preferredLocation: "panel",
646
- disableLoginPrompt: true,
647
781
  environmentVariables: [
648
782
  { name: "ANTHROPIC_BASE_URL", value: "https://api.anthropic.com" },
649
783
  ],
650
784
  }
651
- await fs.writeFile(settingsPath, JSON.stringify(settings, null, 4) + '
652
- ')
653
- } catch {
654
- // File may have been moved or deleted since linking — harmless
655
- }
656
- }
657
-
658
- bingoSettings.vscodeLinked = false
659
- delete bingoSettings.vscodeLinkedPaths
660
- await this.writeSettings(bingoSettings)
661
-
662
- return { paths: linkedPaths, linked: false }
663
- }> {
664
- const bingoSettings = await this.readSettings()
665
- const linkedPaths = (bingoSettings.vscodeLinkedPaths as string[]) || []
666
-
667
- for (const settingsPath of linkedPaths) {
668
- try {
669
- const raw = await fs.readFile(settingsPath, "utf-8")
670
- const settings = JSON.parse(raw)
671
- delete settings.claudeCode
672
- await fs.writeFile(settingsPath, JSON.stringify(settings, null, 4) + '\n')
785
+ const tmpPath = `${settingsPath}.tmp.${Date.now()}`
786
+ await fs.writeFile(tmpPath, JSON.stringify(settings, null, 4) + '\n')
787
+ await fs.rename(tmpPath, settingsPath)
673
788
  } catch {
674
789
  // File may have been moved or deleted since linking — harmless
675
790
  }
@@ -677,6 +792,8 @@ export class ProviderService {
677
792
 
678
793
  bingoSettings.vscodeLinked = false
679
794
  delete bingoSettings.vscodeLinkedPaths
795
+ delete bingoSettings.vscodePreLinkClaudeCode
796
+ delete bingoSettings.vscodePreLinkEnv
680
797
  await this.writeSettings(bingoSettings)
681
798
 
682
799
  return { paths: linkedPaths, linked: false }
@@ -703,12 +820,26 @@ export class ProviderService {
703
820
  if (status.linked) {
704
821
  try {
705
822
  await this.linkVscode()
706
- } catch {
707
- // VS Code may have been uninstalled — clear stale state
708
- const bingoSettings = await this.readSettings()
709
- bingoSettings.vscodeLinked = false
710
- delete bingoSettings.vscodeLinkedPaths
711
- await this.writeSettings(bingoSettings)
823
+ } catch (err: unknown) {
824
+ console.error(
825
+ `[ProviderService] init: failed to re-sync VS Code link: ${err instanceof Error ? err.message : String(err)}`,
826
+ )
827
+
828
+ // Only clear linked state for "VS Code not installed" type errors.
829
+ // Disk failures, corrupt JSON, etc. are transient and should not
830
+ // silently destroy the user's linked state.
831
+ const isNotFound =
832
+ err instanceof Error &&
833
+ (err.message.includes('No VS Code installation detected') ||
834
+ ((err as { statusCode?: number }).statusCode === 400))
835
+
836
+ if (isNotFound) {
837
+ const bingoSettings = await this.readSettings()
838
+ bingoSettings.vscodeLinked = false
839
+ delete bingoSettings.vscodeLinkedPaths
840
+ await this.writeSettings(bingoSettings)
841
+ }
842
+ // For other errors, keep existing state — the link may be recoverable.
712
843
  }
713
844
  }
714
845
  }
@@ -902,10 +1033,10 @@ export class ProviderService {
902
1033
  let transformedBody: unknown
903
1034
  if (format === 'openai_chat') {
904
1035
  transformedBody = anthropicToOpenaiChat(anthropicReq)
905
- upstreamUrl = `${base}/v1/chat/completions`
1036
+ upstreamUrl = v1Url(base, 'chat/completions')
906
1037
  } else {
907
1038
  transformedBody = anthropicToOpenaiResponses(anthropicReq)
908
- upstreamUrl = `${base}/v1/responses`
1039
+ upstreamUrl = v1Url(base, 'responses')
909
1040
  }
910
1041
 
911
1042
  // Call upstream with transformed request
@@ -952,6 +1083,15 @@ export class ProviderService {
952
1083
 
953
1084
  // ─── Helpers ───────────────────────────────────────────────
954
1085
 
1086
+ /**
1087
+ * Normalize a base URL that may already contain a /v1 suffix,
1088
+ * then append /v1/<path> so that double /v1 segments are avoided.
1089
+ */
1090
+ function v1Url(base: string, path: string): string {
1091
+ const normalized = base.replace(/\/v1\/?$/, '')
1092
+ return `${normalized}/v1/${path.replace(/^\//, '')}`
1093
+ }
1094
+
955
1095
  function buildDirectTestRequest(
956
1096
  base: string,
957
1097
  apiKey: string,
@@ -962,21 +1102,21 @@ function buildDirectTestRequest(
962
1102
 
963
1103
  if (format === 'openai_chat') {
964
1104
  return {
965
- url: `${base}/v1/chat/completions`,
1105
+ url: v1Url(base, 'chat/completions'),
966
1106
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
967
1107
  body: { model: modelId, max_tokens: 16, messages: [{ role: 'user', content: prompt }] },
968
1108
  }
969
1109
  }
970
1110
  if (format === 'openai_responses') {
971
1111
  return {
972
- url: `${base}/v1/responses`,
1112
+ url: v1Url(base, 'responses'),
973
1113
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
974
1114
  body: { model: modelId, max_output_tokens: 16, input: [{ type: 'message', role: 'user', content: prompt }] },
975
1115
  }
976
1116
  }
977
1117
  // anthropic
978
1118
  return {
979
- url: `${base}/v1/messages`,
1119
+ url: v1Url(base, 'messages'),
980
1120
  headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
981
1121
  body: { model: modelId, max_tokens: 16, messages: [{ role: 'user', content: prompt }] },
982
1122
  }
@@ -1008,5 +1148,4 @@ function validateResponseBody(
1008
1148
  return { ok: false, error: 'Not a valid Anthropic Messages endpoint' }
1009
1149
  }
1010
1150
  return { ok: true, model: (body.model as string) || undefined }
1011
- }
1012
-
1151
+ }
@@ -5,7 +5,7 @@ import { ConfigurableShortcutHint } from 'src/components/ConfigurableShortcutHin
5
5
  import { CtrlOToExpand, SubAgentProvider } from 'src/components/CtrlOToExpand.js';
6
6
  import { Byline } from 'src/components/design-system/Byline.js';
7
7
  import { KeyboardShortcutHint } from 'src/components/design-system/KeyboardShortcutHint.js';
8
- import type { z } from 'zod/v4';
8
+ import { z } from 'zod/v4';
9
9
  import { AgentProgressLine } from '../../components/AgentProgressLine.js';
10
10
  import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js';
11
11
  import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage.js';
@@ -15,14 +15,14 @@ import { MessageResponse } from '../../components/MessageResponse.js';
15
15
  import { ToolUseLoader } from '../../components/ToolUseLoader.js';
16
16
  import { Box, Text } from '../../ink.js';
17
17
  import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js';
18
- import { findToolByName, type Tools } from '../../Tool.js';
18
+ import { buildTool, findToolByName, toolMatchesName, type Tool, type Tools } from '../../Tool.js';
19
19
  import type { Message, ProgressMessage } from '../../types/message.js';
20
20
  import type { AgentToolProgress } from '../../types/tools.js';
21
21
  import { count } from '../../utils/array.js';
22
22
  import { getSearchOrReadFromContent, getSearchReadSummaryText } from '../../utils/collapseReadSearch.js';
23
23
  import { getDisplayPath } from '../../utils/file.js';
24
24
  import { formatDuration, formatNumber } from '../../utils/format.js';
25
- import { buildSubagentLookups, createAssistantMessage, EMPTY_LOOKUPS } from '../../utils/messages.js';
25
+ import { buildSubagentLookups, createAssistantMessage, EMPTY_LOOKUPS, type MessageLookups } from '../../utils/messages.js';
26
26
  import type { ModelAlias } from '../../utils/model/aliases.js';
27
27
  import { getMainLoopModel, parseUserSpecifiedModel, renderModelName } from '../../utils/model/model.js';
28
28
  import type { Theme, ThemeName } from '../../utils/theme.js';
@@ -32,6 +32,58 @@ import { getAgentColor } from './agentColorManager.js';
32
32
  import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js';
33
33
  const MAX_PROGRESS_MESSAGES_TO_SHOW = 3;
34
34
 
35
+ /**
36
+ * Create a minimal fallback Tool object for a sub-agent tool name not present
37
+ * in the coordinator's tool set. Used in brain mode where the coordinator only
38
+ * has [Agent, AskUserQuestion] but sub-agents use Read/Grep/Bash/Edit/etc.
39
+ * Without fallbacks, findToolByName returns null and renders blank lines.
40
+ */
41
+ function createFallbackTool(name: string): Tool {
42
+ return buildTool({
43
+ name,
44
+ inputSchema: z.object({}).passthrough(),
45
+ description: async () => `Sub-agent tool: ${name}`,
46
+ call: async () => ({ data: {} as unknown }) as any,
47
+ maxResultSizeChars: 100_000,
48
+ prompt: async () => '',
49
+ mapToolResultToToolResultBlockParam: (_content: unknown, toolUseID: string) => ({
50
+ tool_use_id: toolUseID,
51
+ content: [{ type: 'text' as const, text: '' }],
52
+ type: 'tool_result' as const,
53
+ }),
54
+ renderToolUseMessage: (input: Record<string, unknown>) => {
55
+ const keys = Object.keys(input);
56
+ if (keys.length === 0) return '';
57
+ return keys.map(k => {
58
+ const v = input[k];
59
+ let s = typeof v === 'string' ? v : JSON.stringify(v);
60
+ if (s.length > 80) s = s.slice(0, 80) + '\u2026';
61
+ return `${k}: ${s}`;
62
+ }).join(', ');
63
+ },
64
+ renderToolResultMessage: () => null,
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Given the result of buildSubagentLookups, create fallback Tool entries
70
+ * for any tool name not already in `tools`. Returns the original `tools`
71
+ * array if no new names are found. Avoids re-scanning progress data.
72
+ */
73
+ function augmentToolsFromLookups(
74
+ tools: Tools,
75
+ lookups: MessageLookups,
76
+ ): Tools {
77
+ const toolNames = new Set<string>()
78
+ for (const block of lookups.toolUseByToolUseID.values()) {
79
+ toolNames.add(block.name)
80
+ }
81
+ if (toolNames.size === 0) return tools
82
+ const missing = [...toolNames].filter(n => !tools.some(t => toolMatchesName(t, n)))
83
+ if (missing.length === 0) return tools
84
+ return [...tools, ...missing.map(createFallbackTool)]
85
+ }
86
+
35
87
  /**
36
88
  * Guard: checks if progress data has a `message` field (agent_progress or
37
89
  * skill_progress). Other progress types (e.g. bash_progress forwarded from
@@ -262,12 +314,12 @@ function VerboseAgentTranscript(t0) {
262
314
  lookups: agentLookups,
263
315
  inProgressToolUseIDs
264
316
  } = t1;
265
- let t2;
317
+ const augmentedTools = augmentToolsFromLookups(tools, agentLookups); let t2;
266
318
  if ($[2] !== agentLookups || $[3] !== inProgressToolUseIDs || $[4] !== progressMessages || $[5] !== tools || $[6] !== verbose) {
267
319
  const filteredMessages = progressMessages.filter(_temp4);
268
320
  let t3;
269
321
  if ($[8] !== agentLookups || $[9] !== inProgressToolUseIDs || $[10] !== tools || $[11] !== verbose) {
270
- t3 = progressMessage => <MessageResponse key={progressMessage.uuid} height={1}><MessageComponent message={progressMessage.data.message} lookups={agentLookups} addMargin={false} tools={tools} commands={[]} verbose={verbose} inProgressToolUseIDs={inProgressToolUseIDs} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} isTranscriptMode={false} isStatic={true} /></MessageResponse>;
322
+ t3 = progressMessage => <MessageResponse key={progressMessage.uuid} height={1}><MessageComponent message={progressMessage.data.message} lookups={agentLookups} addMargin={false} tools={augmentedTools} commands={[]} verbose={verbose} inProgressToolUseIDs={inProgressToolUseIDs} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} isTranscriptMode={false} isStatic={true} /></MessageResponse>;
271
323
  $[8] = agentLookups;
272
324
  $[9] = inProgressToolUseIDs;
273
325
  $[10] = tools;
@@ -384,6 +436,12 @@ export function renderToolResultMessage(data: Output, progressMessagesForMessage
384
436
  speed: null
385
437
  }
386
438
  });
439
+ const agentProgressData = progressMessagesForMessage
440
+ .filter((pm): pm is ProgressMessage<AgentToolProgress> => hasProgressMessage(pm.data))
441
+ .map(pm => pm.data)
442
+ const { lookups: resultLookups } = buildSubagentLookups(agentProgressData)
443
+ const augmentedTools = augmentToolsFromLookups(tools, resultLookups)
444
+
387
445
  return <Box flexDirection="column">
388
446
  {"external" === 'ant' && <MessageResponse>
389
447
  <Text color="warning">
@@ -394,13 +452,13 @@ export function renderToolResultMessage(data: Output, progressMessagesForMessage
394
452
  <AgentPromptDisplay prompt={prompt} theme={theme} />
395
453
  </MessageResponse>}
396
454
  {isTranscriptMode ? <SubAgentProvider>
397
- <VerboseAgentTranscript progressMessages={progressMessagesForMessage} tools={tools} verbose={verbose} />
455
+ <VerboseAgentTranscript progressMessages={progressMessagesForMessage} tools={augmentedTools} verbose={verbose} />
398
456
  </SubAgentProvider> : null}
399
457
  {isTranscriptMode && content && content.length > 0 && <MessageResponse>
400
458
  <AgentResponseDisplay content={content} theme={theme} />
401
459
  </MessageResponse>}
402
460
  <MessageResponse height={1}>
403
- <MessageComponent message={finalAssistantMessage} lookups={EMPTY_LOOKUPS} addMargin={false} tools={tools} commands={[]} verbose={verbose} inProgressToolUseIDs={new Set()} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} isTranscriptMode={false} isStatic={true} />
461
+ <MessageComponent message={finalAssistantMessage} lookups={EMPTY_LOOKUPS} addMargin={false} tools={augmentedTools} commands={[]} verbose={verbose} inProgressToolUseIDs={new Set()} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} isTranscriptMode={false} isStatic={true} />
404
462
  </MessageResponse>
405
463
  {!isTranscriptMode && <Text dimColor>
406
464
  {' '}
@@ -540,6 +598,13 @@ export function renderToolUseProgressMessage(progressMessages: ProgressMessage<P
540
598
  lookups: subagentLookups,
541
599
  inProgressToolUseIDs: collapsedInProgressIDs
542
600
  } = buildSubagentLookups(progressMessages.filter((pm): pm is ProgressMessage<AgentToolProgress> => hasProgressMessage(pm.data)).map(pm => pm.data));
601
+
602
+ // Augment tools with fallback entries for sub-agent tools not in the
603
+ // coordinator's tool set. In brain mode the coordinator only has
604
+ // [Agent, AskUserQuestion], but sub-agents call Read/Grep/Bash/Edit/etc.
605
+ // Without fallbacks, findToolByName returns null and renders blank lines.
606
+ const augmentedTools = augmentToolsFromLookups(tools, subagentLookups);
607
+
543
608
  return <MessageResponse>
544
609
  <Box flexDirection="column">
545
610
  <SubAgentProvider>
@@ -558,7 +623,7 @@ export function renderToolUseProgressMessage(progressMessages: ProgressMessage<P
558
623
  // content (tool not found, renderToolUseMessage returns null)
559
624
  // doesn't leave a blank line. Tool call headers are single-line
560
625
  // anyway so truncation isn't needed.
561
- return <MessageComponent key={processed.message.uuid} message={processed.message.data.message} lookups={subagentLookups} addMargin={false} tools={tools} commands={[]} verbose={verbose} inProgressToolUseIDs={collapsedInProgressIDs} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} style="condensed" isTranscriptMode={false} isStatic={true} />;
626
+ return <MessageComponent key={processed.message.uuid} message={processed.message.data.message} lookups={subagentLookups} addMargin={false} tools={augmentedTools} commands={[]} verbose={verbose} inProgressToolUseIDs={collapsedInProgressIDs} progressMessagesForMessage={[]} shouldAnimate={false} shouldShowDot={false} style="condensed" isTranscriptMode={false} isStatic={true} />;
562
627
  })}
563
628
  </SubAgentProvider>
564
629
  {hiddenToolUseCount > 0 && <Text dimColor>