bingocode 1.1.200-beta.9 → 1.1.201

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
 
@@ -584,17 +601,24 @@ export class ProviderService {
584
601
  * Write claudeCode settings into ALL detected VS Code editor instances
585
602
  * (stable, Insiders, Cursor). Persists linked state in bingo settings so
586
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.
587
611
  */
588
- async linkVscode(): Promise<{ paths: string[]; linked: boolean }> {
612
+ async linkVscode(): Promise<{ paths: string[]; linked: boolean; failedPaths?: string[] }> {
589
613
  const envVars = await this.buildVscodeEnvVars()
590
614
  const candidatePaths = getVscodeSettingsPaths()
591
615
  const writtenPaths: string[] = []
616
+ const failedPaths: string[] = []
592
617
 
593
- // Save pre-link bingo env so we can restore it on disconnect.
594
- const bingoSettings = await this.readSettings()
595
- const preLinkEnv = bingoSettings.env ?? null
596
- bingoSettings.vscodePreLinkEnv = preLinkEnv
597
- await this.writeSettings(bingoSettings)
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>()
598
622
 
599
623
  for (const settingsPath of candidatePaths) {
600
624
  // Only write if the editor config directory exists (editor is installed)
@@ -606,12 +630,31 @@ export class ProviderService {
606
630
  }
607
631
 
608
632
  let settings: Record<string, unknown> = {}
633
+ let originalContent: string | null = null
609
634
  try {
610
- const raw = await fs.readFile(settingsPath, "utf-8")
611
- 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>
612
639
  } catch {
613
640
  // File does not exist yet — start fresh
614
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
+ }
615
658
 
616
659
  settings.claudeCode = {
617
660
  ...(settings.claudeCode as Record<string, unknown> || {}),
@@ -620,9 +663,73 @@ export class ProviderService {
620
663
  environmentVariables: envVars,
621
664
  }
622
665
 
623
- await fs.mkdir(path.dirname(settingsPath), { recursive: true })
624
- await fs.writeFile(settingsPath, JSON.stringify(settings, null, 4) + '\n')
625
- 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.
626
733
  }
627
734
 
628
735
  if (writtenPaths.length === 0) {
@@ -632,45 +739,52 @@ export class ProviderService {
632
739
  }
633
740
 
634
741
  // Persist so re-sync on restart and unlink work correctly
742
+ const bingoSettings = await this.readSettings()
635
743
  bingoSettings.vscodeLinked = true
636
744
  bingoSettings.vscodeLinkedPaths = writtenPaths
745
+ bingoSettings.vscodePreLinkClaudeCode = vscodePreLinkClaudeCode
637
746
  bingoSettings.env = this.buildBingoEnv(envVars)
638
747
  await this.writeSettings(bingoSettings)
639
748
 
640
- 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
641
757
  }
642
758
 
643
759
  /**
644
- * Restore pre-link bingo env so the CLI no longer routes through the proxy,
645
- * and reset VS Code settings to a clean state without disableLoginPrompt.
760
+ * Reset VS Code settings to pre-link state. Does NOT touching bingo
761
+ * settings.env — the CLI keeps routing through the proxy independently.
646
762
  *
647
- * Without this, the only way to stop VS Code from using bingo would be to
648
- * delete ~/.claude/bingo/settings.json entirely — which would also kill CLI
649
- * routing. Saving/restoring the pre-link env keeps CLI and VS Code independent.
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.
650
766
  */
651
767
  async unlinkVscode(): Promise<{ paths: string[]; linked: boolean }> {
652
768
  const bingoSettings = await this.readSettings()
653
769
  const linkedPaths = (bingoSettings.vscodeLinkedPaths as string[]) || []
654
770
 
655
- // Restore bingo env to pre-link state
656
- const preLinkEnv = bingoSettings.vscodePreLinkEnv
657
- if (preLinkEnv !== undefined) {
658
- if (preLinkEnv === null || Object.keys(preLinkEnv as Record<string, unknown>).length === 0) {
659
- delete bingoSettings.env
660
- } else {
661
- bingoSettings.env = preLinkEnv
662
- }
663
- delete bingoSettings.vscodePreLinkEnv
664
- }
771
+ // Restore original claudeCode snapshot per-path. Fall back to a clean
772
+ const preLinkClaudeCode =
773
+ (bingoSettings.vscodePreLinkClaudeCode as Record<string, unknown> | undefined) || {}
665
774
 
666
775
  for (const settingsPath of linkedPaths) {
667
776
  try {
668
777
  const raw = await fs.readFile(settingsPath, "utf-8")
669
- const settings = JSON.parse(raw)
670
- settings.claudeCode = {
778
+ const settings = parseJsonc(raw) as Record<string, unknown>
779
+ settings.claudeCode = preLinkClaudeCode[settingsPath] ?? {
671
780
  preferredLocation: "panel",
781
+ environmentVariables: [
782
+ { name: "ANTHROPIC_BASE_URL", value: "https://api.anthropic.com" },
783
+ ],
672
784
  }
673
- 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)
674
788
  } catch {
675
789
  // File may have been moved or deleted since linking — harmless
676
790
  }
@@ -678,6 +792,8 @@ export class ProviderService {
678
792
 
679
793
  bingoSettings.vscodeLinked = false
680
794
  delete bingoSettings.vscodeLinkedPaths
795
+ delete bingoSettings.vscodePreLinkClaudeCode
796
+ delete bingoSettings.vscodePreLinkEnv
681
797
  await this.writeSettings(bingoSettings)
682
798
 
683
799
  return { paths: linkedPaths, linked: false }
@@ -704,12 +820,26 @@ export class ProviderService {
704
820
  if (status.linked) {
705
821
  try {
706
822
  await this.linkVscode()
707
- } catch {
708
- // VS Code may have been uninstalled — clear stale state
709
- const bingoSettings = await this.readSettings()
710
- bingoSettings.vscodeLinked = false
711
- delete bingoSettings.vscodeLinkedPaths
712
- 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.
713
843
  }
714
844
  }
715
845
  }
@@ -903,10 +1033,10 @@ export class ProviderService {
903
1033
  let transformedBody: unknown
904
1034
  if (format === 'openai_chat') {
905
1035
  transformedBody = anthropicToOpenaiChat(anthropicReq)
906
- upstreamUrl = `${base}/v1/chat/completions`
1036
+ upstreamUrl = v1Url(base, 'chat/completions')
907
1037
  } else {
908
1038
  transformedBody = anthropicToOpenaiResponses(anthropicReq)
909
- upstreamUrl = `${base}/v1/responses`
1039
+ upstreamUrl = v1Url(base, 'responses')
910
1040
  }
911
1041
 
912
1042
  // Call upstream with transformed request
@@ -953,6 +1083,15 @@ export class ProviderService {
953
1083
 
954
1084
  // ─── Helpers ───────────────────────────────────────────────
955
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
+
956
1095
  function buildDirectTestRequest(
957
1096
  base: string,
958
1097
  apiKey: string,
@@ -963,21 +1102,21 @@ function buildDirectTestRequest(
963
1102
 
964
1103
  if (format === 'openai_chat') {
965
1104
  return {
966
- url: `${base}/v1/chat/completions`,
1105
+ url: v1Url(base, 'chat/completions'),
967
1106
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
968
1107
  body: { model: modelId, max_tokens: 16, messages: [{ role: 'user', content: prompt }] },
969
1108
  }
970
1109
  }
971
1110
  if (format === 'openai_responses') {
972
1111
  return {
973
- url: `${base}/v1/responses`,
1112
+ url: v1Url(base, 'responses'),
974
1113
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
975
1114
  body: { model: modelId, max_output_tokens: 16, input: [{ type: 'message', role: 'user', content: prompt }] },
976
1115
  }
977
1116
  }
978
1117
  // anthropic
979
1118
  return {
980
- url: `${base}/v1/messages`,
1119
+ url: v1Url(base, 'messages'),
981
1120
  headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
982
1121
  body: { model: modelId, max_tokens: 16, messages: [{ role: 'user', content: prompt }] },
983
1122
  }
@@ -1009,5 +1148,4 @@ function validateResponseBody(
1009
1148
  return { ok: false, error: 'Not a valid Anthropic Messages endpoint' }
1010
1149
  }
1011
1150
  return { ok: true, model: (body.model as string) || undefined }
1012
- }
1013
-
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>